From 8fdac824fd251d7eb98c43d55b60a9a679663388 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:28:22 +0200 Subject: [PATCH] feat(packaging): ship Linux embedded MPV frame-copy runtime (#1200) * docs: design Linux frame-copy packaging * docs: plan Linux frame-copy packaging * feat(packaging): define Linux frame-copy profiles * fix(packaging): reject inherited profile names * feat(embedded-mpv): validate staged Linux runtime * fix(embedded-mpv): require Linux source packages * fix(embedded-mpv): harden Linux runtime staging * feat(embedded-mpv): build LGPL Linux runtime * fix(embedded-mpv): pin Linux runtime inputs * feat(embedded-mpv): build relocatable Linux helper * fix(embedded-mpv): require bundled Linux runtime * fix(embedded-mpv): make Linux runtime portable * feat(packaging): ship Linux frame-copy artifacts * fix(embedded-mpv): verify Linux helper linkage * fix(packaging): enforce Linux frame-copy isolation * fix(embedded-mpv): pin Linux display data * docs(embedded-mpv): document Linux frame-copy packaging * feat(embedded-mpv): probe Linux frame-copy runtime * test(embedded-mpv): smoke packaged Linux frame-copy * docs(embedded-mpv): clarify Linux system runtime baseline * fix(embedded-mpv): harden Linux runtime capability gate * ci: verify Linux frame-copy packages * test(embedded-mpv): harden packaged Linux smoke * test(embedded-mpv): preserve packaged GL mode * test(packaging): harden Linux package probes * fix(embedded-mpv): enable private Snap shared memory * fix(embedded-mpv): sanitize Linux helper environment * fix(packaging): enforce private Snap memory semantics * fix(packaging): reject ambiguous Snap memory metadata * fix(embedded-mpv): prioritize trusted Snap GL * fix(packaging): reject advanced Snap YAML semantics * fix(packaging): reject arbitrary Snap YAML aliases * feat(packaging): ship Linux runtime license notices * docs(embedded-mpv): document Linux runtime distribution * fix(packaging): parse Snap trailing comments safely * fix(release): gate Snap publish on public source release * fix(packaging): strip VCS metadata from source bundle * docs(packaging): clarify Linux source release gate * test(embedded-mpv): smoke missing bundled libmpv * style(embedded-mpv): format final validation inputs * fix(e2e): satisfy fixture index signature typing * fix(ci): declare fontconfig gperf generator * fix(embedded-mpv): hash runtime cache identities * fix(packaging): harden Linux frame-copy delivery * fix(packaging): tighten runtime delivery gates * fix(ci): decouple Linux runtime matrix * fix(packaging): harden Linux frame-copy delivery * fix(packaging): validate Linux frame-copy runtimes * fix(packaging): scope Snap Electron library checks * feat(packaging): ship Linux frame-copy runtimes * fix(packaging): improve Linux runtime smoke diagnostics * fix(packaging): expose bounded helper probe details * test(packaging): trace Snap EGL probe failures * fix(packaging): prefer core22 ABI in Snap helper * fix(packaging): bound helper probe capture * fix(packaging): harden Linux frame-copy releases * fix(packaging): canonicalize libplacebo submodule identity * fix(packaging): make source archive inspection portable * fix(packaging): harden Snap release verification --- .github/workflows/build-and-make.yaml | 815 ++++++-- .github/workflows/publish-snap.yaml | 269 +++ .gitignore | 1 + ...linux-embedded-mpv-frame-copy-packaging.md | 90 + AGENTS.md | 139 +- CLAUDE.md | 128 +- .../playwright.packaged.config.ts | 27 + apps/electron-backend-e2e/project.json | 20 + .../src/electron-test-fixtures.ts | 182 +- ...pv-frame-copy-packaged-filesystem.tests.ts | 288 +++ ...dded-mpv-frame-copy-packaged-filesystem.ts | 255 +++ ...d-mpv-frame-copy-packaged-fixtures.spec.ts | 191 ++ ...bedded-mpv-frame-copy-packaged-fixtures.ts | 344 +++ .../embedded-mpv-frame-copy-packaged.e2e.ts | 311 +++ apps/electron-backend/build-embedded-mpv.js | 544 ++++- .../embedded-mpv-linux-linkage.cjs | 340 +++ apps/electron-backend/native/binding.gyp | 10 +- .../native/helper/frame_helper_gl.h | 5 +- .../native/helper/mpv_frame_helper.cpp | 129 ++ apps/electron-backend/project.json | 30 +- apps/electron-backend/src/app/app.spec.ts | 32 +- ...edded-mpv-frame-copy-platform.util.spec.ts | 207 +- .../embedded-mpv-frame-copy-platform.util.ts | 96 +- .../embedded-mpv-frame-copy-runtime.ts | 16 + .../contracts.ts | 330 +++ .../development-manifest.spec.ts | 172 ++ .../flatpak-runtime.spec.ts | 148 ++ .../helper-environment.spec.ts | 369 ++++ .../helper-environment.ts | 281 +++ .../helper-failures.spec.ts | 338 +++ .../helper-launch.spec.ts | 203 ++ .../helper-launch.ts | 103 + .../manifest-policy.spec.ts | 149 ++ .../manifest-validator.ts | 267 +++ .../package-integrity.spec.ts | 246 +++ .../package-validator.ts | 238 +++ .../probe-orchestration.spec.ts | 391 ++++ .../embedded-mpv-frame-copy-runtime/probe.ts | 334 +++ .../runtime-closure-validator.ts | 95 + .../runtime-fixtures.test-helpers.ts | 306 +++ .../runtime-harness.test-helpers.ts | 74 + .../runtime.spec-data.ts | 179 ++ .../source-runtime-policy.spec.ts | 150 ++ .../source-runtime-validator.ts | 247 +++ .../trusted-snap-root.ts | 51 + .../embedded-mpv-frame-copy-runtime/types.ts | 103 + .../validation-primitives.ts | 162 ++ .../embedded-mpv-frame-copy.adapter.spec.ts | 348 ++- .../embedded-mpv-frame-copy.adapter.ts | 98 +- .../embedded-mpv-linux-linkage.spec.ts | 539 +++++ .../embedded-mpv-native-source.spec.ts | 297 ++- .../embedded-mpv-native.service.spec.ts | 121 +- .../services/embedded-mpv-native.service.ts | 66 +- .../embedded-mpv-runtime-diagnostic.spec.ts | 112 + .../embedded-mpv-runtime-diagnostic.ts | 36 + .../src/app/services/store.service.ts | 8 +- apps/electron-backend/src/main.ts | 44 +- apps/electron-backend/tsconfig.spec.json | 29 +- docs/architecture/embedded-mpv-native.md | 495 ++++- ...linux-embedded-mpv-frame-copy-packaging.md | 658 ++++++ ...mbedded-mpv-frame-copy-packaging-design.md | 263 +++ electron-builder.json | 25 + .../src/lib/embedded-mpv-session.interface.ts | 9 +- package.json | 4 +- pnpm-lock.yaml | 3 + tools/embedded-mpv/README.md | 419 +++- tools/embedded-mpv/build-linux-runtime.cjs | 1693 +++++++++++++++ tools/embedded-mpv/build-linux-runtime.mjs | 865 ++++++++ .../embedded-mpv/build-linux-runtime.test.mjs | 1638 +++++++++++++++ .../generate-linux-runtime-notices.cjs | 753 +++++++ .../generate-linux-runtime-notices.test.mjs | 338 +++ tools/embedded-mpv/linux-runtime-manifest.cjs | 999 +++++++++ .../linux-runtime-manifest.test.mjs | 1185 +++++++++++ .../linux-source-archive-contract.cjs | 181 ++ .../linux-source-archive-contract.d.cts | 26 + tools/embedded-mpv/runtime-probe-contract.cjs | 6 + .../embedded-mpv/runtime-probe-contract.d.cts | 6 + tools/embedded-mpv/stage-runtime.mjs | 396 +++- tools/packaging/asar-dependency-closure.mjs | 222 +- .../asar-dependency-closure.test.mjs | 223 ++ .../configure-linux-frame-copy-build.mjs | 263 +++ .../configure-linux-frame-copy-build.test.mjs | 866 ++++++++ tools/packaging/electron-after-pack.cjs | 175 +- .../electron-package-identity.test.mjs | 60 +- tools/packaging/embedded-mpv-arch.test.mjs | 1339 +++++++++++- .../embedded-mpv-frame-copy-files.cjs | 562 ++++- tools/packaging/embedded-mpv-packaging.cjs | 1188 ++++++++++- tools/packaging/linux-frame-copy-profile.cjs | 100 + .../linux-frame-copy-profile.test.mjs | 212 ++ .../prepare-linux-runtime-source-snapshot.cjs | 753 +++++++ ...are-linux-runtime-source-snapshot.test.mjs | 776 +++++++ tools/packaging/project.json | 45 +- .../packaging/publish-snap-workflow.test.mjs | 507 +++++ tools/packaging/release-snap-assets.cjs | 701 +++++++ tools/packaging/release-snap-assets.test.mjs | 1649 +++++++++++++++ .../packaging/release-snap-source-binding.cjs | 1765 ++++++++++++++++ .../snap-workflow-policy.test-helpers.mjs | 564 +++++ .../validate-snap-release-boundary.mjs | 108 + .../verify-electron-package-layout.mjs | 79 +- .../verify-linux-frame-copy-runtime.mjs | 1744 ++++++++++++++++ .../verify-linux-frame-copy-runtime.test.mjs | 1860 +++++++++++++++++ vendor/embedded-mpv/README.md | 39 +- 102 files changed, 36064 insertions(+), 801 deletions(-) create mode 100644 .github/workflows/publish-snap.yaml create mode 100644 .plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md create mode 100644 apps/electron-backend-e2e/playwright.packaged.config.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts create mode 100644 apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts create mode 100644 apps/electron-backend/embedded-mpv-linux-linkage.cjs create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts create mode 100644 apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts create mode 100644 docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md create mode 100644 docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md create mode 100644 tools/embedded-mpv/build-linux-runtime.cjs create mode 100644 tools/embedded-mpv/build-linux-runtime.mjs create mode 100644 tools/embedded-mpv/build-linux-runtime.test.mjs create mode 100644 tools/embedded-mpv/generate-linux-runtime-notices.cjs create mode 100644 tools/embedded-mpv/generate-linux-runtime-notices.test.mjs create mode 100644 tools/embedded-mpv/linux-runtime-manifest.cjs create mode 100644 tools/embedded-mpv/linux-runtime-manifest.test.mjs create mode 100644 tools/embedded-mpv/linux-source-archive-contract.cjs create mode 100644 tools/embedded-mpv/linux-source-archive-contract.d.cts create mode 100644 tools/embedded-mpv/runtime-probe-contract.cjs create mode 100644 tools/embedded-mpv/runtime-probe-contract.d.cts create mode 100644 tools/packaging/configure-linux-frame-copy-build.mjs create mode 100644 tools/packaging/configure-linux-frame-copy-build.test.mjs create mode 100644 tools/packaging/linux-frame-copy-profile.cjs create mode 100644 tools/packaging/linux-frame-copy-profile.test.mjs create mode 100644 tools/packaging/prepare-linux-runtime-source-snapshot.cjs create mode 100644 tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs create mode 100644 tools/packaging/publish-snap-workflow.test.mjs create mode 100644 tools/packaging/release-snap-assets.cjs create mode 100644 tools/packaging/release-snap-assets.test.mjs create mode 100644 tools/packaging/release-snap-source-binding.cjs create mode 100644 tools/packaging/snap-workflow-policy.test-helpers.mjs create mode 100644 tools/packaging/validate-snap-release-boundary.mjs create mode 100644 tools/packaging/verify-linux-frame-copy-runtime.mjs create mode 100644 tools/packaging/verify-linux-frame-copy-runtime.test.mjs diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index bd0abee32..96395ba6e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -12,11 +12,300 @@ on: workflow_dispatch: jobs: - build: + linux-embedded-mpv-runtime: + name: Build pinned Linux Embedded MPV runtime + runs-on: ubuntu-22.04 + timeout-minutes: 120 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Resolve Linux runtime toolchain cache key + id: linux-runtime-cache-key + shell: bash + run: | + set -euo pipefail + + sudo apt-get update + { + apt-cache policy \ + binutils build-essential cmake curl git gperf \ + libasound2-dev libdrm-dev libegl-dev libgbm-dev \ + libgl-dev libpulse-dev libva-dev make nasm \ + ninja-build patchelf perl pkg-config python3-pip \ + tar xz-utils + 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/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}" + + - name: Restore pinned Linux runtime and immutable source inputs + id: linux-runtime-cache + uses: actions/cache@v4 + with: + path: | + vendor/embedded-mpv/linux-x64/include + vendor/embedded-mpv/linux-x64/lib + vendor/embedded-mpv/linux-x64/runtime-manifest.json + dist/linux-frame-copy-runtime-source-inputs + key: ${{ steps.linux-runtime-cache-key.outputs.key }} + + - name: Install pinned Linux runtime build dependencies + if: steps.linux-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + sudo apt-get install --no-install-recommends -y \ + binutils \ + build-essential \ + cmake \ + curl \ + git \ + gperf \ + libasound2-dev \ + libdrm-dev \ + libegl-dev \ + libgbm-dev \ + libgl-dev \ + libpulse-dev \ + libva-dev \ + make \ + nasm \ + ninja-build \ + patchelf \ + perl \ + pkg-config \ + python3-pip \ + tar \ + xz-utils + python3 -m pip install --user 'meson==1.7.2' + + - name: Build and stage pinned LGPL Linux runtime + if: steps.linux-runtime-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + + export PATH="${HOME}/.local/bin:${PATH}" + export IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-build" + export RUNTIME_PREFIX="${RUNNER_TEMP}/linux-frame-copy-runtime-prefix" + + node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}" + node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}" + + export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" + rm -rf "${SOURCE_INPUT_ROOT}" + mkdir -p \ + "${SOURCE_INPUT_ROOT}/archives" \ + "${SOURCE_INPUT_ROOT}/git" + + git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ + submodule foreach --recursive git clean -ffdqx + git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ + clean -ffdqx + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/" + cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo" + node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \ + --runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \ + --source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \ + --output-root "${SOURCE_INPUT_ROOT}/license-inputs" + + - name: Generate Linux runtime notices and assemble source compliance + shell: bash + run: | + set -euo pipefail + + export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64" + export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" + export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources" + export LIBPLACEBO_SOURCE_RECORD="${RUNNER_TEMP}/libplacebo-source-record.json" + + test -f "${RUNTIME_ROOT}/runtime-manifest.json" + test -d "${SOURCE_INPUT_ROOT}/archives" + test -d "${SOURCE_INPUT_ROOT}/git/libplacebo" + test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json" + + rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}" + node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \ + --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ + --license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \ + --output-root "${RUNTIME_ROOT}/notices" + + mkdir -p \ + "${SOURCE_BUNDLE_ROOT}/archives" \ + "${SOURCE_BUNDLE_ROOT}/git" \ + "${SOURCE_BUNDLE_ROOT}/license-inputs" \ + "${SOURCE_BUNDLE_ROOT}/metadata" \ + "${SOURCE_BUNDLE_ROOT}/notices" \ + "${SOURCE_BUNDLE_ROOT}/tooling" + + cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/" + cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/" + cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/" + cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json" + node tools/packaging/prepare-linux-runtime-source-snapshot.cjs prepare \ + --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ + --checkout "${SOURCE_INPUT_ROOT}/git/libplacebo" \ + --output "${SOURCE_BUNDLE_ROOT}/git/libplacebo" \ + --record-output "${LIBPLACEBO_SOURCE_RECORD}" + cp \ + 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 \ + "${SOURCE_BUNDLE_ROOT}/tooling/" + test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt" + test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json" + git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt" + git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch" + node <<'NODE' + 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') + ); + const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives'); + const archives = fs.readdirSync(archivesDirectory).sort().map((name) => { + const contents = fs.readFileSync(path.join(archivesDirectory, name)); + return { + name, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }; + }); + const expectedArchiveHashes = Object.values(manifest.packages) + .map(({ sourceSha256 }) => sourceSha256) + .filter(Boolean) + .sort(); + const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort(); + if ( + new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || + new Set(actualArchiveHashes).size !== actualArchiveHashes.length || + archives.length !== expectedArchiveHashes.length || + JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes) + ) { + throw new Error( + 'Source bundle archives must match the exact unique pinned archive hash set.' + ); + } + + const libplacebo = JSON.parse( + fs.readFileSync(process.env.LIBPLACEBO_SOURCE_RECORD, 'utf8') + ); + if ( + libplacebo.sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit || + JSON.stringify(libplacebo.sourceSubmodules) !== + JSON.stringify(manifest.packages.libplacebo.sourceSubmodules) + ) { + 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( + path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + const repositoryRevision = fs + .readFileSync( + path.join( + process.env.SOURCE_BUNDLE_ROOT, + 'metadata', + 'iptvnator-git-revision.txt' + ), + 'utf8' + ) + .trim(); + fs.writeFileSync( + path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'), + `${JSON.stringify( + { + schemaVersion: 3, + repositoryRevision, + sourcePackages: manifest.packages, + archives, + libplacebo, + legal: { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: notices.noticeFile, + packages: notices.packages, + }, + }, + null, + 2 + )}\n` + ); + NODE + ( + cd "${SOURCE_BUNDLE_ROOT}/archives" + sha256sum * > "../metadata/archive-sha256.txt" + ) + node tools/packaging/prepare-linux-runtime-source-snapshot.cjs assert-vcs-free \ + --directory "${SOURCE_BUNDLE_ROOT}" + + mkdir -p dist/compliance + rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz + tar \ + --create \ + --xz \ + --sort=name \ + --mtime='UTC 1970-01-01' \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --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 + with: + name: linux-embedded-mpv-runtime + path: vendor/embedded-mpv/linux-x64 + if-no-files-found: error + retention-days: 7 + + - name: Upload Linux runtime source compliance + uses: actions/upload-artifact@v4 + with: + name: linux-frame-copy-runtime-sources + path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz + if-no-files-found: error + retention-days: 7 + + build-cross-platform: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: + fail-fast: false matrix: include: # macOS builds - separate runners to avoid native module conflicts @@ -32,26 +321,14 @@ jobs: embedded_mpv_platform: darwin embedded_mpv_arch: arm64 embedded_mpv_build_runtime: true - # Linux and Windows - - os: linux - runner: ubuntu-22.04 - linux_profile: standard - embedded_mpv_platform: linux - embedded_mpv_arch: x64 - embedded_mpv_build_runtime: false - - os: linux - runner: ubuntu-24.04 - linux_profile: flatpak - embedded_mpv_platform: linux - embedded_mpv_arch: x64 - embedded_mpv_build_runtime: false - os: windows runner: windows-2022 + arch: x64 embedded_mpv_platform: win32 embedded_mpv_arch: x64 embedded_mpv_build_runtime: false - steps: + steps: &electron-build-steps - name: Checkout code uses: actions/checkout@v4 @@ -68,38 +345,50 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev + sudo apt-get install --no-install-recommends -y \ + appstream \ + binutils \ + dbus-daemon \ + flatpak \ + flatpak-builder \ + libarchive-tools \ + libegl-dev \ + libgbm-dev \ + libgl-dev \ + libx11-dev \ + libxext-dev \ + mpv \ + pkg-config \ + rpm \ + snapd \ + squashfs-tools \ + xauth \ + xvfb + + - name: Configure Flatpak build runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' + run: | + set -euo pipefail - # Configure Flatpak - # 1. Add the Flathub repository (source of runtimes) flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo - - # 2. Install the standard Freedesktop Platform and SDK (required by electron-builder) - # We install version 24.08 as a safe default, electron-builder might pick what it needs flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 - name: Select Linux packaging targets for CI profile if: matrix.os == 'linux' run: | - node -e " - const fs = require('fs'); - const path = 'electron-builder.json'; - const config = JSON.parse(fs.readFileSync(path, 'utf8')); - const targets = Array.isArray(config.linux?.target) ? config.linux.target : []; - const profile = '${{ matrix.linux_profile }}'; - - if (profile === 'standard') { - config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak'); - } else if (profile === 'flatpak') { - config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak'); - } - - fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n'); - " + cp electron-builder.json "${RUNNER_TEMP}/electron-builder.base.json" + node tools/packaging/configure-linux-frame-copy-build.mjs --profile "${{ matrix.linux_profile }}" - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Download pinned Linux Embedded MPV runtime + if: matrix.os == 'linux' + uses: actions/download-artifact@v4 + with: + name: linux-embedded-mpv-runtime + path: vendor/embedded-mpv/linux-x64 + - name: Inject TMDB API key # No-op when the secret is unavailable (e.g. fork PRs) — the # app then requires a user-provided key for TMDB enrichment. @@ -113,12 +402,12 @@ jobs: - name: Resolve embedded MPV runtime cache key # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') id: embedded-mpv-runtime-cache-key shell: bash env: IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }} - IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c + IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0 run: | set -euo pipefail @@ -186,7 +475,7 @@ jobs: - name: Restore embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') id: embedded-mpv-runtime-cache uses: actions/cache/restore@v4 with: @@ -199,7 +488,7 @@ jobs: - name: Clear stale embedded MPV runtime files # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. - if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' + if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail @@ -229,8 +518,8 @@ jobs: env: IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || '' }} IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }} - IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-06-14-7d245fd100/mpv-dev-lgpl-x86_64-20260614-git-7d245fd100.7z - IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c + IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z + IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0 run: | set -euo pipefail @@ -254,47 +543,11 @@ jobs: pnpm embedded-mpv:stage-runtime:windows-archive -- "${WINDOWS_RUNTIME_URL}" "${WINDOWS_RUNTIME_SHA256}" - - name: Stage Linux embedded MPV build inputs - if: matrix.os == 'linux' - shell: bash - run: | - set -euo pipefail - - RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix" - rm -rf "${RUNTIME_PREFIX}" - mkdir -p "${RUNTIME_PREFIX}/include" - - cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/" - - LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)" - MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)" - export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION - node <<'NODE' - const fs = require('fs'); - const path = require('path'); - - const manifest = { - linuxBackend: 'process-isolated mpv --wid', - buildInputs: { - libmpvDevPackage: process.env.LIBMPV_DEV_VERSION, - mpvPackage: process.env.MPV_VERSION, - }, - sourceDistribution: - 'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.', - }; - - fs.writeFileSync( - path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'), - `${JSON.stringify(manifest, null, 2)}\n` - ); - NODE - - pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}" - - name: Build backend env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }} run: pnpm run build:backend @@ -331,27 +584,29 @@ 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 !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }" - if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then - echo "::error::Linux embedded MPV packages must not bundle libmpv" - find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print - exit 1 - fi - if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then - echo "::error::Linux embedded MPV addon must not link directly to libmpv" - ldd dist/apps/electron-backend/native/embedded_mpv.node - exit 1 - fi - # The frame-copy helper is the inverse: a separate process - # that MUST link libmpv (dev-mode engine; stripped from - # packages until the bundled-runtime staging lands). - # test -f, not -x: the webpack dist asset copy drops file - # modes; consumers restore the bit (after-pack) or require - # it via the X_OK support probe. + 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 - if ! ldd dist/apps/electron-backend/native/iptvnator_mpv_helper | grep -q 'libmpv'; then - echo "::error::Linux frame-copy helper must link libmpv" - ldd dist/apps/electron-backend/native/iptvnator_mpv_helper + test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node + if readelf -d dist/apps/electron-backend/native/embedded_mpv.node | grep -Eq 'Shared library:.*libmpv\.so'; then + echo "::error::Linux embedded MPV addon must not link directly to libmpv" + readelf -d dist/apps/electron-backend/native/embedded_mpv.node + exit 1 + fi + if readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node | grep -Eq 'Shared library:.*libmpv\.so'; then + echo "::error::Linux frame reader must not link directly to libmpv" + readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node + exit 1 + fi + HELPER_DYNAMIC="$(readelf -d dist/apps/electron-backend/native/iptvnator_mpv_helper)" + if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Eq 'Shared library: \[libmpv\.so\.2\]'; then + echo "::error::Linux frame-copy helper must need libmpv.so.2" + printf '%s\n' "${HELPER_DYNAMIC}" + exit 1 + fi + if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Fq 'Library runpath: [$ORIGIN/lib]'; then + echo "::error::Linux frame-copy helper must keep only the relative runtime path" + printf '%s\n' "${HELPER_DYNAMIC}" exit 1 fi ;; @@ -557,6 +812,7 @@ jobs: env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # TEMPORARY PR TEST: change this back to '0' after manually # testing the macOS PR artifact with Embedded MPV included. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }} @@ -567,11 +823,250 @@ jobs: env: PACKAGE_OS: ${{ matrix.os }} PACKAGE_ARCH: ${{ matrix.arch || matrix.embedded_mpv_arch || '' }} + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }} run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH" + - name: Make marker-only foreign-architecture DEB packages + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + env: + IPTVNATOR_EMBEDDED_MPV_PLATFORM: linux + IPTVNATOR_EMBEDDED_MPV_ARCH: x64 + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: '' + IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1' + run: | + set -euo pipefail + + for foreign_arch in armv7l arm64; do + rm -rf dist/executables-linux-foreign + cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json + node tools/packaging/configure-linux-frame-copy-build.mjs \ + --foreign-deb \ + --foreign-arch "${foreign_arch}" + pnpm nx run electron-backend:make \ + --arch="${foreign_arch}" \ + --outputPath=dist/executables-linux-foreign \ + --publishPolicy=never + mapfile -t foreign_debs < <( + find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' -print + ) + test "${#foreign_debs[@]}" -eq 1 + case "${foreign_arch}" in + armv7l) expected_deb_arch=armhf ;; + arm64) expected_deb_arch=arm64 ;; + *) + echo "::error::Unexpected foreign DEB build architecture ${foreign_arch}" + exit 1 + ;; + esac + actual_deb_arch="$(dpkg-deb --field "${foreign_debs[0]}" Architecture)" + test "${actual_deb_arch}" = "${expected_deb_arch}" + mv "${foreign_debs[0]}" dist/executables/ + done + + - name: Verify DEB payloads and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + found=false + for artifact in dist/executables/*.deb; do + test -f "${artifact}" || continue + found=true + case "$(dpkg-deb --field "${artifact}" Architecture)" in + amd64) + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.deb:ro" \ + --workdir /workspace \ + ubuntu:24.04 \ + bash -euo pipefail -c ' + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ + binutils libegl1 libgbm1 libgl1 libgl1-mesa-dri libmpv2 \ + nodejs squashfs-tools xauth xvfb + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.deb --profile system + ' + ;; + arm64|armhf) + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile system + ;; + *) + echo "::error::Unexpected DEB architecture in ${artifact}" + exit 1 + ;; + esac + done + test "${found}" = true + + - name: Verify RPM payload and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + artifact="$(find dist/executables -maxdepth 1 -type f -name '*.rpm' -print -quit)" + test -n "${artifact}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.rpm:ro" \ + --workdir /workspace \ + fedora:latest \ + bash -euo pipefail -c ' + dnf install -y \ + binutils bsdtar libglvnd-egl libglvnd-glx mesa-dri-drivers \ + mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \ + xorg-x11-xauth + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.rpm --profile system + ' + + - name: Verify Pacman payload and x64 system runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'system' + shell: bash + run: | + set -euo pipefail + + artifact="$(find dist/executables -maxdepth 1 -type f \( -name '*.pacman' -o -name '*.pkg.tar.*' \) -print -quit)" + test -n "${artifact}" + docker run --rm \ + --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ + --volume "$(realpath "${artifact}"):/artifact.pacman:ro" \ + --workdir /workspace \ + archlinux:latest \ + bash -euo pipefail -c ' + pacman -Syu --noconfirm \ + binutils libarchive libglvnd mesa mpv nodejs xorg-server-xvfb \ + xorg-xauth + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact /artifact.pacman --profile system + ' + + - name: Verify AppImage payloads and bundled runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + shell: bash + run: | + set -euo pipefail + + found=false + for artifact in dist/executables/*.AppImage; do + test -f "${artifact}" || continue + found=true + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile portable + done + test "${found}" = true + + - name: Verify Snap payloads and strict-confinement runtime + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + shell: bash + run: | + set -euo pipefail + + found=false + installed_x64=false + for artifact in dist/executables/*.snap; do + test -f "${artifact}" || continue + found=true + verification="$( + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${artifact}" --profile portable \ + 2>&1 | tee /dev/stderr + )" + if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then + snap list mesa-core22 >/dev/null 2>&1 || sudo snap install mesa-core22 + snap list gnome-3-28-1804 >/dev/null 2>&1 || sudo snap install gnome-3-28-1804 + sudo snap install --dangerous "${artifact}" + installed_x64=true + fi + done + test "${found}" = true + test "${installed_x64}" = true + sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 + sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804 + sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22 + snap connections iptvnator | awk \ + '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }' + set +e + disconnected_probe="$( + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + snap run iptvnator --embedded-mpv-runtime-probe 2>&1 + )" + disconnected_status=$? + set -e + printf '%s\n' "${disconnected_probe}" + test "${disconnected_status}" -eq 1 + printf '%s\n' "${disconnected_probe}" | \ + grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}' + sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 + snap connections iptvnator | awk \ + '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }' + snap connections iptvnator | awk \ + '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }' + snap connections iptvnator | awk \ + '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }' + xvfb-run -a env \ + LIBGL_ALWAYS_SOFTWARE=1 \ + IPTVNATOR_TRACE_PLAYER=1 \ + EGL_LOG_LEVEL=debug \ + LIBGL_DEBUG=verbose \ + __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ + GBM_BACKEND=/tmp/hostile-gbm \ + MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ + LIBVA_DRIVER_NAME=/tmp/hostile-va \ + VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \ + VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \ + VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \ + VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \ + VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \ + VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \ + VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \ + XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \ + XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \ + XDG_DATA_HOME=/tmp/hostile-xdg-data-home \ + XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \ + snap run iptvnator --embedded-mpv-runtime-probe + + - name: Run packaged x64 frame-copy and fallback smoke + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + env: + IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' + IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator + LIBGL_ALWAYS_SOFTWARE: '1' + run: | + xvfb-run -a pnpm nx run \ + electron-backend-e2e:packaged-frame-copy-smoke \ + --skip-nx-cache + + - name: Diagnose packaged x64 frame-copy hardware path + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + continue-on-error: true + env: + IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' + IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator + run: | + set -euo pipefail + + if [ ! -e /dev/dri/renderD128 ]; then + echo "::notice::No /dev/dri/renderD128 is available; skipping the non-blocking hardware-path diagnostic." + exit 0 + fi + ls -la /dev/dri + xvfb-run -a pnpm nx run \ + electron-backend-e2e:packaged-frame-copy-smoke \ + --skip-nx-cache + - name: Save embedded MPV runtime cache # TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'` # after the macOS Embedded MPV artifacts are built and manually tested. @@ -584,7 +1079,7 @@ jobs: vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }} - - name: Smoke test packaged Flatpak launcher + - name: Verify Flatpak payload, launcher, and sandboxed runtime if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' shell: bash run: | @@ -596,8 +1091,12 @@ jobs: exit 1 fi + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + node tools/packaging/verify-linux-frame-copy-runtime.mjs \ + --artifact "${FLATPAK_BUNDLE}" --profile flatpak flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}" - flatpak run --command=sh com.fourgray.iptvnator -c ' + xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + flatpak run --command=sh com.fourgray.iptvnator -c ' set -euo pipefail test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml @@ -609,6 +1108,10 @@ jobs: grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}" grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}" ' + xvfb-run -a dbus-run-session -- flatpak run \ + --env=LIBGL_ALWAYS_SOFTWARE=1 \ + com.fourgray.iptvnator \ + --embedded-mpv-runtime-probe - name: Upload artifacts (macOS) if: matrix.os == 'macos' @@ -622,23 +1125,31 @@ jobs: dist/executables/**/*.blockmap retention-days: 7 - - name: Upload artifacts (Linux) - if: matrix.os == 'linux' && matrix.linux_profile == 'standard' + - name: Upload system-runtime Linux artifacts + if: matrix.os == 'linux' && matrix.linux_profile == 'system' uses: actions/upload-artifact@v4 with: - name: linux-artifacts + name: linux-system-artifacts path: | - dist/executables/**/*.deb - dist/executables/**/*.rpm - dist/executables/**/*.snap - dist/executables/**/*.AppImage - dist/executables/**/*.tar.gz - dist/executables/**/*.pacman + dist/executables/*.deb + dist/executables/*.rpm + dist/executables/*.pacman + dist/executables/*.pkg.tar.* + retention-days: 7 + + - name: Upload portable-runtime Linux artifacts + if: matrix.os == 'linux' && matrix.linux_profile == 'portable' + uses: actions/upload-artifact@v4 + with: + name: linux-portable-artifacts + path: | + dist/executables/*.AppImage + dist/executables/*.snap dist/executables/**/latest-linux*.yml dist/executables/**/*.blockmap retention-days: 7 - - name: Upload artifacts (Flatpak) + - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' uses: actions/upload-artifact@v4 with: @@ -660,9 +1171,44 @@ jobs: dist/executables/**/*.blockmap retention-days: 7 + build-linux: + name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }}) + needs: linux-embedded-mpv-runtime + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - os: linux + runner: ubuntu-22.04 + arch: x64 + linux_profile: system + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + - os: linux + runner: ubuntu-22.04 + arch: x64 + linux_profile: portable + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + - os: linux + runner: ubuntu-24.04 + arch: x64 + linux_profile: flatpak + embedded_mpv_platform: linux + embedded_mpv_arch: x64 + embedded_mpv_build_runtime: false + + steps: *electron-build-steps + create-release: name: Create Draft Release - needs: build + needs: + - build-cross-platform + - build-linux if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest permissions: @@ -813,15 +1359,16 @@ jobs: artifacts/macos-arm64-artifacts/*-arm64.zip artifacts/macos-arm64-artifacts/*.blockmap artifacts/latest-mac.yml - artifacts/linux-artifacts/*.AppImage - artifacts/linux-artifacts/*.deb - artifacts/linux-artifacts/*.rpm - artifacts/linux-artifacts/*.snap - artifacts/linux-artifacts/*.tar.gz - artifacts/linux-artifacts/*.pacman - artifacts/linux-artifacts/latest-linux*.yml - artifacts/linux-artifacts/*.blockmap + artifacts/linux-system-artifacts/*.deb + artifacts/linux-system-artifacts/*.rpm + artifacts/linux-system-artifacts/*.pacman + artifacts/linux-system-artifacts/*.pkg.tar.* + artifacts/linux-portable-artifacts/*.AppImage + artifacts/linux-portable-artifacts/*.snap + artifacts/linux-portable-artifacts/latest-linux*.yml + artifacts/linux-portable-artifacts/*.blockmap artifacts/linux-flatpak-artifacts/*.flatpak + artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz artifacts/windows-artifacts/*-setup.exe artifacts/windows-artifacts/*.msi artifacts/windows-artifacts/*.zip @@ -829,31 +1376,3 @@ jobs: artifacts/windows-artifacts/*.blockmap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-snap: - name: Publish to Snapcraft Store - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} - - steps: - - name: Download snap artifact - uses: actions/download-artifact@v4 - with: - name: linux-artifacts - path: artifacts - - - name: Setup Snapcraft - uses: samuelmeuli/action-snapcraft@v3 - - - name: Publish all snaps to edge channel - run: | - # Find and publish all snap files - for SNAP_FILE in artifacts/*.snap; do - if [ -f "$SNAP_FILE" ]; then - echo "Publishing: $SNAP_FILE" - snapcraft upload --release=edge "$SNAP_FILE" - fi - done diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml new file mode 100644 index 000000000..b07fe5469 --- /dev/null +++ b/.github/workflows/publish-snap.yaml @@ -0,0 +1,269 @@ +name: Publish Snap after public release + +on: + release: + types: + - published + +permissions: + contents: read + +jobs: + 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: + 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@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 + + gh api \ + --paginate \ + --slurp \ + "repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \ + > "${RUNNER_TEMP}/snap-release-assets.json" + node tools/packaging/release-snap-assets.cjs select \ + --assets-json "${RUNNER_TEMP}/snap-release-assets.json" \ + --output-json "${RUNNER_TEMP}/selected-snap-release-assets.json" + + - name: Download exact public release assets + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads" + rm -rf "${ASSET_DIRECTORY}" + mkdir -p "${ASSET_DIRECTORY}" + node -e \ + "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \ + "${RUNNER_TEMP}/selected-snap-release-assets.json" | + while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do + gh api \ + --header "Accept: application/octet-stream" \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \ + > "${ASSET_DIRECTORY}/${ASSET_NAME}" + done + + - name: Verify downloaded public release assets + shell: bash + 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" \ + --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: Reverify sealed public release assets + 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)" + + - name: Bind verified release transfer + id: bind-transfer + shell: bash + run: | + set -euo pipefail + + RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json" + RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")" + RECEIPT_SHA256="${RECEIPT_RECORD%% *}" + [[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]] + printf 'receipt-sha256=%s\n' "${RECEIPT_SHA256}" >> "${GITHUB_OUTPUT}" + + - name: Transfer verified release assets + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: verified-snap-release-assets + path: /var/lib/iptvnator-snap-release/assets + if-no-files-found: error + retention-days: 1 + compression-level: 0 + include-hidden-files: true + + publish-snap: + name: Publish verified public-release Snap to edge + needs: verify-snap + if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Download verified release assets + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: verified-snap-release-assets + path: ${{ runner.temp }}/verified-snap-release-assets + + - name: Seal transferred public release assets + shell: bash + env: + EXPECTED_RECEIPT_SHA256: ${{ needs.verify-snap.outputs.receipt-sha256 }} + run: | + set -euo pipefail + + TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets" + SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release" + SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets" + test -d "${TRANSFERRED_ASSET_DIRECTORY}" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}" + shopt -s nullglob dotglob + TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*) + TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap) + test "${#TRANSFERRED_SNAPS[@]}" -gt 0 + test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))" + test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz" + test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do + test -f "${ASSET_FILE}" + test ! -L "${ASSET_FILE}" + done + RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json" + RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")" + ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}" + [[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]] + test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}" + /usr/bin/jq --exit-status ' + type == "object" and + (keys == ["assets", "repositoryRevision", "schemaVersion"]) and + (.schemaVersion == 1) and + (.repositoryRevision | + type == "string" and test("^[a-f0-9]{40,64}$")) and + (.assets | type == "array" and length >= 2) and + (.assets | all(.[]; + type == "object" and + (keys == ["id", "name", "sha256", "size"]) and + (.id | + type == "number" and . > 0 and + . <= 9007199254740991 and . == floor) and + (.name | + type == "string" and length > 0 and + . != "." and . != ".." and + (contains("/") | not) and + (contains("\\") | not) and + (explode | all(.[]; . > 31 and . != 127))) and + (.sha256 | + type == "string" and test("^[a-f0-9]{64}$")) and + (.size | + type == "number" and . > 0 and + . <= 9007199254740991 and . == floor))) and + ([.assets[].name] | length == (unique | length)) and + ([.assets[] | + select(.name == "linux-frame-copy-runtime-sources.tar.xz")] | + length == 1) and + ([.assets[] | select(.name | endswith(".snap"))] | + length >= 1) and + (.assets | all(.[]; + .name == "linux-frame-copy-runtime-sources.tar.xz" or + (.name | endswith(".snap")))) + ' "${RECEIPT_PATH}" > /dev/null + RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "${RECEIPT_PATH}")" + test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))" + SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv" + CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt" + umask 077 + /usr/bin/jq --raw-output \ + '.assets[] | [.name, (.size | tostring)] | @tsv' \ + "${RECEIPT_PATH}" > "${SIZE_MANIFEST}" + while IFS=$'\t' read -r ASSET_NAME EXPECTED_SIZE; do + ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}" + ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")" + test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}" + done < "${SIZE_MANIFEST}" + /usr/bin/jq --raw-output \ + '.assets[] | "\(.sha256) \(.name)"' \ + "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}" + ( + cd "${TRANSFERRED_ASSET_DIRECTORY}" + /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}" + ) + rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}" + shopt -u nullglob dotglob + sudo test ! -e "${SEALED_ASSET_PARENT}" + sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}" + sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}" + sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}" + sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} + + sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} + + sudo chmod 0555 "${SEALED_ASSET_PARENT}" + + - name: Install Snapcraft + shell: bash + run: | + set -euo pipefail + + sudo snap install snapcraft --classic --channel=stable + + - name: Publish all public-release snaps to edge + shell: bash + env: + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} + run: | + set -euo pipefail + + VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}" + unset SNAPCRAFT_STORE_CREDENTIALS + shopt -s nullglob dotglob + SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap) + test "${#SNAP_FILES[@]}" -gt 0 + for SNAP_FILE in "${SNAP_FILES[@]}"; do + SNAP_NAME="${SNAP_FILE##*/}" + echo "Publishing public release asset: ${SNAP_NAME}" + # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke. + # GitHub Actions never promotes automatically. + SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}" + done + unset STORE_CREDENTIALS + shopt -u nullglob dotglob diff --git a/.gitignore b/.gitignore index ea63467e3..c2263e287 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,5 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json vendor/embedded-mpv/*/bin/ vendor/embedded-mpv/*/include/ vendor/embedded-mpv/*/lib/ +vendor/embedded-mpv/*/notices/ vendor/embedded-mpv/*/runtime-manifest.json diff --git a/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md new file mode 100644 index 000000000..dd616baea --- /dev/null +++ b/.plans/2026-07-18-linux-embedded-mpv-frame-copy-packaging.md @@ -0,0 +1,90 @@ +# Linux Embedded MPV Frame-Copy Packaging Plan + +## Audited baseline + +- Linux frame-copy already has an isolated `iptvnator_mpv_helper`, a frame + reader addon, shared controls, native-view fallback, and runtime capability + probes. +- Existing packaged Linux builds intentionally remove the helper/runtime and + retain only system `mpv --wid` native-view. +- Electron, Electron libraries, `embedded_mpv.node`, and + `embedded_mpv_frame_reader.node` must never load or link libmpv. Only the + helper may link it. +- Electron Builder produces AppImage, DEB, RPM, Pacman, Snap, and Flatpak + Linux targets. The available reproducible native/runtime toolchain is x64. + +## Decisions + +1. Support official frame-copy artifacts on Linux x64 only. Keep every non-x64 + artifact marker-only and fail closed to native-view; never accept an + architecture override that injects x64 native files. +2. Use three isolated packaging profiles: + - `system`: DEB/RPM/Pacman use declared distribution libmpv/GL dependencies + and contain no private `native/lib`. + - `portable`: AppImage/Snap contain a pinned LGPL-compatible shared-library + closure with `$ORIGIN`-relative helper loading. + - `flatpak`: Flatpak contains the same pinned closure, validated in the + exact `/app` runtime context. +3. Treat the package manifest as necessary but insufficient. Frame-copy is + available only after exact manifest/schema/profile checks, executable-mode + checks, artifact hashes, dependency-closure/process-isolation checks, and a + bounded helper runtime probe. No environment flag bypasses this gate. +4. Publish exact source archives, recursive source identities, build flags, + licenses, notices, patches/tooling, and pinned display data for bundled + runtimes. Bind every bundled x64 package manifest to the final compliance + archive bytes and released repository revision. +5. Keep Snap Store credentials isolated from release-tag code on a fresh + runner. Store publication is edge-only; candidate/stable promotion remains + manual after installed-package smoke. + +## TDD implementation phases + +1. Add failing tests for target/profile partitioning, x64 and marker-only + layouts, exact dependency declarations, RPATH/SONAME rules, executable + modes, and Electron/libmpv isolation. +2. Implement profile-aware build and packaging hooks that stage the helper, + frame reader, runtime manifest, private closure where applicable, and legal + payload without weakening native-view. +3. Add failing runtime-policy tests for missing/tampered files, wrong + architecture/profile, malformed manifests, loader failures, hostile + environments, probe timeout/output bounds, and stable fallback reasons. +4. Implement one sanitized helper environment shared by probe and playback, + including Snap graphics-provider handling and Flatpak runtime paths. +5. Add failing compliance/release tests for exact recursive submodule records, + VCS-free source inventory, archive member/type layout, source checksums, + license/notices completeness, package-to-source byte binding, sealed asset + receipts, and credential boundaries. +6. Implement deterministic source generation, package bindings, static Snap + inspection, fresh-runner artifact transfer, and minimal direct Store + upload. +7. Add packaged x64 smoke for actual frame-copy playback plus missing-runtime + native-view fallback. Run fixture-contract tests before the smoke and allow + CI llvmpipe through Chromium's GPU blocklist without bypassing the runtime + gate. +8. Update canonical architecture/maintenance documentation and mirrored + `AGENTS.md`/`CLAUDE.md` contracts. + +## Acceptance and verification matrix + +- Local/macOS: + - Nx discovery + - packaging and Electron backend unit/integration tests + - packaged-smoke fixture tests + - affected lint targets + - production backend build + - formatting, syntax, and `git diff --check` +- Linux x64 CI: + - build the pinned runtime/helper/frame reader + - verify helper links/resolves libmpv and Electron/addons do not + - extract and statically validate all six package families + - run system, portable, Snap-installed, and Flatpak application probes + - run packaged frame-copy playback and missing-runtime native-view fallback + - regenerate and bind the exact compliance source archive +- Non-x64 CI: + - build selected ARM package targets independently + - require marker-only layout and absence of every x64 native/runtime artifact +- Merge gate: + - exact-head CI green + - no unresolved review findings + - fresh code review clean + - no automatic Snap promotion beyond edge diff --git a/AGENTS.md b/AGENTS.md index a8069f79e..a81b7cba1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ IPTVNATOR_TRACE_STARTUP=1 nx serve electron-backend - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and request-scoped DB events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in the main process and DB worker - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow lifecycle and unresponsive events - - `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output + - `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console output into the Electron terminal - GPU/compositor debugging: @@ -216,6 +216,143 @@ Key files: - Canonical docs: `docs/architecture/player-controls-contract.md` and `docs/architecture/embedded-mpv-native.md` +## Linux Embedded MPV Packaging + +- Official Linux frame-copy artifacts are x64-only. AppImage, DEB, RPM, + Pacman, Snap, and Flatpak are supported; non-x64 Linux packages must remain + marker-only and must never inherit x64 native artifacts from environment + overrides. +- Packaging runs three isolated profiles: + - `system`: DEB/RPM/Pacman, no private `native/lib`, with package + dependencies DEB=`libmpv2,libegl1,libgl1,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and + Pacman=`mpv,libglvnd,mesa` + - `portable`: AppImage/Snap with the pinned LGPL-compatible closure + - `flatpak`: Flatpak with the same pinned closure +- The DEB system-runtime contract is Ubuntu 24.04+ (`libmpv2`). Ubuntu 22.04 + provides `libmpv1`, so use the x64 AppImage on Jammy instead of weakening the + package dependency or advertising frame-copy without a compatible runtime. +- Only `iptvnator_mpv_helper` may link libmpv. The Electron executable, + Electron libraries, `embedded_mpv.node`, and + `embedded_mpv_frame_reader.node` must not load or link it. Preserve this + process-isolation contract in build, package, and smoke checks. +- `electron-backend/native{,/**/*}` is excluded from `app.asar`; `afterPack` + exclusively writes the profile-normalized unpacked native tree. Layout and + final-artifact checks must reject every archived + `/electron-backend/native/**` entry so system and marker-only packages cannot + hide stale x64 artifacts. +- Packaged addon, frame-reader, and helper discovery is package-owned + `app.asar.unpacked` only. Writable cwd/dist candidates are development-only + and must never satisfy packaged native-view support or the frame-copy gate. +- Pristine afterPack/unpacked layouts scan Electron libraries recursively. + Extracted Snap payloads exclude only the package-manager `lib/**` and + `usr/lib/**` trees that Snap overlays into the same root; every other + directory remains recursive, and Electron-library symlinks still fail + closed. +- Linux frame-copy availability is fail-closed. The packaged manifest, + artifact modes, declared bundled hashes/closure, and bounded + `--runtime-probe` must all succeed before frame-copy can relax the renderer + sandbox. Any failure reports a stable reason and falls back to native-view + without crashing; an environment flag never bypasses this gate. +- Snap is `core22`/strict and uses an exact private `shared-memory` plug plus + the `graphics-core22` content plug at an empty mode-0755 `$SNAP/graphics`, + with `mesa-core22` as default provider. It declares only the canonical + provider layouts: `/usr/share/libdrm` binds from + `$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to + `$SNAP/graphics/drirc.d`. The provider is external shared content, not part + of IPTVnator's package size, source archive, or notices. Installed-Snap CI + must prove controlled unavailable exit after disconnect, then reconnect and + prove success. The helper links `libGL.so.1` rather than `libOpenGL.so.0`. +- The probe and playback helper share one sanitized loader environment: + ambient audit, preload, library, graphics-driver, and shell-startup overrides + are removed; the validated private closure wins; trusted Snap GL, + `graphics-core22`, the core22 base x64 root, and exact GNOME-platform roots + precede generic in-snap roots. The core22 base must precede GNOME so its + `libedit.so.2` cannot be replaced by the older copy requiring + `libtinfo.so.5`. The extracted-artifact verifier removes the identical + unsafe loader/graphics/shell set before direct helper smoke while preserving + feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the + wrapper `PATH`, removes exported `BASH_FUNC_*` functions, and launches + probe/playback through the regular executable + `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or + disconnected provider returns `snap-graphics-provider-unavailable` before + helper spawn. The packaging-only `--embedded-mpv-runtime-probe` app switch + runs the complete cached manifest/hash/helper gate before BrowserWindow + startup and exits with one availability JSON line. A nonzero helper exit + keeps top-level reason `helper-probe-failed`; `helperReason` is 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 + 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; + trace-write failure cannot change the capability result. Installed-Snap CI + enables Mesa EGL/GL diagnostics through this bounded channel. Any loader + failure remains a stable native-view fallback, never a flag-enabled success. +- In the exact packaged Flatpak `/app` context, reconstruct only Freedesktop + Platform 24.08's immutable `__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS`; its GL + 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 + `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and the exact + `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 the exact pinned six recursive libplacebo submodule + records. Each record is canonical `full-commit safe/path`; clone-depth + dependent `git describe` annotations are discarded and never form part of + the provenance identity. 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. + That public-release boundary independently revalidates the exact strict + `meta/snap.yaml` graphics/shared-memory contract and enumerates + `resources/app.asar`, rejecting any archived + `electron-backend/native/**` payload before publication. Its bounded ASAR + header reader uses only Node built-ins and released local tooling, so the + clean tag checkout does not require `node_modules`. + 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`. + ## Repo Skills - `iptvnator-ui-design` diff --git a/CLAUDE.md b/CLAUDE.md index a640accb1..7b404d099 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,7 +127,7 @@ Useful narrower flags: - `IPTVNATOR_TRACE_DB=1` traces DB worker requests and DB progress events - `IPTVNATOR_TRACE_SQL=1` traces SQLite statements in both main and worker connections - `IPTVNATOR_TRACE_WINDOW=1` traces BrowserWindow navigation/load lifecycle -- `IPTVNATOR_TRACE_PLAYER=1` traces external-player launch/reuse/polling debug output +- `IPTVNATOR_TRACE_PLAYER=1` traces external-player activity and bounded Embedded MPV runtime-probe stderr - `IPTVNATOR_TRACE_RENDERER_CONSOLE=1` mirrors renderer console logs into the Electron terminal For GPU/compositor debugging: @@ -617,7 +617,131 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Built-in web players: HTML5+hls.js, Video.js, and ArtPlayer - External players: MPV, VLC (via IPC to Electron backend) - Embedded MPV (experimental, macOS/Windows/Linux): renders mpv video inside the Electron window through a native addon. macOS uses the libmpv render API in an `NSOpenGLView`; Windows uses in-process libmpv with `--wid` against an app-owned child `HWND`; Linux spawns an out-of-process `mpv --wid=` controlled over a JSON IPC socket (X11/XWayland only, requires system `mpv` on PATH; subtitles/speed/aspect/recording are not exported there). mpv's own screensaver inhibition does not apply to any of these paths, so `EmbeddedMpvNativeService` holds an Electron `powerSaveBlocker` (`prevent-display-sleep`) whenever any session's status is `playing`, and releases it on pause, dispose, or shutdown. Service: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts`; full architecture: `docs/architecture/embedded-mpv-native.md`. -- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy engine` (restart required) or `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV experiment flag): a per-session helper renders mpv offscreen at viewport size (headless CGL on macOS, headless EGL on Linux, WGL against a hidden window on Windows) and publishes BGRA frames into a shm ring (POSIX shm; a `Local\` named file mapping on Windows); the preload frame pump uploads them onto a renderer ``, so controls/dialogs are ordinary DOM above the video. Frame-copy is the first runtime consumer of shared `app-player-controls`: `PlayerControlsComponent` and its surface/shortcut/fullscreen collaborators own the DOM UI interactions, while the component-scoped `EmbeddedMpvControlsAdapter` maps session state and commands and coordinates correlated recording state; native-view retains the legacy fixed dock. Stored and explicit opt-ins relax the sandbox only while the base embedded-MPV feature is enabled and a platform-supported packaged runtime contains both the regular-file helper (`iptvnator_mpv_helper` / `.exe`) and readable regular frame-reader addon; packaged discovery is restricted to packaged resources. A disabled base experiment keeps embedded MPV unavailable with the sandbox intact, while a missing, mode-stripped, or incomplete frame-copy runtime falls back to the native engine without relaxing the sandbox. On Linux the engine is dev-build-only for now: the helper links system libmpv (build deps: `libmpv-dev`, `libegl-dev`, `libgl-dev`, `libopengl-dev`, `libgbm-dev`) and is stripped from packages until bundled-runtime staging lands. On Windows the helper links vendored libmpv and package validation requires the exact MPV DLL named in the helper's PE import table beside the executable. Backend process adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; helper: `apps/electron-backend/native/helper/`; details in `docs/architecture/embedded-mpv-native.md` ("Frame-Copy Engine"). +- Embedded MPV frame-copy engine (experimental, macOS Apple Silicon + Linux + x64 + Windows; enabled via `Settings > Playback > Embedded MPV: frame-copy +engine` (restart required) or + `IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1` on top of the embedded MPV + experiment flag): a per-session helper renders mpv offscreen (CGL on macOS, + EGL on Linux, WGL on Windows), publishes BGRA frames into a shm ring, and the + preload frame pump uploads them to + ``. Shared `app-player-controls` owns the DOM + UI; native-view retains the legacy dock. On Linux, only + `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the + addon, and frame reader must not. Pristine afterPack/unpacked layouts scan + Electron libraries recursively; extracted Snap payloads exclude only the + package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root. + Every other directory remains recursive, and Electron-library symlinks still + fail closed. `electron-backend/native{,/**/*}` is excluded from `app.asar`; + `afterPack` alone owns the profile-normalized unpacked native tree, and + package checks reject every archived `/electron-backend/native/**` entry. + Packaged addon, frame-reader, and helper discovery uses only package-owned + `app.asar.unpacked` paths; cwd/dist candidates remain development-only. + Official x64 packages use three separate profiles: + DEB/RPM/Pacman depend on system libmpv plus the helper's direct + EGL/GL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and + Flatpak bundles the same closure. Exact system dependencies are + DEB=`libmpv2,libegl1,libgl1,libgbm1`, + RPM=`mpv-libs,libglvnd-egl,libglvnd-glx,mesa-libgbm`, and + Pacman=`mpv,libglvnd,mesa`. The DEB contract is verified on Ubuntu 24.04+; + Ubuntu 22.04 users need the x64 AppImage because Jammy provides `libmpv1`. + ARM packages are marker-only. Stored or explicit opt-ins cannot bypass the + fail-closed packaged manifest/file/hash gate and bounded `--runtime-probe`; + any failure keeps the sandbox enabled, records a stable reason, and falls + back to native-view without crashing. Snap is `core22`/strict and uses an + exact private `shared-memory` plug plus the `graphics-core22` content plug at + a real empty mode-0755 `$SNAP/graphics`, with external `mesa-core22` as the + default provider. Its only provider-data layouts bind `/usr/share/libdrm` + from `$SNAP/graphics/libdrm` and symlink `/usr/share/drirc.d` to + `$SNAP/graphics/drirc.d`. Installed-Snap CI requires controlled unavailable + status after disconnect, then reconnects and requires success. The helper + links `libGL.so.1`, and probe/playback share a sanitized loader environment + in which ambient audit, preload, library, graphics-driver, and shell-startup + overrides are removed; the validated private closure plus trusted host GL, + graphics-content, core22 base x64, and exact GNOME-platform roots have + explicit precedence. The core22 base stays ahead of GNOME so the older + `libedit.so.2` requiring `libtinfo.so.5` cannot shadow the base ABI. The + extracted-artifact verifier removes the identical unsafe loader/graphics/ + shell set before direct helper smoke while preserving selectors such as + `LIBGL_ALWAYS_SOFTWARE`. Snap fixes the wrapper `PATH`, + removes exported `BASH_FUNC_*` functions, and + launches probe/playback through the regular executable + `$SNAP/graphics/bin/graphics-core22-provider-wrapper`; a missing or + disconnected provider returns `snap-graphics-provider-unavailable` before + helper spawn. The packaging-only + `--embedded-mpv-runtime-probe` app switch runs the complete packaged gate + before BrowserWindow startup and emits one availability JSON line. A nonzero + helper exit keeps top-level reason `helper-probe-failed`; `helperReason` is + 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 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. + Installed-Snap CI enables Mesa EGL/GL diagnostics through this bounded + 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. 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 the exact pinned six recursive libplacebo + submodule records. Each record is canonical `full-commit safe/path`; + clone-depth dependent `git describe` annotations are discarded and never + form part of the provenance identity. 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. 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. That public-release boundary independently + revalidates the exact strict `meta/snap.yaml` graphics/shared-memory + contract and enumerates `resources/app.asar`, rejecting any archived + `electron-backend/native/**` payload before publication. Its bounded ASAR + header reader uses only Node built-ins and released local tooling, so the + clean tag checkout does not require `node_modules`. 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: + `libs/ui/playback/src/lib/embedded-mpv-player/embedded-mpv-controls.adapter.ts`; + helper: `apps/electron-backend/native/helper/`; canonical packaging/runtime + contracts: `docs/architecture/embedded-mpv-native.md` and + `tools/embedded-mpv/README.md`. - Shared player-controls layer: `libs/ui/playback/src/lib/player-controls/` exports the engine-neutral `PlayerController` contract, standalone `app-player-controls`, a generic web-video adapter/helper, and component-scoped `WEB_PLAYER_SHARED_CONTROLS` rollout token. Persisted `Settings.webPlayerSharedControls` is default-off, and its checkbox appears only when HTML5, Video.js, or ArtPlayer is selected. `WebPlayerViewComponent` snapshots the preference into the immutable token for each new player host. The parent `/workspace` route awaits the initial `SettingsStore` load, including cold-start direct links, before this snapshot can occur. Saving applies to the next host without an application restart; an existing session never changes controls mode in place. Embedded MPV ignores the web-player preference: frame-copy always uses shared DOM controls through `EmbeddedMpvControlsAdapter`, native-view retains its compositor-safe legacy dock, and external MPV/VLC retain their own UI. The Embedded MPV host selects exactly one controls UI for its reported engine. `showControls=false` detaches the shared surface, modal overlays gate frame-copy playback shortcuts, fullscreen remains DOM-based with Embedded MPV bounds sync, and a playback/session transition key prevents engine or session handoff from presenting stale recording feedback while timers and pending commands are cancelled. Same-session IPC replies yield to a broadcast snapshot received while the command was pending, so a successful recording acknowledgement cannot be rolled back by a stale reply. The built-in HTML5/hls.js player is the second guarded consumer: `HtmlVideoPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`, while its neutral `web-video-support` bridge is shared with ArtPlayer and owns HLS/native tracks, MPEG-TS VOD duration correction, caption preference, and source cleanup. `HtmlVideoElementSession` owns native video-event lifecycle, persisted volume, start-time/time/ended propagation, and legacy post-play caption suppression. Video.js is the third guarded consumer: `VjsPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; its bridge rebinds the current Tech video after `playerreset`, exposes source-stable audio/subtitle IDs, preserves caption preference and explicit subtitle-off state, and reads Video.js duration. Reset-driven raw MPEG-TS changes pause first, coalesce to the latest desired source, preserve actual volume across Video.js's reset, and restart when authoritative live/VOD metadata changes. In shared-controls mode, Video.js native controls, click/double-click/hotkey actions, and spatial navigation are disabled. ArtPlayer is the fourth guarded consumer: `ArtPlayerComponent` provides a component-scoped `WebVideoControlsAdapter`; `ArtPlayerSourceSession` owns HLS/MPEG-TS/native sources, the neutral web-video bridge, exact cleanup, and a destroyed-session guard for delayed `customType` callbacks, while `ArtPlayerVideoSession` owns native media/ArtPlayer events. Shared ArtPlayer mode uses authoritative live/VOD metadata, HLS/native tracks and caption preference, MPEG-TS VOD duration correction, and reapplies app volume directly after ArtPlayer restores its own stored volume. Vendor chrome/hotkeys are disabled, and a transparent capture layer gives shared controls exclusive click and double-click ownership. `WebPlayerViewComponent.resolvedIsLive` supplies authoritative metadata; visible playback diagnostics disable shared pointer/keyboard ownership and exit only the active HTML5, Video.js, or ArtPlayer shell's own fullscreen so retry/fallback actions remain visible. On the preference-off path, all three web players retain their existing controls, source behavior, and legacy series navigation. Contract: `docs/architecture/player-controls-contract.md`. - Shared web picture-in-picture stays inside that default-off rollout. `PlayerController` exposes capability `pictureInPicture`, state diff --git a/apps/electron-backend-e2e/playwright.packaged.config.ts b/apps/electron-backend-e2e/playwright.packaged.config.ts new file mode 100644 index 000000000..d5f67f26a --- /dev/null +++ b/apps/electron-backend-e2e/playwright.packaged.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; +import baseConfig from './playwright.config'; + +export default defineConfig({ + ...baseConfig, + outputDir: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke', + reporter: [ + ['list'], + [ + 'html', + { + outputFolder: + '../../dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke', + }, + ], + [ + 'json', + { + outputFile: + '../../dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/results.json', + }, + ], + ], + timeout: 120000, + webServer: [], +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 1e886ba34..cdaa4ec01 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -12,6 +12,26 @@ "e2e": { "dependsOn": ["electron-backend:build-e2e"] }, + "packaged-frame-copy-smoke": { + "dependsOn": ["test-packaged-frame-copy-fixtures"], + "executor": "nx:run-commands", + "cache": false, + "outputs": [ + "{workspaceRoot}/dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke", + "{workspaceRoot}/dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke" + ], + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec playwright test --config=playwright.packaged.config.ts src/embedded-mpv-frame-copy-packaged.e2e.ts" + } + }, + "test-packaged-frame-copy-fixtures": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "pnpm exec tsx --test src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index c0439b37f..a7c2f6c66 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -9,14 +9,18 @@ import { } from '@playwright/test'; import { createServer, Server } from 'http'; import { + accessSync, + constants as fsConstants, existsSync, mkdtempSync, + readdirSync, readFileSync, rmSync, + statSync, writeFileSync, } from 'fs'; import { tmpdir } from 'os'; -import { join, resolve } from 'path'; +import { dirname, join, resolve } from 'path'; export const workspaceRoot = resolve(__dirname, '../../..'); export const electronMainPath = join( @@ -70,7 +74,7 @@ type ElectronFixtures = { dataDir: string; }; -type LaunchElectronAppOptions = { +export type LaunchElectronAppOptions = { env?: Record; }; @@ -171,7 +175,142 @@ export async function launchElectronApp( }; } -function attachElectronProcessDiagnostics(electronApp: ElectronApplication): void { +/** + * Resolve the x64 unpacked Linux executable produced by electron-builder. + * An explicit path wins so CI can point at an AppImage/Flatpak extraction + * without relying on electron-builder's local output directory names. + */ +export function resolvePackagedLinuxExecutable( + explicitPath = process.env['IPTVNATOR_E2E_PACKAGED_EXECUTABLE'] +): string | undefined { + if (explicitPath?.trim()) { + return resolve(explicitPath.trim()); + } + + const executablesRoot = join(workspaceRoot, 'dist', 'executables'); + if (!existsSync(executablesRoot)) { + return undefined; + } + + const unpackedDirectories = readdirSync(executablesRoot, { + withFileTypes: true, + }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith('linux') && + entry.name.endsWith('-unpacked') && + !entry.name.includes('arm') + ) + .sort((left, right) => { + const leftPriority = left.name === 'linux-unpacked' ? 0 : 1; + const rightPriority = right.name === 'linux-unpacked' ? 0 : 1; + return ( + leftPriority - rightPriority || + left.name.localeCompare(right.name) + ); + }); + + for (const directory of unpackedDirectories) { + for (const executableName of ['IPTVnator', 'iptvnator']) { + const candidate = join( + executablesRoot, + directory.name, + executableName + ); + try { + accessSync(candidate, fsConstants.X_OK); + if (statSync(candidate).isFile()) { + return candidate; + } + } catch { + // Keep looking for the next unpacked x64 layout. + } + } + } + + return undefined; +} + +export function getPackagedLinuxNativeDir(executablePath: string): string { + return join( + dirname(resolve(executablePath)), + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); +} + +export function resolvePackagedElectronLaunchArgs( + getuid: (() => number) | undefined +): string[] { + 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 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, + dataDir: string, + options: LaunchElectronAppOptions = {} +): Promise { + if (process.platform !== 'linux') { + throw new Error( + 'The packaged embedded-MPV launcher is available on Linux only.' + ); + } + + const resolvedExecutablePath = resolve(executablePath); + try { + accessSync(resolvedExecutablePath, fsConstants.X_OK); + if (!statSync(resolvedExecutablePath).isFile()) { + throw new Error('not a regular file'); + } + } catch (error) { + throw new Error( + `Packaged Linux executable is not a regular executable file at ${resolvedExecutablePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const electronApp = await electron.launch({ + executablePath: resolvedExecutablePath, + args: resolvePackagedElectronLaunchArgs(process.getuid), + env: { + ...process.env, + IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS: + process.env['IPTVNATOR_ALLOW_PRIVATE_NETWORK_URLS'] ?? '1', + ...options.env, + ELECTRON_IS_DEV: '0', + IPTVNATOR_E2E_DATA_DIR: dataDir, + NODE_ENV: 'test', + }, + }); + attachElectronProcessDiagnostics(electronApp); + + const mainWindow = await findMainWindow(electronApp); + await waitForAppReady(mainWindow); + + return { + electronApp, + mainWindow, + }; +} + +function attachElectronProcessDiagnostics( + electronApp: ElectronApplication +): void { if (!process.env['CI']) { return; } @@ -235,10 +374,7 @@ async function waitForPromiseWithTimeout( return await Promise.race([ promise.then(() => true), new Promise((resolvePromise) => { - timeoutId = setTimeout( - () => resolvePromise(false), - timeoutMs - ); + timeoutId = setTimeout(() => resolvePromise(false), timeoutMs); }), ]); } finally { @@ -297,11 +433,11 @@ async function waitForAppReady(page: Page): Promise { } catch (error) { const diagnostics = await page.evaluate(() => ({ appRootLength: - document.querySelector('app-root')?.innerHTML.trim().length ?? 0, + document.querySelector('app-root')?.innerHTML.trim().length ?? + 0, baseHref: - document - .querySelector('base') - ?.getAttribute('href') ?? '', + document.querySelector('base')?.getAttribute('href') ?? + '', readyState: document.readyState, title: document.title, url: location.href, @@ -357,7 +493,7 @@ export async function importM3uPlaylistFromNativeDialog( const fileInput = dialog.locator('input[type="file"][name="playlist"]'); await fileInput.evaluate((element, selectedFilePath) => { - (element as HTMLInputElement).dataset.filePathOverride = + (element as HTMLInputElement).dataset['filePathOverride'] = selectedFilePath; }, filePath); await fileInput.setInputFiles(filePath); @@ -501,15 +637,17 @@ async function clickDialogMethodOption( label: RegExp, legacySelector?: string ): Promise { - const optionByRadio = dialog - .getByRole('radio', { name: label }) - .first(); + const optionByRadio = dialog.getByRole('radio', { name: label }).first(); if ((await optionByRadio.count()) > 0) { await optionByRadio.click(); return; } - for (const tablistLabel of ['Source method', 'Playlist category', 'M3U source']) { + for (const tablistLabel of [ + 'Source method', + 'Playlist category', + 'M3U source', + ]) { const tablist = dialog .locator(`[role="tablist"][aria-label="${tablistLabel}"]`) .first(); @@ -541,9 +679,7 @@ async function clickDialogMethodOption( } if (!legacySelector) { - throw new Error( - `Could not find dialog option matching ${label}.` - ); + throw new Error(`Could not find dialog option matching ${label}.`); } await dialog.locator(legacySelector).click(); @@ -704,9 +840,7 @@ export function buildM3uContent(channels: M3uTestChannel[]): string { const attributes = [ channel.tvgId ? `tvg-id="${channel.tvgId}"` : '', channel.tvgCountry ? `tvg-country="${channel.tvgCountry}"` : '', - channel.tvgLanguage - ? `tvg-language="${channel.tvgLanguage}"` - : '', + channel.tvgLanguage ? `tvg-language="${channel.tvgLanguage}"` : '', channel.tvgName ? `tvg-name="${channel.tvgName}"` : '', channel.logo ? `tvg-logo="${channel.logo}"` : '', channel.groupTitle ? `group-title="${channel.groupTitle}"` : '', @@ -889,9 +1023,7 @@ export async function switchUnifiedCollectionContent( await clickButtonToggleOption(toggleGroup, contentLabel); } -export async function clearCurrentUnifiedCollection( - page: Page -): Promise { +export async function clearCurrentUnifiedCollection(page: Page): Promise { await page .getByRole('button', { name: /Clear .* (favorites|recently viewed)/i, diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts new file mode 100644 index 000000000..191f7688d --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.tests.ts @@ -0,0 +1,288 @@ +import assert = require('node:assert/strict'); +import { + chmodSync, + existsSync, + lstatSync, + mkdtempSync, + mkdirSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, it } from 'node:test'; +import { + createDisposablePackagedLinuxApp, + createPackagedEntryGuard, + readPackagedRuntimeIdentity, +} from './embedded-mpv-frame-copy-packaged-filesystem'; + +const temporaryDirectories = new Set(); + +type PackageFixture = { + executablePath: string; + libmpvAliasPath: string; + libmpvSonamePath: string; + nativeDir: string; + packageRoot: string; + payloadPath: string; + runtimeManifestPath: string; +}; + +function createPackageFixture(): PackageFixture { + const packageRoot = mkdtempSync( + join(tmpdir(), 'iptvnator-packaged-fixture-source-') + ); + temporaryDirectories.add(packageRoot); + const executablePath = join(packageRoot, 'IPTVnator'); + const nativeDir = join( + packageRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const payloadPath = join(packageRoot, 'resources', 'payload.bin'); + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + const runtimeLibraryDir = join(nativeDir, 'lib'); + const libmpvSonamePath = join(runtimeLibraryDir, 'libmpv.so.2'); + const libmpvAliasPath = join(runtimeLibraryDir, 'libmpv.so'); + + mkdirSync(runtimeLibraryDir, { recursive: true }); + writeFileSync(executablePath, '#!/bin/sh\nexit 0\n'); + chmodSync(executablePath, 0o755); + writeFileSync(payloadPath, 'packaged payload'); + chmodSync(payloadPath, 0o640); + writeFileSync(libmpvSonamePath, 'packaged libmpv'); + symlinkSync('libmpv.so.2', libmpvAliasPath); + writeFileSync( + runtimeManifestPath, + JSON.stringify({ + arch: 'x64', + libmpvSoname: 'libmpv.so.2', + platform: 'linux', + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + + if (process.platform !== 'win32') { + symlinkSync( + 'payload.bin', + join(packageRoot, 'resources', 'payload-link') + ); + } + + return { + executablePath, + libmpvAliasPath, + libmpvSonamePath, + nativeDir, + packageRoot, + payloadPath, + runtimeManifestPath, + }; +} + +function entryExists(entryPath: string): boolean { + try { + lstatSync(entryPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +afterEach(() => { + for (const directory of temporaryDirectories) { + rmSync(directory, { force: true, recursive: true }); + } + temporaryDirectories.clear(); +}); + +describe('disposable unpacked package clone', () => { + it('requires a safe versioned libmpv target in the packaged manifest', () => { + const source = createPackageFixture(); + + assert.equal( + readPackagedRuntimeIdentity(source.nativeDir).libmpvSoname, + 'libmpv.so.2' + ); + + writeFileSync( + source.runtimeManifestPath, + JSON.stringify({ + arch: 'x64', + libmpvSoname: '../libmpv.so.2', + platform: 'linux', + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + assert.throws( + () => readPackagedRuntimeIdentity(source.nativeDir), + /libmpvSoname/ + ); + }); + + it('hides and restores a cloned regular dependency without changing the source package', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + const clonedLibmpvPath = join(clone.nativeDir, 'lib', 'libmpv.so.2'); + const guard = createPackagedEntryGuard(clonedLibmpvPath, { + expectedKind: 'regular-file', + hiddenDirectory: clone.temporaryRoot, + }); + + try { + assert.equal(lstatSync(clonedLibmpvPath).isFile(), true); + assert.equal( + statSync(clonedLibmpvPath).ino, + statSync(source.libmpvSonamePath).ino + ); + + guard.hide(); + + assert.equal(entryExists(clonedLibmpvPath), false); + assert.equal(lstatSync(source.libmpvSonamePath).isFile(), true); + assert.equal( + readFileSync(source.libmpvSonamePath, 'utf8'), + 'packaged libmpv' + ); + + guard.restore(); + + assert.equal(lstatSync(clonedLibmpvPath).isFile(), true); + assert.equal( + statSync(clonedLibmpvPath).ino, + statSync(source.libmpvSonamePath).ino + ); + } finally { + guard.restore(); + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + + assert.equal(entryExists(source.libmpvSonamePath), true); + }); + + it('hides and restores a cloned symbolic link without dereferencing it', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + const clonedAliasPath = join(clone.nativeDir, 'lib', 'libmpv.so'); + const guard = createPackagedEntryGuard(clonedAliasPath, { + expectedKind: 'symbolic-link', + hiddenDirectory: clone.temporaryRoot, + }); + + try { + guard.hide(); + assert.equal(entryExists(clonedAliasPath), false); + assert.equal( + lstatSync(source.libmpvAliasPath).isSymbolicLink(), + true + ); + assert.equal(readlinkSync(source.libmpvAliasPath), 'libmpv.so.2'); + + guard.restore(); + assert.equal(lstatSync(clonedAliasPath).isSymbolicLink(), true); + assert.equal(readlinkSync(clonedAliasPath), 'libmpv.so.2'); + } finally { + guard.restore(); + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + }); + + it('hardlinks regular files and preserves modes, symlinks, and the source manifest', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath); + temporaryDirectories.add(clone.temporaryRoot); + + try { + assert.notEqual(clone.packageRoot, source.packageRoot); + assert.equal( + statSync(clone.executablePath).mode & 0o777, + statSync(source.executablePath).mode & 0o777 + ); + + const clonedPayloadPath = join( + clone.packageRoot, + 'resources', + 'payload.bin' + ); + assert.equal( + statSync(clonedPayloadPath).ino, + statSync(source.payloadPath).ino + ); + assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640); + + if (process.platform !== 'win32') { + const clonedLinkPath = join( + clone.packageRoot, + 'resources', + 'payload-link' + ); + assert.equal(lstatSync(clonedLinkPath).isSymbolicLink(), true); + assert.equal(readlinkSync(clonedLinkPath), 'payload.bin'); + } + + const clonedRuntimeManifestPath = join( + clone.nativeDir, + 'embedded-mpv-runtime.json' + ); + assert.equal(existsSync(clonedRuntimeManifestPath), true); + assert.equal(existsSync(source.runtimeManifestPath), true); + } finally { + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + + assert.equal(existsSync(clone.temporaryRoot), false); + assert.equal(existsSync(source.runtimeManifestPath), true); + }); + + it('copies a regular file when hardlinking is unavailable', () => { + const source = createPackageFixture(); + const clone = createDisposablePackagedLinuxApp(source.executablePath, { + linkFile() { + throw Object.assign(new Error('cross-device hardlink'), { + code: 'EXDEV', + }); + }, + }); + temporaryDirectories.add(clone.temporaryRoot); + + try { + assert.equal(statSync(clone.executablePath).mode & 0o777, 0o755); + const clonedPayloadPath = join( + clone.packageRoot, + 'resources', + 'payload.bin' + ); + assert.equal( + readFileSync(clonedPayloadPath, 'utf8'), + readFileSync(source.payloadPath, 'utf8') + ); + assert.notEqual( + statSync(clonedPayloadPath).ino, + statSync(source.payloadPath).ino + ); + assert.equal(statSync(clonedPayloadPath).mode & 0o777, 0o640); + } finally { + clone.cleanup(); + temporaryDirectories.delete(clone.temporaryRoot); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts new file mode 100644 index 000000000..69d0c0a19 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-filesystem.ts @@ -0,0 +1,255 @@ +import { + chmodSync, + copyFileSync, + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + renameSync, + rmSync, + symlinkSync, + type Stats, +} from 'fs'; +import { tmpdir } from 'os'; +import { basename, dirname, join, resolve } from 'path'; +import { getPackagedLinuxNativeDir } from './electron-test-fixtures'; + +export type DisposablePackagedLinuxApp = { + cleanup: () => void; + executablePath: string; + nativeDir: string; + packageRoot: string; + temporaryRoot: string; +}; + +export type DisposablePackagedLinuxAppOptions = { + linkFile?: (existingPath: string, newPath: string) => void; +}; + +export type PackagedRuntimeIdentity = { + arch: string; + libmpvSoname: string; + platform: string; + profile: string; + runtimeMode: string; +}; + +export type PackagedEntryKind = 'regular-file' | 'symbolic-link'; + +export type PackagedEntryGuard = { + hide: () => void; + restore: () => void; +}; + +export type PackagedEntryGuardOptions = { + expectedKind: PackagedEntryKind; + hiddenDirectory: string; +}; + +const HARDLINK_COPY_FALLBACK_CODES = new Set([ + 'EACCES', + 'EMLINK', + 'ENOSYS', + 'ENOTSUP', + 'EPERM', + 'EXDEV', +]); +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; + +function entryKindMatches( + stats: Stats, + expectedKind: PackagedEntryKind +): boolean { + return expectedKind === 'regular-file' + ? stats.isFile() && !stats.isSymbolicLink() + : stats.isSymbolicLink(); +} + +function entryExistsByLstat(entryPath: string): boolean { + try { + lstatSync(entryPath); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } +} + +function clonePackagedEntry( + sourcePath: string, + destinationPath: string, + linkFile: (existingPath: string, newPath: string) => void +): void { + const sourceStats = lstatSync(sourcePath); + + if (sourceStats.isDirectory()) { + mkdirSync(destinationPath); + for (const entry of readdirSync(sourcePath)) { + clonePackagedEntry( + join(sourcePath, entry), + join(destinationPath, entry), + linkFile + ); + } + chmodSync(destinationPath, sourceStats.mode & 0o7777); + return; + } + + if (sourceStats.isSymbolicLink()) { + symlinkSync(readlinkSync(sourcePath), destinationPath); + return; + } + + if (!sourceStats.isFile()) { + throw new Error( + `Unsupported packaged runtime entry type at ${sourcePath}.` + ); + } + + try { + linkFile(sourcePath, destinationPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !HARDLINK_COPY_FALLBACK_CODES.has(code)) { + throw error; + } + copyFileSync(sourcePath, destinationPath); + chmodSync(destinationPath, sourceStats.mode & 0o7777); + } +} + +export function createDisposablePackagedLinuxApp( + executablePath: string, + options: DisposablePackagedLinuxAppOptions = {} +): DisposablePackagedLinuxApp { + const sourceExecutablePath = resolve(executablePath); + const sourcePackageRoot = dirname(sourceExecutablePath); + const temporaryRoot = mkdtempSync( + join(tmpdir(), 'iptvnator-packaged-frame-copy-') + ); + const packageRoot = join(temporaryRoot, basename(sourcePackageRoot)); + let cleaned = false; + + try { + clonePackagedEntry( + sourcePackageRoot, + packageRoot, + options.linkFile ?? linkSync + ); + } catch (error) { + rmSync(temporaryRoot, { force: true, recursive: true }); + throw error; + } + + const clonedExecutablePath = join( + packageRoot, + basename(sourceExecutablePath) + ); + return { + cleanup() { + if (cleaned) { + return; + } + rmSync(temporaryRoot, { force: true, recursive: true }); + cleaned = true; + }, + executablePath: clonedExecutablePath, + nativeDir: getPackagedLinuxNativeDir(clonedExecutablePath), + packageRoot, + temporaryRoot, + }; +} + +export function createPackagedEntryGuard( + entryPath: string, + options: PackagedEntryGuardOptions +): PackagedEntryGuard { + const guardedEntryPath = resolve(entryPath); + const hiddenDirectory = resolve(options.hiddenDirectory); + const hiddenEntryPath = join( + hiddenDirectory, + `.${basename(guardedEntryPath)}.e2e-hidden-${process.pid}` + ); + let hidden = false; + + return { + hide() { + const entryStats = lstatSync(guardedEntryPath); + if (!entryKindMatches(entryStats, options.expectedKind)) { + throw new Error( + `Packaged entry is not a ${options.expectedKind}: ${guardedEntryPath}` + ); + } + const hiddenDirectoryStats = lstatSync(hiddenDirectory); + if ( + hiddenDirectoryStats.isSymbolicLink() || + !hiddenDirectoryStats.isDirectory() + ) { + throw new Error( + `Packaged entry stash is not a regular directory: ${hiddenDirectory}` + ); + } + if (entryExistsByLstat(hiddenEntryPath)) { + throw new Error( + `Stale hidden packaged entry exists: ${hiddenEntryPath}` + ); + } + renameSync(guardedEntryPath, hiddenEntryPath); + hidden = true; + }, + restore() { + if (!hidden) { + return; + } + if (!entryExistsByLstat(hiddenEntryPath)) { + throw new Error( + `Hidden packaged entry disappeared before restore: ${hiddenEntryPath}` + ); + } + if (entryExistsByLstat(guardedEntryPath)) { + throw new Error( + `Refusing to overwrite packaged entry during restore: ${guardedEntryPath}` + ); + } + renameSync(hiddenEntryPath, guardedEntryPath); + hidden = false; + }, + }; +} + +export function readPackagedRuntimeIdentity( + nativeDir: string +): PackagedRuntimeIdentity { + const runtimeManifestPath = join(nativeDir, 'embedded-mpv-runtime.json'); + const parsed = JSON.parse( + readFileSync(runtimeManifestPath, 'utf8') + ) as Partial; + + for (const field of [ + 'arch', + 'platform', + 'profile', + 'runtimeMode', + ] as const) { + if (typeof parsed[field] !== 'string' || !parsed[field]) { + throw new Error( + `Packaged runtime manifest ${field} is invalid at ${runtimeManifestPath}.` + ); + } + } + if ( + typeof parsed.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(parsed.libmpvSoname) + ) { + throw new Error( + `Packaged runtime manifest libmpvSoname is invalid at ${runtimeManifestPath}.` + ); + } + + return parsed as PackagedRuntimeIdentity; +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts new file mode 100644 index 000000000..7e9130f5e --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.spec.ts @@ -0,0 +1,191 @@ +import assert = require('node:assert/strict'); +import { readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { describe, it } from 'node:test'; +import { isMeaningfulNativePlaybackSnapshot } from './embedded-mpv-frame-copy-packaged-fixtures'; +import './embedded-mpv-frame-copy-packaged-filesystem.tests'; +import { resolvePackagedElectronLaunchArgs } from './electron-test-fixtures'; +import packagedPlaywrightConfig from '../playwright.packaged.config'; + +const projectRoot = resolve(__dirname, '..'); + +describe('packaged Electron launch arguments', () => { + 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'] + ); + }); +}); + +describe('native-view playback proof', () => { + it('requires the loaded URL, a playing or paused state, and positive duration', () => { + const expectedUrl = 'http://127.0.0.1:3210/fixture.y4m'; + const baseSnapshot = { + durationSeconds: 2, + status: 'playing', + streamUrl: expectedUrl, + }; + + assert.equal( + isMeaningfulNativePlaybackSnapshot(baseSnapshot, expectedUrl), + true + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, status: 'paused' }, + `${expectedUrl}#ignored` + ), + true + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, durationSeconds: null }, + expectedUrl + ), + false + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, status: 'idle' }, + expectedUrl + ), + false + ); + assert.equal( + isMeaningfulNativePlaybackSnapshot( + { ...baseSnapshot, streamUrl: `${expectedUrl}?other=1` }, + expectedUrl + ), + false + ); + }); + + it('loads the fixture and observes playback before disposing the native session', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + const fallbackStart = source.indexOf('const launchedFallbackApp'); + const captureIndex = source.indexOf( + 'installEmbeddedMpvSessionCapture', + fallbackStart + ); + const loadIndex = source.indexOf( + 'loadEmbeddedMpvPlayback', + fallbackStart + ); + const proofIndex = source.indexOf( + 'isMeaningfulNativePlaybackSnapshot', + fallbackStart + ); + const exitCodeIndex = source.indexOf( + 'electronApp.process().exitCode', + fallbackStart + ); + const disposeIndex = source.indexOf( + 'disposeEmbeddedMpvSession', + fallbackStart + ); + + assert.ok(fallbackStart >= 0); + assert.ok(captureIndex > fallbackStart); + assert.ok(loadIndex > captureIndex); + assert.ok(proofIndex > loadIndex); + assert.ok(exitCodeIndex > proofIndex); + assert.ok(disposeIndex > exitCodeIndex); + }); + + it('removes the manifest-declared libmpv target and expects the stable missing-library reason', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + const manifestIdentityIndex = source.indexOf( + 'const runtimeIdentity = readPackagedRuntimeIdentity' + ); + const guardIndex = source.indexOf( + 'createPackagedEntryGuard(', + manifestIdentityIndex + ); + const sonameIndex = source.indexOf( + 'runtimeIdentity.libmpvSoname', + guardIndex + ); + const hideIndex = source.indexOf('.hide()', sonameIndex); + const fallbackStart = source.indexOf( + 'const launchedFallbackApp', + hideIndex + ); + const missingReasonIndex = source.indexOf( + "frameCopyUnavailableReason: 'runtime-library-missing'", + fallbackStart + ); + + assert.ok(manifestIdentityIndex >= 0); + assert.ok(guardIndex > manifestIdentityIndex); + assert.ok(sonameIndex > guardIndex); + assert.ok(hideIndex > sonameIndex); + assert.ok(fallbackStart > hideIndex); + assert.ok(missingReasonIndex > fallbackStart); + assert.doesNotMatch(source, /createRuntimeManifestGuard/); + assert.doesNotMatch(source, /runtimeManifest\.hide/); + }); +}); + +describe('dedicated packaged smoke target', () => { + it('inherits the GL mode from the workflow environment', () => { + const source = readFileSync( + join(projectRoot, 'src', 'embedded-mpv-frame-copy-packaged.e2e.ts'), + 'utf8' + ); + + assert.doesNotMatch(source, /LIBGL_ALWAYS_SOFTWARE\s*:/); + }); + + it('does not build the backend or start portal mock servers', () => { + const project = JSON.parse( + readFileSync(join(projectRoot, 'project.json'), 'utf8') + ) as { + targets?: Record< + string, + { + cache?: boolean; + dependsOn?: unknown; + options?: { command?: string }; + } + >; + }; + const target = project.targets?.['packaged-frame-copy-smoke']; + const packagedConfig = readFileSync( + join(projectRoot, 'playwright.packaged.config.ts'), + 'utf8' + ); + + if (!target) { + throw new Error('The packaged frame-copy smoke target is missing.'); + } + assert.equal(target.cache, false); + assert.deepEqual(target.dependsOn, [ + 'test-packaged-frame-copy-fixtures', + ]); + assert.match( + target.options?.command ?? '', + /playwright\.packaged\.config\.ts/ + ); + assert.match( + target.options?.command ?? '', + /embedded-mpv-frame-copy-packaged\.e2e\.ts/ + ); + assert.match(packagedConfig, /timeout:\s*120000/); + assert.match(packagedConfig, /webServer:\s*\[\]/); + assert.deepEqual(packagedPlaywrightConfig.webServer, []); + }); +}); diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts new file mode 100644 index 000000000..31c888631 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged-fixtures.ts @@ -0,0 +1,344 @@ +import type { + EmbeddedMpvSession, + EmbeddedMpvSupport, +} from '@iptvnator/shared/interfaces'; +import { spawnSync } from 'child_process'; +import { createServer, type Server } from 'http'; +import sharp = require('sharp'); +import { + closeElectronApp, + expect, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import type { + DisposablePackagedLinuxApp, + PackagedEntryGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; + +declare global { + interface Window { + __packagedEmbeddedMpvSessions?: EmbeddedMpvSession[]; + __packagedEmbeddedMpvUnsubscribe?: () => void; + } +} + +export type LocalMediaServer = { + close: () => Promise; + url: string; +}; + +function createTwoSecondY4mFixture(): Buffer { + const width = 64; + const height = 36; + const framesPerSecond = 10; + const frameCount = framesPerSecond * 2; + const yPlaneBytes = width * height; + const chromaPlaneBytes = (width / 2) * (height / 2); + const chunks: Buffer[] = [ + Buffer.from( + `YUV4MPEG2 W${width} H${height} F${framesPerSecond}:1 Ip A1:1 C420jpeg\n`, + 'ascii' + ), + ]; + + for (let index = 0; index < frameCount; index += 1) { + const evenFrame = index % 2 === 0; + chunks.push( + Buffer.from('FRAME\n', 'ascii'), + Buffer.alloc(yPlaneBytes, evenFrame ? 76 : 150), + Buffer.alloc(chromaPlaneBytes, evenFrame ? 84 : 44), + Buffer.alloc(chromaPlaneBytes, evenFrame ? 255 : 21) + ); + } + + return Buffer.concat(chunks); +} + +async function listen(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const onError = (error: Error) => { + server.off('listening', onListening); + rejectPromise(error); + }; + const onListening = () => { + server.off('error', onError); + resolvePromise(); + }; + + server.once('error', onError); + server.once('listening', onListening); + server.listen(0, '127.0.0.1'); + }); +} + +async function closeServer(server: Server): Promise { + await new Promise((resolvePromise, rejectPromise) => { + server.close((error) => { + if (error) { + rejectPromise(error); + return; + } + resolvePromise(); + }); + }); +} + +export async function createLocalMediaServer(): Promise { + const body = createTwoSecondY4mFixture(); + const resourcePath = '/embedded-mpv-frame-copy-smoke.y4m'; + const server = createServer((request, response) => { + const pathname = (request.url ?? '').split('?')[0]; + if (pathname !== resourcePath) { + response.writeHead(404).end(); + return; + } + + const range = request.headers.range?.match(/^bytes=(\d+)-(\d*)$/); + if (!range) { + response.writeHead(200, { + 'Accept-Ranges': 'bytes', + 'Content-Length': body.length, + 'Content-Type': 'video/x-yuv4mpeg', + }); + response.end(request.method === 'HEAD' ? undefined : body); + return; + } + + const start = Number(range[1]); + const requestedEnd = range[2] ? Number(range[2]) : body.length - 1; + const end = Math.min(requestedEnd, body.length - 1); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start < 0 || + start > end || + start >= body.length + ) { + response.writeHead(416, { + 'Content-Range': `bytes */${body.length}`, + }); + response.end(); + return; + } + + response.writeHead(206, { + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + 'Content-Range': `bytes ${start}-${end}/${body.length}`, + 'Content-Type': 'video/x-yuv4mpeg', + }); + response.end( + request.method === 'HEAD' + ? undefined + : body.subarray(start, end + 1) + ); + }); + + await listen(server); + const address = server.address(); + if (!address || typeof address === 'string') { + await closeServer(server); + throw new Error('Unable to resolve the local media server address.'); + } + + return { + close: () => closeServer(server), + url: `http://127.0.0.1:${address.port}${resourcePath}`, + }; +} + +export function assertNativeFallbackPrerequisites(): void { + if (!process.env['DISPLAY']) { + throw new Error( + 'The packaged native-view fallback smoke requires DISPLAY (run it under Xvfb/X11).' + ); + } + + const mpv = spawnSync('mpv', ['--version'], { + stdio: 'ignore', + timeout: 3000, + }); + if (mpv.status !== 0) { + throw new Error( + 'The packaged native-view fallback smoke requires a working system mpv CLI on PATH.' + ); + } +} + +export async function installEmbeddedMpvSessionCapture( + app: LaunchedElectronApp +): Promise { + await app.mainWindow.evaluate(() => { + window.__packagedEmbeddedMpvUnsubscribe?.(); + window.__packagedEmbeddedMpvSessions = []; + window.__packagedEmbeddedMpvUnsubscribe = + window.electron.onEmbeddedMpvSessionUpdate?.((session) => { + window.__packagedEmbeddedMpvSessions?.push(session); + }); + }); +} + +export async function installFrameCanvasAndSessionCapture( + app: LaunchedElectronApp +): Promise { + await installEmbeddedMpvSessionCapture(app); + await app.mainWindow.evaluate(() => { + document.querySelector('canvas[data-embedded-mpv-frame]')?.remove(); + const canvas = document.createElement('canvas'); + canvas.dataset['embeddedMpvFrame'] = ''; + canvas.dataset['testId'] = 'packaged-embedded-mpv-frame'; + Object.assign(canvas.style, { + background: '#000', + height: '180px', + left: '0', + position: 'fixed', + top: '0', + width: '320px', + zIndex: '2147483647', + }); + document.body.append(canvas); + }); +} + +export async function getEmbeddedMpvSupport( + app: LaunchedElectronApp +): Promise { + return app.mainWindow.evaluate(async () => { + return window.electron.getEmbeddedMpvSupport(); + }); +} + +export async function getLatestSession( + app: LaunchedElectronApp, + sessionId: string +): Promise { + return app.mainWindow.evaluate((id) => { + const sessions = + window.__packagedEmbeddedMpvSessions?.filter( + (session) => session.id === id + ) ?? []; + return sessions.at(-1) ?? null; + }, sessionId); +} + +export function isMeaningfulNativePlaybackSnapshot( + snapshot: { + durationSeconds: number | null; + error?: string; + status: string; + streamUrl: string; + } | null, + expectedUrl: string +): boolean { + if ( + !snapshot || + snapshot.error || + !['paused', 'playing'].includes(snapshot.status) || + typeof snapshot.durationSeconds !== 'number' || + !Number.isFinite(snapshot.durationSeconds) || + snapshot.durationSeconds <= 0 + ) { + return false; + } + + const normalizeUrl = (value: string): string => { + try { + const url = new URL(value); + url.hash = ''; + return url.href; + } catch { + return value.trim(); + } + }; + + return normalizeUrl(snapshot.streamUrl) === normalizeUrl(expectedUrl); +} + +export async function renderedFrameSignal( + app: LaunchedElectronApp +): Promise { + const canvas = app.mainWindow.getByTestId('packaged-embedded-mpv-frame'); + const png = await canvas.screenshot(); + const { data, info } = await sharp(png) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + let signal = 0; + + for (let offset = 0; offset < data.length; offset += info.channels) { + if (data[offset] + data[offset + 1] + data[offset + 2] > 30) { + signal += 1; + } + } + + return signal; +} + +export async function closeAndWaitForExit( + app: LaunchedElectronApp +): Promise { + const processHandle = app.electronApp.process(); + await closeElectronApp(app); + await expect + .poll( + () => + processHandle.exitCode !== null || + processHandle.signalCode !== null, + { timeout: 10000 } + ) + .toBe(true); +} + +export async function cleanupPackagedFrameCopySmoke(options: { + apps: Array; + hiddenRuntimeEntry?: PackagedEntryGuard; + media?: LocalMediaServer; + packageClone?: DisposablePackagedLinuxApp; +}): Promise { + const errors: unknown[] = []; + + for (const app of options.apps) { + if (!app) { + continue; + } + try { + await closeAndWaitForExit(app); + } catch (error) { + errors.push(error); + } + } + + if (options.hiddenRuntimeEntry) { + try { + options.hiddenRuntimeEntry.restore(); + } catch (error) { + errors.push(error); + } + } + + if (options.media) { + try { + await options.media.close(); + } catch (error) { + errors.push(error); + } + } + + if (options.packageClone) { + try { + options.packageClone.cleanup(); + } catch (error) { + errors.push(error); + } + } + + if (errors.length > 0) { + throw new Error( + `Packaged frame-copy smoke cleanup failed: ${errors + .map((error) => + error instanceof Error ? error.message : String(error) + ) + .join('; ')}` + ); + } +} diff --git a/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts new file mode 100644 index 000000000..b9a43f860 --- /dev/null +++ b/apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts @@ -0,0 +1,311 @@ +import { + expect, + launchPackagedElectronApp, + resolvePackagedLinuxExecutable, + test, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { join } from 'path'; +import { + assertNativeFallbackPrerequisites, + cleanupPackagedFrameCopySmoke, + closeAndWaitForExit, + createLocalMediaServer, + getEmbeddedMpvSupport, + getLatestSession, + installEmbeddedMpvSessionCapture, + installFrameCanvasAndSessionCapture, + isMeaningfulNativePlaybackSnapshot, + renderedFrameSignal, + type LocalMediaServer, +} from './embedded-mpv-frame-copy-packaged-fixtures'; +import { + createDisposablePackagedLinuxApp, + createPackagedEntryGuard, + readPackagedRuntimeIdentity, + type DisposablePackagedLinuxApp, + type PackagedEntryGuard, +} from './embedded-mpv-frame-copy-packaged-filesystem'; + +const PACKAGED_FRAME_COPY_REQUIRED_ENV = + 'IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY'; +const FRAME_COPY_OPT_IN_ENV = 'IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY'; +const packagedExecutable = resolvePackagedLinuxExecutable(); +const packagedFrameCopyRequired = isTruthy( + process.env[PACKAGED_FRAME_COPY_REQUIRED_ENV] +); + +function isTruthy(value: string | undefined): boolean { + return ['1', 'true', 'yes', 'on'].includes( + (value ?? '').trim().toLowerCase() + ); +} + +// This smoke drives the public preload API directly so it exercises the real +// packaged main process, helper, frame reader, WebGL pump, and pause controls +// without coupling runtime validation to playlist import/settings UI state. +test.describe('Packaged Linux embedded MPV frame-copy runtime', () => { + test.skip( + process.platform !== 'linux', + 'The packaged frame-copy runtime is Linux-only.' + ); + test.skip( + !packagedExecutable && !packagedFrameCopyRequired, + `Set IPTVNATOR_E2E_PACKAGED_EXECUTABLE or ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1 in the dedicated packaged-runtime job.` + ); + + test('@critical @electron @embedded-mpv uses packaged frame-copy and fails closed to native-view', async ({ + dataDir, + }) => { + expect( + process.arch, + 'The official Linux frame-copy runtime is x64-only.' + ).toBe('x64'); + expect( + packagedExecutable, + `The dedicated packaged-runtime job must provide a real unpacked x64 executable with IPTVNATOR_E2E_PACKAGED_EXECUTABLE when ${PACKAGED_FRAME_COPY_REQUIRED_ENV}=1.` + ).toBeTruthy(); + + const sourceExecutablePath = packagedExecutable as string; + assertNativeFallbackPrerequisites(); + let packageClone: DisposablePackagedLinuxApp | undefined; + let hiddenRuntimeEntry: PackagedEntryGuard | undefined; + let media: LocalMediaServer | undefined; + let frameCopyApp: LaunchedElectronApp | undefined; + let fallbackApp: LaunchedElectronApp | undefined; + + try { + packageClone = + createDisposablePackagedLinuxApp(sourceExecutablePath); + const executablePath = packageClone.executablePath; + const nativeDir = packageClone.nativeDir; + const runtimeIdentity = readPackagedRuntimeIdentity(nativeDir); + hiddenRuntimeEntry = createPackagedEntryGuard( + join(nativeDir, 'lib', runtimeIdentity.libmpvSoname), + { + expectedKind: 'regular-file', + hiddenDirectory: packageClone.temporaryRoot, + } + ); + const mediaServer = await createLocalMediaServer(); + media = mediaServer; + + expect(runtimeIdentity).toMatchObject({ + arch: 'x64', + platform: 'linux', + runtimeMode: 'bundled', + }); + expect(['portable', 'flatpak']).toContain(runtimeIdentity.profile); + + const launchedFrameCopyApp = await launchPackagedElectronApp( + executablePath, + dataDir, + { + env: { + [FRAME_COPY_OPT_IN_ENV]: '1', + }, + } + ); + frameCopyApp = launchedFrameCopyApp; + + await expect + .poll(() => getEmbeddedMpvSupport(launchedFrameCopyApp)) + .toMatchObject({ + engine: 'frame-copy', + frameCopyAvailable: true, + platform: 'linux', + supported: true, + }); + + await installFrameCanvasAndSessionCapture(launchedFrameCopyApp); + const created = await launchedFrameCopyApp.mainWindow.evaluate( + async () => { + return window.electron.createEmbeddedMpvSession( + { x: 0, y: 0, width: 320, height: 180 }, + 'Packaged frame-copy smoke', + 0 + ); + } + ); + + await launchedFrameCopyApp.mainWindow.evaluate( + async ({ sessionId, streamUrl }) => { + await window.electron.setEmbeddedMpvPaused(sessionId, true); + await window.electron.loadEmbeddedMpvPlayback(sessionId, { + streamUrl, + title: 'Two-second generated Y4M fixture', + isLive: false, + }); + }, + { sessionId: created.id, streamUrl: mediaServer.url } + ); + + await expect + .poll( + () => getLatestSession(launchedFrameCopyApp, created.id), + { + timeout: 15000, + } + ) + .toMatchObject({ + status: 'paused', + streamUrl: mediaServer.url, + videoHeight: 36, + videoWidth: 64, + }); + + const attached = await launchedFrameCopyApp.mainWindow.evaluate( + async (sessionId) => { + return window.electron.attachEmbeddedMpvFrameView?.( + sessionId + ); + }, + created.id + ); + expect(attached).toBe(true); + await expect( + launchedFrameCopyApp.mainWindow.getByTestId( + 'packaged-embedded-mpv-frame' + ) + ).toHaveAttribute('width', '320'); + await expect( + launchedFrameCopyApp.mainWindow.getByTestId( + 'packaged-embedded-mpv-frame' + ) + ).toHaveAttribute('height', '180'); + await expect + .poll(() => renderedFrameSignal(launchedFrameCopyApp), { + timeout: 15000, + }) + .toBeGreaterThan(0); + + await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvPaused(sessionId, false), + created.id + ); + await expect + .poll( + async () => + ( + await getLatestSession( + launchedFrameCopyApp, + created.id + ) + )?.status, + { timeout: 10000 } + ) + .toBe('playing'); + + await launchedFrameCopyApp.mainWindow.evaluate( + (sessionId) => + window.electron.setEmbeddedMpvPaused(sessionId, true), + created.id + ); + await expect + .poll( + async () => + ( + await getLatestSession( + launchedFrameCopyApp, + created.id + ) + )?.status, + { timeout: 10000 } + ) + .toBe('paused'); + await launchedFrameCopyApp.mainWindow.evaluate( + async (sessionId) => { + window.electron.detachEmbeddedMpvFrameView?.(); + await window.electron.disposeEmbeddedMpvSession(sessionId); + window.__packagedEmbeddedMpvUnsubscribe?.(); + }, + created.id + ); + + await closeAndWaitForExit(launchedFrameCopyApp); + frameCopyApp = undefined; + + hiddenRuntimeEntry.hide(); + expect(readPackagedRuntimeIdentity(nativeDir)).toEqual( + runtimeIdentity + ); + + const launchedFallbackApp = await launchPackagedElectronApp( + executablePath, + dataDir, + { + env: { + [FRAME_COPY_OPT_IN_ENV]: '1', + }, + } + ); + fallbackApp = launchedFallbackApp; + const fallbackSupport = + await getEmbeddedMpvSupport(launchedFallbackApp); + + expect(fallbackSupport).toMatchObject({ + engine: 'native', + frameCopyAvailable: false, + frameCopyUnavailableReason: 'runtime-library-missing', + platform: 'linux', + supported: true, + }); + + await installEmbeddedMpvSessionCapture(launchedFallbackApp); + const nativeSession = await launchedFallbackApp.mainWindow.evaluate( + async () => { + return window.electron.createEmbeddedMpvSession( + { x: 0, y: 0, width: 320, height: 180 }, + 'Native-view fallback smoke', + 0 + ); + } + ); + expect(nativeSession.id).toMatch(/^embedded-mpv-/); + await launchedFallbackApp.mainWindow.evaluate( + async ({ sessionId, streamUrl }) => { + await window.electron.loadEmbeddedMpvPlayback(sessionId, { + streamUrl, + title: 'Native-view generated Y4M fixture', + isLive: false, + }); + }, + { sessionId: nativeSession.id, streamUrl: mediaServer.url } + ); + await expect + .poll( + async () => + isMeaningfulNativePlaybackSnapshot( + await getLatestSession( + launchedFallbackApp, + nativeSession.id + ), + mediaServer.url + ), + { timeout: 15000 } + ) + .toBe(true); + expect( + launchedFallbackApp.electronApp.process().exitCode + ).toBeNull(); + expect( + launchedFallbackApp.electronApp.process().signalCode + ).toBeNull(); + await launchedFallbackApp.mainWindow.evaluate(async (sessionId) => { + await window.electron.disposeEmbeddedMpvSession(sessionId); + window.__packagedEmbeddedMpvUnsubscribe?.(); + }, nativeSession.id); + await expect + .poll(() => launchedFallbackApp.mainWindow.title()) + .toContain('IPTVnator'); + } finally { + await cleanupPackagedFrameCopySmoke({ + apps: [frameCopyApp, fallbackApp], + hiddenRuntimeEntry, + media, + packageClone, + }); + } + }); +}); diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 432f46486..eac33083a 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -1,3 +1,4 @@ +const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); @@ -10,6 +11,25 @@ const { const { removeStaleFrameCopyArtifacts, } = require('../../tools/packaging/embedded-mpv-frame-copy-files.cjs'); +const { + isLinuxSystemBuildInputManifest, + 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, + runWithCleanup, + validateLinuxFrameCopyLinkage, +} = require('./embedded-mpv-linux-linkage.cjs'); + +const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']); +const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl'; +const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build'; const workspaceRoot = process.cwd(); const addonRoot = path.join( @@ -109,6 +129,158 @@ 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 ( + stagedManifest === null || + typeof stagedManifest !== 'object' || + Array.isArray(stagedManifest) + ) { + throw new Error( + `Staged Linux runtime manifest must be an object: ${path.join( + runtimeRoot, + 'runtime-manifest.json' + )}` + ); + } + + const envelopeErrors = []; + if (stagedManifest.origin !== LINUX_STAGED_RUNTIME_ORIGIN) { + envelopeErrors.push(`origin must be "${LINUX_STAGED_RUNTIME_ORIGIN}"`); + } + if (stagedManifest.platform !== 'linux') { + envelopeErrors.push('platform must be "linux"'); + } + if (stagedManifest.arch !== targetArch) { + envelopeErrors.push(`arch must be "${targetArch}"`); + } + if ( + typeof stagedManifest.stagedAt !== 'string' || + stagedManifest.stagedAt.trim().length === 0 + ) { + envelopeErrors.push('stagedAt must be a non-empty string'); + } + if (!Array.isArray(stagedManifest.runtimeFiles)) { + envelopeErrors.push('runtimeFiles must be an array'); + } + + const systemBuildInputs = isLinuxSystemBuildInputManifest(stagedManifest); + let buildInputMode; + let sourceArchive = null; + let sourceRuntimeManifest; + if (systemBuildInputs) { + buildInputMode = 'system-build-inputs'; + sourceRuntimeManifest = { + linuxBackend: stagedManifest.linuxBackend, + buildInputs: stagedManifest.buildInputs, + sourceDistribution: stagedManifest.sourceDistribution, + }; + if ( + Array.isArray(stagedManifest.runtimeFiles) && + stagedManifest.runtimeFiles.length !== 0 + ) { + envelopeErrors.push( + 'system build inputs must not declare staged runtime files' + ); + } + if (stagedManifest.sourceBuildOrigin !== undefined) { + envelopeErrors.push( + 'system build inputs must not declare sourceBuildOrigin' + ); + } + } else { + buildInputMode = 'bundled-runtime'; + sourceArchive = readLinuxSourceArchiveBinding( + runtimeRoot, + envelopeErrors + ); + const sourceBuildOrigin = stagedManifest.sourceBuildOrigin; + const sourceMetadata = { ...stagedManifest }; + delete sourceMetadata.sourceBuildOrigin; + delete sourceMetadata.stagedAt; + sourceRuntimeManifest = { + ...sourceMetadata, + origin: sourceBuildOrigin, + }; + if (sourceBuildOrigin !== LINUX_SOURCE_RUNTIME_ORIGIN) { + envelopeErrors.push( + `sourceBuildOrigin must be "${LINUX_SOURCE_RUNTIME_ORIGIN}"` + ); + } + } + + const sourceManifestErrors = + buildInputMode === 'system-build-inputs' + ? validateLinuxSystemBuildInputManifest(sourceRuntimeManifest) + : validateLinuxRuntimeManifest(sourceRuntimeManifest); + const declaredRuntimeFileNames = Array.isArray( + sourceRuntimeManifest.runtimeFiles + ) + ? sourceRuntimeManifest.runtimeFiles + .map((runtimeFile) => runtimeFile.name) + .sort() + : []; + const stagedRuntimeFileNames = listRuntimeFiles( + path.join(runtimeRoot, 'lib'), + runtimeFilePredicate + ) + .map((runtimeFile) => path.basename(runtimeFile)) + .sort(); + if ( + JSON.stringify(stagedRuntimeFileNames) !== + JSON.stringify(declaredRuntimeFileNames) + ) { + envelopeErrors.push( + 'staged lib directory must exactly match manifest runtimeFiles' + ); + } + + const errors = [...envelopeErrors, ...sourceManifestErrors]; + if (errors.length > 0) { + throw new Error( + [ + `Invalid staged Linux runtime at ${runtimeRoot}:`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + return { + buildInputMode, + sourceArchive, + sourceRuntimeManifest, + sourceRuntimeValidated: buildInputMode === 'bundled-runtime', + }; +} + function fileExists(filePath) { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); } @@ -183,9 +355,9 @@ function findWindowsLibMpv(runtimeRoot) { } /* Debian/Ubuntu install linker targets under the multiarch triple dir. The - * compiler's built-in search paths cover it for -l resolution either way; - * this keeps the -L flag and the helper's baked rpath pointing somewhere - * real. */ + * compiler's built-in search paths cover it for -l resolution either way. + * This directory is a link-time input only; the helper runtime intentionally + * stays on the sanitized system loader contract. */ function defaultLinuxSystemLibDir() { const multiarchTriples = { arm: 'arm-linux-gnueabihf', @@ -211,15 +383,19 @@ function findLinuxLibMpv(libDir) { } function resolveRuntime() { + const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); + const stagedLinuxRuntime = + targetPlatform === 'linux' && fs.existsSync(vendoredHeader) + ? validatedLinuxSourceRuntime(vendoredRuntimeRoot) + : null; const vendoredLibMpv = targetPlatform === 'darwin' ? findLibMpv(vendoredLibDir) : targetPlatform === 'win32' ? findWindowsLibMpv(vendoredRuntimeRoot) : targetPlatform === 'linux' - ? true + ? stagedLinuxRuntime : null; - const vendoredHeader = path.join(vendoredIncludeDir, 'mpv', 'client.h'); if (vendoredLibMpv && fs.existsSync(vendoredHeader)) { const windowsImportLib = @@ -237,17 +413,23 @@ function resolveRuntime() { binDir: vendoredBinDir, manifest: readRuntimeManifest(vendoredRuntimeRoot), windowsImportLib, + ...(stagedLinuxRuntime ?? {}), }; } if (targetPlatform === 'linux') { // Dev-first Linux flow (frame-copy helper links system libmpv): a // distro libmpv-dev install is a full runtime — no staging needed. - // LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system - // paths for machines with a local (non-root) libmpv prefix. + // LIBMPV_INCLUDE_DIR overrides the header path. + // LINUX_NATIVE_LIBRARY_DIR overrides the link-time library directory, + // which must already be visible to the system dynamic loader. const systemIncludeDir = process.env.LIBMPV_INCLUDE_DIR || '/usr/include'; if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) { + const sourceRuntimeManifest = { + linuxBackend: 'process-isolated mpv --wid', + warning: 'Development-only unmanaged system libmpv toolchain.', + }; return { origin: 'system-dev', includeDir: systemIncludeDir, @@ -255,10 +437,10 @@ function resolveRuntime() { process.env.LINUX_NATIVE_LIBRARY_DIR || defaultLinuxSystemLibDir(), binDir: undefined, - manifest: { - warning: - 'Development-only system libmpv toolchain. Release packaging keeps the external-mpv-process contract.', - }, + manifest: sourceRuntimeManifest, + buildInputMode: 'system-dev', + sourceRuntimeManifest, + sourceRuntimeValidated: false, windowsImportLib: null, }; } @@ -288,19 +470,141 @@ function resolveRuntime() { return null; } -function writeLinuxProcessRuntimeManifest(runtime) { +function hasStagedLinuxLibMpvLinkerInput(runtime) { + return ( + Array.isArray(runtime.sourceRuntimeManifest?.runtimeFiles) && + runtime.sourceRuntimeManifest.runtimeFiles.some( + (runtimeFile) => runtimeFile.name === 'libmpv.so' + ) + ); +} + +function assertRequiredLinuxFrameCopyRuntime(runtime) { + if (targetPlatform !== 'linux' || !embeddedMpvRequired) { + return; + } + + if ( + runtime.buildInputMode !== 'bundled-runtime' || + runtime.sourceRuntimeValidated !== true || + !runtime.sourceArchive || + !hasStagedLinuxLibMpvLinkerInput(runtime) + ) { + cleanOutput(); + throw new Error( + 'Required Linux builds must use the validated bundled source runtime containing staged libmpv.' + ); + } +} + +function copyLinuxRuntimeClosureToNativeBuild(runtime) { fs.rmSync(outputLibDir, { recursive: true, force: true }); + const declaredRuntimeFiles = + runtime.buildInputMode === 'bundled-runtime' + ? runtime.sourceRuntimeManifest.runtimeFiles + : []; + if (declaredRuntimeFiles.length === 0) { + return []; + } + + fs.mkdirSync(outputLibDir, { recursive: true }); + const copiedRuntimeFiles = []; + for (const runtimeFile of declaredRuntimeFiles) { + const sourcePath = path.join(runtime.libDir, runtimeFile.name); + let descriptor; + try { + descriptor = fs.openSync( + sourcePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + throw new Error( + `Staged Linux runtime path is not a regular file: ${sourcePath}` + ); + } + + const contents = fs.readFileSync(descriptor); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for staged Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + + const destinationPath = path.join(outputLibDir, runtimeFile.name); + fs.writeFileSync(destinationPath, contents, { mode: 0o755 }); + copiedRuntimeFiles.push({ ...runtimeFile }); + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } + } + + return copiedRuntimeFiles; +} + +function writeLinuxFrameCopyBuildManifest(runtime) { + const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime); + const libmpvSoname = + runtime.sourceRuntimeValidated === true && + runtime.buildInputMode === 'bundled-runtime' && + copiedRuntimeFiles.length > 0 + ? resolveVerifiedLinuxLibMpvSoname({ + outputLibDir, + runtimeFiles: copiedRuntimeFiles, + runtimeDependencyClosure: + runtime.sourceRuntimeManifest.runtimeDependencyClosure, + readDynamicSection: readLinuxDynamicSection, + }) + : null; + const packageRuntimeAvailable = + runtime.sourceRuntimeValidated === true && + runtime.buildInputMode === 'bundled-runtime' && + copiedRuntimeFiles.length > 0 && + libmpvSoname !== null; const manifest = { - ...runtime.manifest, - origin: 'external-mpv-process', + schemaVersion: 1, + origin: 'linux-frame-copy-build', generatedAt: new Date().toISOString(), - runtimeFiles: [], - linuxBackend: - runtime.manifest.linuxBackend ?? 'process-isolated mpv --wid', - mpvExecutable: 'mpv', platform: targetPlatform, - targetArch, + arch: targetArch, + buildInputMode: runtime.buildInputMode, + sourceRuntimeValidated: runtime.sourceRuntimeValidated, + allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES], + packageRuntimeAvailability: { + system: packageRuntimeAvailable, + bundled: packageRuntimeAvailable, + }, + artifacts: { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', + }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname, + runtimeFiles: copiedRuntimeFiles, + runtimeTotalBytes: copiedRuntimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + sourceArchive: runtime.sourceArchive ?? null, + sourceRuntime: runtime.sourceRuntimeManifest, }; fs.writeFileSync( @@ -428,12 +732,33 @@ function runNodeGyp(command, env) { } } +function readLinuxDynamicSection(filePath) { + const result = spawnSync('readelf', ['-d', filePath], { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) { + throw new Error( + `Unable to run readelf for ${filePath}: ${result.error.message}` + ); + } + if (result.status !== 0) { + throw new Error( + `readelf -d failed for ${filePath} with status ${ + result.status ?? 1 + }: ${(result.stderr ?? '').trim()}` + ); + } + return result.stdout; +} + function main() { fs.mkdirSync(outputDir, { recursive: true }); cleanDistNativeOutput(); + cleanOutput(); if (targetPlatform !== process.platform) { - cleanOutput(); if (embeddedMpvRequired) { throw new Error( `Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.` @@ -465,58 +790,114 @@ function main() { return; } - const runtimeManifest = - targetPlatform === 'darwin' - ? copyRuntimeToNativeBuild({ - runtimeLibDir: runtime.libDir, - outputLibDir, - runtimeOrigin: runtime.origin, - runtimeManifest: { - targetArch, - ...runtime.manifest, - }, - }) - : targetPlatform === 'linux' - ? writeLinuxProcessRuntimeManifest(runtime) - : copyGenericRuntimeToNativeBuild(runtime); - fs.rmSync(unavailableMarkerFile, { force: true }); + assertRequiredLinuxFrameCopyRuntime(runtime); - const electronPackageJson = require( - path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') - ); - const electronVersion = electronPackageJson.version; - const env = { - ...process.env, - npm_config_runtime: 'electron', - npm_config_target: electronVersion, - npm_config_arch: targetArch, - npm_config_disturl: 'https://electronjs.org/headers', - npm_config_build_from_source: 'true', - npm_config_update_binary: 'false', - LIBMPV_INCLUDE_DIR: runtime.includeDir, - ...(targetPlatform === 'linux' - ? { - LINUX_NATIVE_LIBRARY_DIR: - process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir, - } - : { LIBMPV_LIBRARY_DIR: outputLibDir }), - ...(runtime.windowsImportLib - ? { - LIBMPV_IMPORT_LIB: path.join( + const buildNativeArtifacts = () => { + const runtimeManifest = + targetPlatform === 'darwin' + ? copyRuntimeToNativeBuild({ + runtimeLibDir: runtime.libDir, outputLibDir, - path.basename(runtime.windowsImportLib) - ), - } - : {}), - }; + runtimeOrigin: runtime.origin, + runtimeManifest: { + targetArch, + ...runtime.manifest, + }, + }) + : targetPlatform === 'linux' + ? writeLinuxFrameCopyBuildManifest(runtime) + : copyGenericRuntimeToNativeBuild(runtime); + const linuxLinkageInputs = + targetPlatform === 'linux' + ? resolveLinuxFrameCopyLinkageInputs({ + buildInputMode: runtime.buildInputMode, + outputLibDir, + packagedLibmpvSoname: runtimeManifest.libmpvSoname, + readDynamicSection: readLinuxDynamicSection, + runtimeLibDir: runtime.libDir, + }) + : null; - log( - `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` - ); - cleanNativeBuildIntermediates(); - try { + const electronPackageJson = require( + path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') + ); + const electronVersion = electronPackageJson.version; + const env = { + ...process.env, + npm_config_runtime: 'electron', + npm_config_target: electronVersion, + npm_config_arch: targetArch, + npm_config_disturl: 'https://electronjs.org/headers', + npm_config_build_from_source: 'true', + npm_config_update_binary: 'false', + LIBMPV_INCLUDE_DIR: runtime.includeDir, + ...(targetPlatform === 'linux' + ? { + LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: + linuxLinkageInputs.linkerLibraryDir, + } + : { LIBMPV_LIBRARY_DIR: outputLibDir }), + ...(runtime.windowsImportLib + ? { + LIBMPV_IMPORT_LIB: path.join( + outputLibDir, + path.basename(runtime.windowsImportLib) + ), + } + : {}), + }; + + log( + `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` + ); + cleanNativeBuildIntermediates(); runNodeGyp('configure', env); runNodeGyp('build', env); + + if (!fs.existsSync(outputFile)) { + throw new Error(`Build finished without producing ${outputFile}.`); + } + + if (targetPlatform === 'linux') { + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname, + outputDir, + readDynamicSection: readLinuxDynamicSection, + }); + } + + if (targetPlatform === 'darwin') { + patchAddonForBundledRuntime(outputFile, outputLibDir); + // The frame-copy helper executable links libmpv too and sits next + // to the same lib/ directory, so it gets the identical + // dependency-path rewrite + ad-hoc re-sign. + const frameHelperFile = path.join( + outputDir, + 'iptvnator_mpv_helper' + ); + if (fs.existsSync(frameHelperFile)) { + patchAddonForBundledRuntime(frameHelperFile, outputLibDir); + } + const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ + outputFile, + ...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []), + ...runtimeManifest.dylibs.map((dylib) => + path.join(outputLibDir, dylib) + ), + ]); + if ( + runtime.origin === 'vendored-lgpl' && + forbiddenLinkErrors.length > 0 + ) { + throw new Error(forbiddenLinkErrors.join('\n')); + } + } + + fs.rmSync(unavailableMarkerFile, { force: true }); + }; + + try { + runWithCleanup(buildNativeArtifacts, cleanOutput); } catch (error) { if (!embeddedMpvRequired && runtime.origin === 'system-dev') { // The system-dev fallback triggers on any machine with @@ -528,40 +909,11 @@ function main() { error instanceof Error ? error.message : String(error) }` ); - cleanOutput(); return; } throw error; } - if (!fs.existsSync(outputFile)) { - throw new Error(`Build finished without producing ${outputFile}.`); - } - - if (targetPlatform === 'darwin') { - patchAddonForBundledRuntime(outputFile, outputLibDir); - // The frame-copy helper executable links libmpv too and sits next to - // the same lib/ directory, so it gets the identical dependency-path - // rewrite + ad-hoc re-sign. - const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper'); - if (fs.existsSync(frameHelperFile)) { - patchAddonForBundledRuntime(frameHelperFile, outputLibDir); - } - const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ - outputFile, - ...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []), - ...runtimeManifest.dylibs.map((dylib) => - path.join(outputLibDir, dylib) - ), - ]); - if ( - runtime.origin === 'vendored-lgpl' && - forbiddenLinkErrors.length > 0 - ) { - throw new Error(forbiddenLinkErrors.join('\n')); - } - } - log(`Built ${path.relative(workspaceRoot, outputFile)}.`); } diff --git a/apps/electron-backend/embedded-mpv-linux-linkage.cjs b/apps/electron-backend/embedded-mpv-linux-linkage.cjs new file mode 100644 index 000000000..3cfa0b197 --- /dev/null +++ b/apps/electron-backend/embedded-mpv-linux-linkage.cjs @@ -0,0 +1,340 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const LIBMPV_NEEDED_PATTERN = /^libmpv\.so(?:\..*)?$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +const LINUX_FRAME_COPY_ARTIFACTS = Object.freeze([ + Object.freeze({ + fileName: 'embedded_mpv.node', + label: 'embedded MPV addon', + mayLinkLibmpv: false, + }), + Object.freeze({ + fileName: 'embedded_mpv_frame_reader.node', + label: 'embedded MPV frame reader', + mayLinkLibmpv: false, + }), + Object.freeze({ + fileName: 'iptvnator_mpv_helper', + label: 'embedded MPV frame-copy helper', + mayLinkLibmpv: true, + }), +]); + +function parseReadelfDynamic(output) { + if (typeof output !== 'string') { + throw new TypeError('readelf dynamic output must be a string.'); + } + + const dynamic = { + needed: [], + rpath: [], + runpath: [], + soname: [], + }; + const dynamicEntryPattern = + /\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g; + for (const [, tag, value] of output.matchAll(dynamicEntryPattern)) { + if (tag === 'NEEDED') { + dynamic.needed.push(value); + continue; + } + if (tag === 'SONAME') { + dynamic.soname.push(value); + continue; + } + dynamic[tag.toLowerCase()].push( + ...value.split(':').filter((entry) => entry.length > 0) + ); + } + + return dynamic; +} + +function exactlyOneRuntimeFile(runtimeFiles, name) { + if (!Array.isArray(runtimeFiles)) { + throw new Error('Linux runtimeFiles metadata must be an array.'); + } + const matchingRecords = runtimeFiles.filter( + (runtimeFile) => runtimeFile?.name === name + ); + if (matchingRecords.length !== 1) { + throw new Error( + `Linux runtime must contain exactly one exact runtimeFiles record for ${name}.` + ); + } + + const [runtimeFile] = matchingRecords; + if ( + !Number.isInteger(runtimeFile.size) || + runtimeFile.size <= 0 || + typeof runtimeFile.sha256 !== 'string' || + !SHA256_PATTERN.test(runtimeFile.sha256) + ) { + throw new Error( + `Linux runtimeFiles record for ${name} has invalid size or SHA-256 metadata.` + ); + } + return runtimeFile; +} + +function readVerifiedRuntimeFile(filePath, runtimeFile) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing copied Linux runtime file: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Copied Linux runtime file must be a regular non-symbolic-link file: ${filePath}` + ); + } + + let descriptor; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + const descriptorStat = fs.fstatSync(descriptor); + if (!descriptorStat.isFile()) { + throw new Error( + `Copied Linux runtime path is not a regular file: ${filePath}` + ); + } + const contents = fs.readFileSync(descriptor); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } +} + +function closureLibMpvSoname(runtimeDependencyClosure) { + if (!Array.isArray(runtimeDependencyClosure?.entries)) { + throw new Error( + 'Validated Linux runtime dependency closure entries are required.' + ); + } + + const libMpvEntries = runtimeDependencyClosure.entries.filter( + (entry) => + entry?.name === 'libmpv.so' || + VERSIONED_LIBMPV_PATTERN.test(entry?.name) + ); + const declaredSonames = libMpvEntries.map((entry) => entry.soname); + const uniqueSonames = new Set(declaredSonames); + if ( + declaredSonames.length === 0 || + declaredSonames.some( + (soname) => + typeof soname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(soname) + ) || + uniqueSonames.size !== 1 + ) { + throw new Error( + 'Validated Linux runtime closure must declare exactly one versioned libmpv SONAME.' + ); + } + + return declaredSonames[0]; +} + +function resolveVerifiedLinuxLibMpvSoname({ + outputLibDir, + runtimeFiles, + runtimeDependencyClosure, + readDynamicSection, +}) { + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + const expectedSoname = closureLibMpvSoname(runtimeDependencyClosure); + const linkerInputRecord = exactlyOneRuntimeFile(runtimeFiles, 'libmpv.so'); + const exactSonameRecord = exactlyOneRuntimeFile( + runtimeFiles, + expectedSoname + ); + const linkerInputPath = path.join(outputLibDir, 'libmpv.so'); + const exactSonamePath = path.join(outputLibDir, expectedSoname); + + readVerifiedRuntimeFile(linkerInputPath, linkerInputRecord); + readVerifiedRuntimeFile(exactSonamePath, exactSonameRecord); + + const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath)); + if ( + dynamic.soname.length !== 1 || + !VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0]) + ) { + throw new Error( + 'Copied Linux libmpv.so must contain exactly one DT_SONAME with a versioned libmpv basename.' + ); + } + if (dynamic.soname[0] !== expectedSoname) { + throw new Error( + `Copied Linux libmpv.so DT_SONAME ${dynamic.soname[0]} does not match validated closure SONAME ${expectedSoname}.` + ); + } + + return expectedSoname; +} + +function resolveLinuxFrameCopyLinkageInputs({ + buildInputMode, + outputLibDir, + packagedLibmpvSoname, + readDynamicSection, + runtimeLibDir, +}) { + const systemDevelopment = buildInputMode === 'system-dev'; + const linkerLibraryDir = systemDevelopment ? runtimeLibDir : outputLibDir; + if ( + typeof linkerLibraryDir !== 'string' || + linkerLibraryDir.trim().length === 0 + ) { + throw new Error( + 'Linux frame-copy linkage requires a non-empty linker library directory.' + ); + } + + if (!systemDevelopment) { + return { + expectedLibmpvSoname: packagedLibmpvSoname, + linkerLibraryDir, + }; + } + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + const linkerInputPath = path.join(linkerLibraryDir, 'libmpv.so'); + const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath)); + if ( + dynamic.soname.length !== 1 || + !VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0]) + ) { + throw new Error( + 'The system-development libmpv linker input must contain exactly one versioned libmpv SONAME.' + ); + } + + return { + expectedLibmpvSoname: dynamic.soname[0], + linkerLibraryDir, + }; +} + +function assertRegularArtifact(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `${label} must be a regular non-symbolic-link file: ${filePath}` + ); + } +} + +function validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname, + outputDir, + readDynamicSection, +}) { + if ( + typeof expectedLibmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(expectedLibmpvSoname) + ) { + throw new Error( + 'Linux frame-copy linkage validation requires an exact versioned libmpv SONAME.' + ); + } + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + for (const artifact of LINUX_FRAME_COPY_ARTIFACTS) { + const artifactPath = path.join(outputDir, artifact.fileName); + assertRegularArtifact(artifactPath, artifact.label); + const dynamic = parseReadelfDynamic(readDynamicSection(artifactPath)); + const libMpvDependencies = dynamic.needed.filter((dependency) => + LIBMPV_NEEDED_PATTERN.test(dependency) + ); + + if (!artifact.mayLinkLibmpv) { + if (libMpvDependencies.length > 0) { + throw new Error( + `${artifact.label} must not have a direct libmpv DT_NEEDED entry; found ${libMpvDependencies.join(', ')}.` + ); + } + continue; + } + + if ( + libMpvDependencies.length !== 1 || + libMpvDependencies[0] !== expectedLibmpvSoname + ) { + throw new Error( + `${artifact.label} DT_NEEDED must contain exactly ${expectedLibmpvSoname}; found ${ + libMpvDependencies.join(', ') || '' + }.` + ); + } + if (dynamic.rpath.length !== 0) { + throw new Error( + `${artifact.label} must not contain RPATH; found ${dynamic.rpath.join(':')}.` + ); + } + if ( + dynamic.runpath.length !== 1 || + dynamic.runpath[0] !== '$ORIGIN/lib' + ) { + throw new Error( + `${artifact.label} RUNPATH must be exactly $ORIGIN/lib; found ${ + dynamic.runpath.join(':') || '' + }.` + ); + } + } +} + +function runWithCleanup(operation, cleanup) { + try { + return operation(); + } catch (error) { + cleanup(); + throw error; + } +} + +module.exports = { + parseReadelfDynamic, + resolveLinuxFrameCopyLinkageInputs, + resolveVerifiedLinuxLibMpvSoname, + runWithCleanup, + validateLinuxFrameCopyLinkage, +}; diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index 4ab060049..fa8bd4b2c 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -158,14 +158,14 @@ ], "ldflags": [ "-pthread", - "-Wl,-rpath,'$$ORIGIN/lib'", - "-Wl,-rpath,> 16) + "." + + std::to_string(version & 0xffff); +} + +std::string runtimeProbeShmName() { +#if defined(_WIN32) + const uint64_t processId = (uint64_t)GetCurrentProcessId(); +#else + const uint64_t processId = (uint64_t)getpid(); +#endif + return "/impv-fc-runtime-probe-" + std::to_string(processId); +} + +/* + * Bounded startup capability probe: initialize an idle libmpv client and + * create the platform GL + mpv OpenGL render contexts, then create, validate, + * and destroy a minimal shared-memory ring. It deliberately does not create + * an FBO, open media, or enter either command loop. + */ +int runRuntimeProbe() { + mpv_handle* mpv = mpv_create(); + if (!mpv) { + return runtimeProbeFailure("mpv-create-failed"); + } + + mpv_set_option_string(mpv, "vo", "libmpv"); + mpv_set_option_string(mpv, "idle", "yes"); + mpv_set_option_string(mpv, "input-default-bindings", "no"); + mpv_set_option_string(mpv, "osc", "no"); + const int initializeResult = mpv_initialize(mpv); + if (initializeResult < 0) { + const std::string error = mpv_error_string(initializeResult); + mpv_destroy(mpv); + return runtimeProbeFailure("mpv-initialize-failed", error); + } + + GlContext gl; + std::string error; + if (!gl.create(error)) { + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("gl-context-create-failed", error); + } + if (!gl.makeCurrent(error)) { + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("gl-context-bind-failed", error); + } + + mpv_opengl_init_params glInit = { + gl.procLoader(), + gl.procLoaderCtx(), + }; + mpv_render_param renderParams[] = { + {MPV_RENDER_PARAM_API_TYPE, + const_cast(MPV_RENDER_API_TYPE_OPENGL)}, + {MPV_RENDER_PARAM_OPENGL_INIT_PARAMS, &glInit}, + {MPV_RENDER_PARAM_INVALID, nullptr}, + }; + mpv_render_context* renderContext = nullptr; + const int renderResult = + mpv_render_context_create(&renderContext, mpv, renderParams); + if (renderResult < 0 || !renderContext) { + const std::string renderError = + renderResult < 0 ? mpv_error_string(renderResult) + : "render context unavailable"; + if (renderContext) mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("mpv-render-context-failed", renderError); + } + + frame_helper::ShmRing runtimeProbeRing; + const std::string shmName = runtimeProbeShmName(); + if (!runtimeProbeRing.create(shmName, 16, 16, 1)) { + runtimeProbeRing.destroy(); + mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("shared-memory-create-failed"); + } + const bool sharedMemoryInitialized = + runtimeProbeRing.base != nullptr && + runtimeProbeRing.header != nullptr && + runtimeProbeRing.header->magic == FRAME_SHM_MAGIC && + runtimeProbeRing.header->version == FRAME_SHM_VERSION && + runtimeProbeRing.header->width == 16 && + runtimeProbeRing.header->height == 16 && + runtimeProbeRing.header->generation == 1; + runtimeProbeRing.destroy(); + if (!sharedMemoryInitialized) { + mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + return runtimeProbeFailure("shared-memory-initialize-failed"); + } + + const std::string libmpvVersion = libmpvClientApiVersion(); + mpv_render_context_free(renderContext); + gl.destroy(); + mpv_terminate_destroy(mpv); + emitLine(JsonWriter() + .num("protocol", 1) + .boolean("usable", true) + .str("libmpv", libmpvVersion) + .str("renderApi", gl.renderApiName()) + .finish()); + return 0; +} + } // namespace int main(int argc, char** argv) { @@ -641,6 +767,9 @@ int main(int argc, char** argv) { signal(SIGPIPE, SIG_IGN); #endif const HelperArgs args = parseArgs(argc, argv); + if (args.runtimeProbe) { + return runRuntimeProbe(); + } g_state.mpv = mpv_create(); if (!g_state.mpv) { diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index fc23eea78..8e6b9febb 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -36,7 +36,11 @@ "inputs": [ "production", "^production", - "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + "{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" ], "options": { "outputPath": "dist/apps/electron-backend", @@ -98,7 +102,11 @@ "inputs": [ "production", "^production", - "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + "{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" ], "options": { "outputPath": "dist/apps/electron-backend", @@ -193,11 +201,27 @@ } }, "lint": { - "command": "eslint apps/electron-backend/**/*.ts" + "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" + ], + "command": "eslint apps/electron-backend/**/*.ts \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts\" \"apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/**/*.ts\"" }, "test": { "executor": "@nx/jest:jest", "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "inputs": [ + "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" + ], "options": { "jestConfig": "apps/electron-backend/jest.config.ts" } diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 9658f5607..639d86d48 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -133,8 +133,34 @@ describe('Electron app security helpers', () => { it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, '0'])( + 'does not probe frame-copy runtime for an inactive %s opt-in', + (explicitFrameCopy) => { + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + if (explicitFrameCopy === undefined) { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + } else { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = + explicitFrameCopy; + } + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).not.toHaveBeenCalled(); + } + ); + + it('probes frame-copy runtime for an explicit opt-in', () => { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1); }); it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => { @@ -215,9 +241,9 @@ describe('Electron app security helpers', () => { expect(mainWindow.loadFile).toHaveBeenCalledWith( expect.stringContaining('index.html') ); - expect( - mockClearStorageData.mock.invocationCallOrder[0] - ).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]); + expect(mockClearStorageData.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.loadFile.mock.invocationCallOrder[0] + ); }); it('continues packaged renderer loading when Electron service worker cleanup fails', async () => { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts index 77986aeb0..9e41fc0db 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts @@ -10,10 +10,17 @@ const mockElectronApp = { jest.mock('electron', () => ({ app: mockElectronApp })); import { + getEmbeddedMpvAddonCandidatePaths, + getFrameCopyRuntimeAvailability, isFrameCopyPlatformSupported, + isFrameCopyRuntimeUsable, resolveFrameCopyHelperPath, + shouldPromotePersistedFrameCopyOptIn, } from './embedded-mpv-frame-copy-platform.util'; -import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime'; describe('embedded-mpv-frame-copy-platform.util', () => { describe('isFrameCopyPlatformSupported', () => { @@ -31,7 +38,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => { ['darwin', 'arm64', true], ['darwin', 'x64', false], ['linux', 'x64', true], - ['linux', 'arm64', true], + ['linux', 'arm64', false], + ['linux', 'arm', false], ['win32', 'x64', true], ['freebsd', 'x64', false], ])('%s/%s -> %s', (platform, arch, expected) => { @@ -61,8 +69,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => { process.platform === 'win32' ? 'iptvnator_mpv_helper.exe' : 'iptvnator_mpv_helper'; - const helperPath = () => - path.join(releaseDir(), helperFileName()); + const helperPath = () => path.join(releaseDir(), helperFileName()); const readerPath = () => path.join(releaseDir(), 'embedded_mpv_frame_reader.node'); @@ -152,24 +159,184 @@ describe('embedded-mpv-frame-copy-platform.util', () => { expect(resolveFrameCopyHelperPath()).toBe(packagedHelper); }); + + it('limits packaged native-view addon discovery to package-owned paths', () => { + mockElectronApp.isPackaged = true; + const resourcesPath = path.join(tempDir, 'IPTVnator', 'Resources'); + Object.defineProperty(process, 'resourcesPath', { + configurable: true, + value: resourcesPath, + }); + mockElectronApp.getAppPath.mockReturnValue( + path.join(resourcesPath, 'app.asar') + ); + + expect(getEmbeddedMpvAddonCandidatePaths()).toEqual([ + path.join( + resourcesPath, + 'app.asar.unpacked', + 'electron-backend', + 'native', + 'embedded_mpv.node' + ), + ]); + }); }); - it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => { - const shouldPromote = ( - frameCopyPlatform as typeof frameCopyPlatform & { - shouldPromotePersistedFrameCopyOptIn?: ( - storedEnabled: boolean, - explicitEnv: string | undefined, - runtimeUsable: boolean - ) => boolean; - } - ).shouldPromotePersistedFrameCopyOptIn; + describe('isFrameCopyRuntimeUsable', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; - expect(shouldPromote).toBeDefined(); - expect(shouldPromote?.(true, undefined, true)).toBe(true); - expect(shouldPromote?.(true, undefined, false)).toBe(false); - expect(shouldPromote?.(true, '0', true)).toBe(false); - expect(shouldPromote?.(true, '1', true)).toBe(false); - expect(shouldPromote?.(false, undefined, true)).toBe(false); + beforeEach(() => { + mockElectronApp.isPackaged = false; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { value: originalArch }); + mockElectronApp.isPackaged = false; + }); + + it('requires a successful Linux x64 runtime probe', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn< + EmbeddedMpvFrameCopyRuntimeResult, + [string, EmbeddedMpvFrameCopyManifestContract] + >(() => ({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + })); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + true + ); + expect(probeRuntime).toHaveBeenCalledWith( + '/native/iptvnator_mpv_helper', + 'development' + ); + + probeRuntime.mockReturnValueOnce({ + usable: false, + reason: 'helper-probe-failed', + }); + expect( + getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime) + ).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + }); + + it('selects the packaged manifest contract only from app.isPackaged', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + mockElectronApp.isPackaged = true; + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn< + EmbeddedMpvFrameCopyRuntimeResult, + [string, EmbeddedMpvFrameCopyManifestContract] + >(() => ({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + })); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + true + ); + expect(probeRuntime).toHaveBeenCalledWith( + '/native/iptvnator_mpv_helper', + 'packaged' + ); + }); + + it.each<[NodeJS.Platform, string]>([ + ['darwin', 'arm64'], + ['win32', 'x64'], + ])( + 'keeps the existing helper-presence gate on %s', + (platform, arch) => { + Object.defineProperty(process, 'platform', { + value: platform, + }); + Object.defineProperty(process, 'arch', { value: arch }); + const resolveHelper = jest.fn( + () => '/native/iptvnator_mpv_helper' + ); + const probeRuntime = jest.fn(); + + expect( + isFrameCopyRuntimeUsable(resolveHelper, probeRuntime) + ).toBe(true); + expect(probeRuntime).not.toHaveBeenCalled(); + } + ); + + it('rejects Linux ARM before helper discovery or probing', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'arm64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn(); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + false + ); + expect(resolveHelper).not.toHaveBeenCalled(); + expect(probeRuntime).not.toHaveBeenCalled(); + }); + + it('reports unsupported architecture for Intel macOS', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn(); + + expect( + getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime) + ).toEqual({ + usable: false, + reason: 'unsupported-architecture', + }); + expect(resolveHelper).not.toHaveBeenCalled(); + expect(probeRuntime).not.toHaveBeenCalled(); + }); + }); + + it('lazily probes only a stored opt-in without an explicit env override', () => { + const runtimeUsable = jest.fn(() => true); + expect( + shouldPromotePersistedFrameCopyOptIn( + false, + undefined, + runtimeUsable + ) + ).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, '0', runtimeUsable) + ).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, '1', runtimeUsable) + ).toBe(false); + expect(runtimeUsable).not.toHaveBeenCalled(); + + expect( + shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable) + ).toBe(true); + expect(runtimeUsable).toHaveBeenCalledTimes(1); + + runtimeUsable.mockReturnValue(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable) + ).toBe(false); + expect(runtimeUsable).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts index b412c5c98..f4844d0df 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts @@ -1,6 +1,11 @@ import { app } from 'electron'; import { accessSync, constants as fsConstants, statSync } from 'fs'; import path from 'path'; +import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime'; /** * Platform gate + helper discovery for the embedded MPV frame-copy engine, @@ -9,15 +14,15 @@ import path from 'path'; * callable before app.whenReady(). * * macOS: Apple Silicon only (owner decision 2026-07-10) — Intel Macs keep - * the docked native engine. Linux: any arch — the helper renders offscreen - * through headless EGL and links libmpv out of process, so neither window - * embedding nor the in-process-libmpv ban constrains it. Windows: any arch - * with a helper binary (WGL offscreen render; in practice x64, the only - * vendored runtime) — the helper-presence check below is the real gate. + * the docked native engine. Linux: x64 only — official packages validate a + * profile manifest and run the helper's bounded EGL/libmpv capability probe; + * ARM packages remain honestly unavailable. Windows: any arch with a helper + * binary (WGL offscreen render; in practice x64, the only vendored runtime) + * — the helper-presence check below is the real gate. */ export function isFrameCopyPlatformSupported(): boolean { return ( - process.platform === 'linux' || + (process.platform === 'linux' && process.arch === 'x64') || process.platform === 'win32' || (process.platform === 'darwin' && process.arch === 'arm64') ); @@ -77,7 +82,7 @@ export function getEmbeddedMpvAddonCandidatePaths(): string[] { return dedupeDefinedPaths( app.isPackaged - ? [...packagedAddonPaths, ...distAddonPaths, localBuildAddonPath] + ? packagedAddonPaths : [localBuildAddonPath, ...distAddonPaths, ...packagedAddonPaths] ); } @@ -104,10 +109,7 @@ export function resolveFrameCopyHelperPath(): string | null { .map((candidatePath) => path.dirname(candidatePath)) .map((nativeDir) => ({ helper: path.join(nativeDir, helperFileName), - reader: path.join( - nativeDir, - 'embedded_mpv_frame_reader.node' - ), + reader: path.join(nativeDir, 'embedded_mpv_frame_reader.node'), })) .find(({ helper, reader }) => { try { @@ -127,16 +129,80 @@ export function resolveFrameCopyHelperPath(): string | null { ); } +type FrameCopyRuntimeProbe = ( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract +) => EmbeddedMpvFrameCopyRuntimeResult; + +export type FrameCopyRuntimeAvailability = + | EmbeddedMpvFrameCopyRuntimeResult + | { usable: true }; + +let cachedDefaultRuntimeAvailability: FrameCopyRuntimeAvailability | undefined; + +export function getFrameCopyRuntimeAvailability( + resolveHelper: () => string | null = resolveFrameCopyHelperPath, + probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime +): FrameCopyRuntimeAvailability { + const usesProcessDecision = + resolveHelper === resolveFrameCopyHelperPath && + probeRuntime === probeEmbeddedMpvFrameCopyRuntime; + if (usesProcessDecision && cachedDefaultRuntimeAvailability !== undefined) { + return cachedDefaultRuntimeAvailability; + } + + let availability: FrameCopyRuntimeAvailability; + if (!isFrameCopyPlatformSupported()) { + availability = { + usable: false, + reason: + process.platform === 'linux' || process.platform === 'darwin' + ? 'unsupported-architecture' + : 'unsupported-platform', + }; + } else { + const helperPath = resolveHelper(); + if (!helperPath) { + availability = { + usable: false, + reason: 'runtime-artifact-missing', + }; + } else if (process.platform !== 'linux') { + availability = { usable: true }; + } else { + try { + // app.isPackaged is the trusted boundary. Environment flags + // can request the feature, but cannot weaken its manifest + // contract or make development artifacts package-trusted. + availability = probeRuntime( + helperPath, + app.isPackaged ? 'packaged' : 'development' + ); + } catch { + availability = { + usable: false, + reason: 'runtime-probe-internal-error', + }; + } + } + } + if (usesProcessDecision) { + cachedDefaultRuntimeAvailability = availability; + } + return availability; +} + export function isFrameCopyRuntimeUsable( - resolveHelper: () => string | null = resolveFrameCopyHelperPath + resolveHelper: () => string | null = resolveFrameCopyHelperPath, + probeRuntime: FrameCopyRuntimeProbe = probeEmbeddedMpvFrameCopyRuntime ): boolean { - return isFrameCopyPlatformSupported() && resolveHelper() !== null; + return getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime).usable; } export function shouldPromotePersistedFrameCopyOptIn( storedEnabled: boolean, explicitEnv: string | undefined, - runtimeUsable = isFrameCopyRuntimeUsable() + runtimeUsable: () => boolean = isFrameCopyRuntimeUsable ): boolean { - return explicitEnv === undefined && storedEnabled && runtimeUsable; + return explicitEnv === undefined && storedEnabled && runtimeUsable(); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts new file mode 100644 index 000000000..349f0ef52 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts @@ -0,0 +1,16 @@ +export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment'; +export { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime/helper-launch'; +export { + createEmbeddedMpvFrameCopyRuntimeProbe, + probeEmbeddedMpvFrameCopyRuntime, +} from './embedded-mpv-frame-copy-runtime/probe'; +export type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeDependencies, + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeMode, + EmbeddedMpvFrameCopyRuntimeResult, + EmbeddedMpvHelperRuntimeProbeFailureReason, +} from './embedded-mpv-frame-copy-runtime/types'; +export type { LinuxFrameCopyHelperLaunchFileSystem } from './embedded-mpv-frame-copy-runtime/helper-launch'; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts new file mode 100644 index 000000000..4a094e217 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts @@ -0,0 +1,330 @@ +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 { + origin: string; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + targets: ReadonlySet; +} + +export const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json'; +export const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node'; +export const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node'; +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', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +] 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 = + /^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/; +export const VERSION_PATTERN = /^\d+(?:\.\d+)+$/; + +export const GLIBC_TOOLCHAIN_ALLOWLIST = [ + 'ld-linux-x86-64.so.2', + 'libc.so.6', + 'libdl.so.2', + 'libgcc_s.so.1', + 'libm.so.6', + 'libpthread.so.0', + 'librt.so.1', + 'libstdc++.so.6', +] as const; + +export const EXPECTED_EXTERNAL_SYSTEM_LIBRARIES = [ + { + name: 'libEGL.so.1', + interface: 'EGL', + reason: 'System graphics-driver interface used by the frame-copy helper.', + }, + { + name: 'libGL.so.1', + interface: 'OpenGL', + reason: 'System OpenGL compatibility interface supplied by the graphics stack.', + }, + { + name: 'libGLX.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.', + }, + { + name: 'libOpenGL.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL interface supplied by the graphics stack.', + }, + { + name: 'libasound.so.2', + interface: 'ALSA', + reason: 'Linux system audio interface intentionally used by libmpv.', + }, + { + name: 'libdrm.so.2', + interface: 'DRM', + reason: 'Kernel graphics interface used by system GBM and VA-API drivers.', + }, + { + name: 'libgbm.so.1', + interface: 'GBM', + reason: 'System graphics-buffer interface used by headless EGL rendering.', + }, + { + name: 'libpulse.so.0', + interface: 'PulseAudio', + reason: 'Linux desktop audio interface intentionally used by libmpv.', + }, + { + name: 'libva-drm.so.2', + interface: 'VA-API DRM', + reason: 'System VA-API DRM interface used for hardware decoding.', + }, + { + name: 'libva.so.2', + interface: 'VA-API', + reason: 'System video-acceleration interface used for hardware decoding.', + }, +] as const; + +export const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), +]); + +export const PORTABLE_ABI_BASELINE = { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', +} as const; + +export const PINNED_SOURCE_PACKAGE_IDENTITIES = { + freetype: { + version: '2.13.3', + sourceUrl: + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + sourceSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + license: 'FreeType License (FTL)', + }, + fribidi: { + version: '1.0.16', + sourceUrl: + 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + sourceSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + license: 'LGPL-2.1-or-later', + }, + harfbuzz: { + version: '8.5.0', + sourceUrl: + 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + sourceSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + license: 'MIT', + }, + expat: { + version: '2.8.2', + sourceUrl: + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz', + sourceSha256: + '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + license: 'MIT', + }, + fontconfig: { + version: '2.16.0', + sourceUrl: + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz', + sourceSha256: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + license: 'MIT', + }, + libass: { + version: '0.17.3', + sourceUrl: + 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + sourceSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + license: 'ISC', + }, + openssl: { + version: '3.5.7', + sourceUrl: + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz', + sourceSha256: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + license: 'Apache-2.0', + }, + ffmpeg: { + version: '8.1', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + sourceSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '7.360.1', + 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: { + version: '0.409', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + sourceSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + buildInput: { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }, + license: 'GPL-2.0-or-later OR XFree86-1.0', + }, + 'libdisplay-info': { + version: '0.1.1', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + sourceSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, + mpv: { + version: '0.41.0', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + sourceSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + license: 'LGPL-2.1-or-later with -Dgpl=false', + }, +} as const; + +export const EXPECTED_ARTIFACTS = { + addon: { + name: FRAME_COPY_ADDON_NAME, + regularFile: true, + readable: true, + }, + frameReader: { + name: FRAME_COPY_READER_NAME, + regularFile: true, + readable: true, + }, + helper: { + name: FRAME_COPY_HELPER_NAME, + regularFile: true, + readable: true, + executable: true, + }, +}; + +export const EXPECTED_PROCESS_ISOLATION = { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], +}; + +export const EXPECTED_DEVELOPMENT_ARTIFACTS = { + addon: FRAME_COPY_ADDON_NAME, + frameReader: FRAME_COPY_READER_NAME, + helper: FRAME_COPY_HELPER_NAME, +}; + +export const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], +}; + +export const DEVELOPMENT_MANIFEST_FIELDS = [ + 'allowedPackageRuntimeModes', + 'arch', + 'artifacts', + 'buildInputMode', + 'generatedAt', + 'libmpvSoname', + 'nativeViewFallback', + 'origin', + 'packageRuntimeAvailability', + 'platform', + 'processIsolation', + 'runtimeFiles', + 'runtimeTotalBytes', + 'schemaVersion', + 'sourceArchive', + 'sourceRuntime', + 'sourceRuntimeValidated', +] as const; + +export const SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ + deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']), + rpm: Object.freeze([ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ]), + pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), +}); + +export const PROFILE_CONTRACTS = { + system: { + origin: 'system-libmpv-frame-copy', + runtimeMode: 'system', + targets: new Set(['deb', 'rpm', 'pacman']), + }, + portable: { + origin: 'bundled-lgpl-frame-copy', + runtimeMode: 'bundled', + targets: new Set(['appimage', 'snap']), + }, + flatpak: { + origin: 'bundled-lgpl-frame-copy', + runtimeMode: 'bundled', + targets: new Set(['flatpak']), + }, +} as const satisfies Record; + +export const BASE_MANIFEST_FIELDS = [ + 'arch', + 'artifacts', + 'generatedAt', + 'libmpvSoname', + 'nativeViewFallback', + 'origin', + 'packageDependencies', + 'platform', + 'processIsolation', + 'profile', + 'runtimeFiles', + 'runtimeMode', + 'runtimeTotalBytes', + 'schemaVersion', + 'targets', +] as const; + +export const BUNDLED_MANIFEST_FIELDS = [ + ...BASE_MANIFEST_FIELDS, + 'externalSystemLibraries', + 'runtimeDependencyClosure', + 'sourceArchive', + 'sourceRuntime', +] as const; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts new file mode 100644 index 000000000..57fd6bc06 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts @@ -0,0 +1,172 @@ +import { mkdirSync } from 'fs'; +import path from 'path'; +import { + cloneManifest, + createDevelopmentFixture, + probeDevelopmentRuntime, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy development manifest', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it.each(['system-dev', 'system-build-inputs', 'bundled-runtime'] as const)( + 'accepts an exact unpackaged %s build manifest and still runs the helper probe', + (buildInputMode) => { + const fixture = createDevelopmentFixture( + context.rootDir, + buildInputMode + ); + + expect( + probeDevelopmentRuntime( + context.createProbe(), + fixture.helperPath + ) + ).toEqual( + expect.objectContaining({ + usable: true, + runtimeMode: + buildInputMode === 'bundled-runtime' + ? 'bundled' + : 'system', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + fixture.helperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: + buildInputMode === 'bundled-runtime' + ? { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join( + fixture.nativeDir, + 'lib' + ), + } + : { + PATH: '/usr/bin', + }, + }) + ); + } + ); + + it('keeps the packaged manifest contract strict when a development manifest is present', () => { + const fixture = createDevelopmentFixture( + context.rootDir, + 'bundled-runtime' + ); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + 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', + mutate(manifest: Record) { + manifest.origin = 'system-libmpv-frame-copy'; + }, + }, + { + label: 'architecture', + mutate(manifest: Record) { + manifest.arch = 'arm64'; + }, + }, + { + label: 'artifact set', + mutate(manifest: Record) { + const artifacts = manifest.artifacts as Record; + artifacts.helper = 'other-helper'; + }, + }, + { + label: 'package availability', + mutate(manifest: Record) { + manifest.packageRuntimeAvailability = { + system: true, + bundled: false, + }; + }, + }, + { + label: 'unexpected field', + mutate(manifest: Record) { + manifest.manifestContract = 'packaged'; + }, + }, + ])( + 'rejects a development manifest with an invalid $label', + ({ mutate }) => { + const fixture = createDevelopmentFixture( + context.rootDir, + 'system-dev' + ); + const manifest = cloneManifest(fixture.manifest); + mutate(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect( + probeDevelopmentRuntime( + context.createProbe(), + fixture.helperPath + ) + ).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); + + it('rejects a system development manifest with a private runtime directory', () => { + const fixture = createDevelopmentFixture(context.rootDir, 'system-dev'); + mkdirSync(path.join(fixture.nativeDir, 'lib')); + + expect( + probeDevelopmentRuntime(context.createProbe(), fixture.helperPath) + ).toEqual({ + usable: false, + reason: 'runtime-library-directory-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts new file mode 100644 index 000000000..27b743f6e --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/flatpak-runtime.spec.ts @@ -0,0 +1,148 @@ +import { accessSync, lstatSync, readFileSync, readdirSync } from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; +import { createFixture } from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +const FLATPAK_NATIVE_DIR = + '/app/iptvnator/resources/app.asar.unpacked/electron-backend/native'; +const FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [ + '/etc/egl/egl_external_platform.d', + '/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d', + '/usr/share/egl/egl_external_platform.d', +].join(':'); + +describe('Flatpak embedded MPV frame-copy runtime', () => { + it('reconstructs the immutable Freedesktop GL metadata inside the packaged app', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + }, + FLATPAK_NATIVE_DIR, + 'bundled' + ) + ).toEqual({ + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, + LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'), + }); + }); + + it.each([ + ['wrong app id', 'com.example.other', '/app/iptvnator/native'], + [ + 'helper outside /app', + 'com.fourgray.iptvnator', + '/opt/iptvnator/native', + ], + ])( + 'does not reconstruct Flatpak GL metadata for %s', + (_label, flatpakId, nativeDir) => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + FLATPAK_ID: flatpakId, + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + FLATPAK_ID: flatpakId, + LD_LIBRARY_PATH: path.join(nativeDir, 'lib'), + }); + } + ); + + describe('packaged capability probe', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('uses reconstructed Freedesktop GL metadata through the application gate', () => { + const fixture = createFixture(context.rootDir, 'flatpak'); + const virtualHelperPath = path.join( + FLATPAK_NATIVE_DIR, + 'iptvnator_mpv_helper' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === FLATPAK_NATIVE_DIR || + candidatePath.startsWith(`${FLATPAK_NATIVE_DIR}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(FLATPAK_NATIVE_DIR, candidatePath) + ); + } + return candidatePath; + }; + const probeRuntime = context.createProbe({ + env: { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + }, + fileSystem: { + accessSync: (candidatePath, mode) => + accessSync(translatePath(candidatePath), mode), + lstatSync: (candidatePath) => + lstatSync(translatePath(candidatePath)), + readFileSync: (candidatePath) => + readFileSync(translatePath(candidatePath)), + readdirSync: (candidatePath) => + readdirSync(translatePath(candidatePath)), + }, + }); + + expect(probeRuntime(virtualHelperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile: 'flatpak', + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + virtualHelperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: { + PATH: '/app/bin:/usr/bin', + FLATPAK_ID: 'com.fourgray.iptvnator', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + FREEDESKTOP_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS, + LD_LIBRARY_PATH: path.join(FLATPAK_NATIVE_DIR, 'lib'), + }, + }) + ); + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts new file mode 100644 index 000000000..168ccc144 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts @@ -0,0 +1,369 @@ +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; + +const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + EGL_PLATFORM: 'x11', + DRI_PRIME: '1', + GALLIUM_DRIVER: 'llvmpipe', + VDPAU_DRIVER: 'mesa', + __GLX_VENDOR_LIBRARY_NAME: 'mesa', + __GLX_FORCE_VENDOR_LIBRARY_0: 'mesa', + VK_INSTANCE_LAYERS: 'VK_LAYER_MESA_overlay', + VK_LOADER_DRIVERS_SELECT: '*mesa*', +} as const; + +describe('createLinuxFrameCopyHelperEnvironment', () => { + it('removes ambient loader overrides for system packages', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + HOME: '/home/user', + ...HOSTILE_LOADER_ENVIRONMENT, + }, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual({ + PATH: '/usr/bin', + HOME: '/home/user', + }); + }); + + it('preserves graphics feature and debug selectors', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + GRAPHICS_SELECTOR_ENVIRONMENT, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual(GRAPHICS_SELECTOR_ENVIRONMENT); + }); + + it('keeps trusted Snap GL and core22 roots ahead of older and generic libraries', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: [ + '/var/lib/snapd/lib/gl', + '/tmp/hostile-gl', + '/var/lib/snapd/lib/gl/nvidia', + '/var/lib/snapd/lib/gl-evil', + ].join(':'), + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: [ + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + ].join(':'), + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ].join(':'), + }); + }); + + it.each([ + '/tmp/gnome-platform', + '/snap/iptvnator/42/gnome-platform-evil', + 'gnome-platform', + ])( + 'ignores an untrusted Snap desktop runtime declaration: %s', + (declaredDesktopRuntime) => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + const helperEnvironment = createLinuxFrameCopyHelperEnvironment( + { + SNAP: snapRoot, + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: declaredDesktopRuntime, + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + }, + nativeDir, + 'bundled' + ); + + expect(helperEnvironment.LD_LIBRARY_PATH?.split(':')).toEqual([ + path.join(nativeDir, 'lib'), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ]); + expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain( + declaredDesktopRuntime + ); + expect(helperEnvironment.LD_LIBRARY_PATH).not.toContain( + 'hostile-linux-gnu' + ); + expect(helperEnvironment.SNAP_DESKTOP_RUNTIME).toBeUndefined(); + expect(helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET).toBe( + 'x86_64-linux-gnu' + ); + expect(helperEnvironment.SNAP_ARCH).toBe('amd64'); + } + ); + + it('does not trust Snap loader paths when nativeDir is outside the declared mount', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + SNAP: '/snap/iptvnator/42', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + '/opt/iptvnator/native', + 'bundled' + ) + ).toEqual({ + PATH: '/usr/bin', + SNAP: '/snap/iptvnator/42', + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_LIBRARY_PATH: '/opt/iptvnator/native/lib', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts new file mode 100644 index 000000000..ecf7d5c08 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts @@ -0,0 +1,281 @@ +import path from 'path'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './types'; +import { resolveTrustedSnapRoot } from './trusted-snap-root'; + +const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl'; +const TRUSTED_SNAP_EGL_VENDOR_ROOT = '/var/lib/snapd/lib/glvnd/egl_vendor.d'; +const SNAP_DESKTOP_RUNTIME_DIRECTORY = 'gnome-platform'; +const SNAP_GRAPHICS_RUNTIME_DIRECTORY = 'graphics'; +const SNAP_X64_LIBRARY_TRIPLET = 'x86_64-linux-gnu'; +const TRUSTED_SNAP_BASE_LIBRARY_ROOT = '/usr/lib/x86_64-linux-gnu'; +const TRUSTED_SNAP_HELPER_PATH = '/usr/sbin:/usr/bin:/sbin:/bin'; +const TRUSTED_FLATPAK_APP_ID = 'com.fourgray.iptvnator'; +const TRUSTED_FLATPAK_APP_ROOT = '/app'; +const TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = [ + '/etc/egl/egl_external_platform.d', + '/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d', + '/usr/share/egl/egl_external_platform.d', +].join(':'); +const UNSAFE_HELPER_ENVIRONMENT_VARIABLES = [ + 'BASH_ENV', + 'ENV', + 'BASHOPTS', + 'SHELLOPTS', + 'PS4', + 'BASH_XTRACEFD', + 'CDPATH', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'LD_ORIGIN_PATH', + 'LD_PRELOAD', + '__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS', + '__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES', + '__EGL_VENDOR_LIBRARY_DIRS', + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'GBM_BACKENDS_PATH', + 'LIBGL_DRIVERS_PATH', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'LIBVA_DRIVERS_PATH', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'VK_LAYER_PATH', +] as const; + +function isPathInside( + parentPath: string, + candidatePath: string, + allowEqual: boolean +): boolean { + const relativePath = path.relative(parentPath, candidatePath); + if (relativePath === '') { + return allowEqual; + } + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +function getTrustedSnapLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const snapLibraryPaths = getTrustedSnapHostGlLibraryPaths(environment); + const graphicsLibraryRoot = path.join( + snapRoot, + SNAP_GRAPHICS_RUNTIME_DIRECTORY, + 'usr', + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + const desktopLibraryPaths = getTrustedSnapDesktopLibraryPaths( + environment, + snapRoot + ); + + return [ + ...snapLibraryPaths, + graphicsLibraryRoot, + path.join(graphicsLibraryRoot, 'vdpau'), + // The core22 libedit ABI must win over gnome-3-28's libtinfo5 build. + TRUSTED_SNAP_BASE_LIBRARY_ROOT, + ...desktopLibraryPaths, + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', SNAP_X64_LIBRARY_TRIPLET), + path.join(snapRoot, 'usr', 'lib', SNAP_X64_LIBRARY_TRIPLET), + ]; +} + +function getTrustedSnapHostGlLibraryPaths( + environment: NodeJS.ProcessEnv +): string[] { + return (environment.SNAP_LIBRARY_PATH ?? '') + .split(':') + .filter(Boolean) + .filter((libraryPath) => path.isAbsolute(libraryPath)) + .map((libraryPath) => path.resolve(libraryPath)) + .filter((libraryPath) => + isPathInside(TRUSTED_SNAP_GL_ROOT, libraryPath, true) + ); +} + +function getTrustedSnapDesktopLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const desktopRuntime = resolveTrustedSnapDesktopRuntime( + environment, + snapRoot + ); + if (!desktopRuntime) { + return []; + } + + const desktopLibraryRoot = path.join( + desktopRuntime, + 'usr', + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + return [ + path.join(desktopRuntime, 'lib', SNAP_X64_LIBRARY_TRIPLET), + desktopLibraryRoot, + path.join(desktopLibraryRoot, 'mesa'), + path.join(desktopLibraryRoot, 'mesa-egl'), + path.join(desktopLibraryRoot, 'dri'), + path.join(desktopLibraryRoot, 'pulseaudio'), + ]; +} + +function resolveTrustedSnapDesktopRuntime( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string | null { + const expectedDesktopRuntime = path.join( + snapRoot, + SNAP_DESKTOP_RUNTIME_DIRECTORY + ); + const declaredDesktopRuntime = environment.SNAP_DESKTOP_RUNTIME; + if ( + !declaredDesktopRuntime || + !path.isAbsolute(declaredDesktopRuntime) || + path.resolve(declaredDesktopRuntime) !== expectedDesktopRuntime + ) { + return null; + } + return expectedDesktopRuntime; +} + +function isTrustedFlatpakRuntime( + environment: NodeJS.ProcessEnv, + nativeDir: string +): boolean { + return ( + environment.FLATPAK_ID === TRUSTED_FLATPAK_APP_ID && + path.isAbsolute(nativeDir) && + isPathInside(TRUSTED_FLATPAK_APP_ROOT, path.resolve(nativeDir), false) + ); +} + +function applyTrustedSnapGraphicsEnvironment( + helperEnvironment: NodeJS.ProcessEnv, + sourceEnvironment: NodeJS.ProcessEnv, + snapRoot: string +): void { + const graphicsRuntime = path.join( + snapRoot, + SNAP_GRAPHICS_RUNTIME_DIRECTORY, + 'usr' + ); + const graphicsLibraryRoot = path.join( + graphicsRuntime, + 'lib', + SNAP_X64_LIBRARY_TRIPLET + ); + const graphicsDriRoot = path.join(graphicsLibraryRoot, 'dri'); + + helperEnvironment.GBM_BACKENDS_PATH = [ + path.join(graphicsLibraryRoot, 'gbm'), + path.join(TRUSTED_SNAP_GL_ROOT, 'gbm'), + ].join(':'); + helperEnvironment.LIBGL_DRIVERS_PATH = graphicsDriRoot; + helperEnvironment.LIBVA_DRIVERS_PATH = graphicsDriRoot; + helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = path.join( + graphicsRuntime, + 'share', + 'egl', + 'egl_external_platform.d' + ); + helperEnvironment.__EGL_VENDOR_LIBRARY_DIRS = [ + TRUSTED_SNAP_EGL_VENDOR_ROOT, + path.join(graphicsRuntime, 'share', 'glvnd', 'egl_vendor.d'), + ].join(':'); + helperEnvironment.VK_LAYER_PATH = [ + path.join(graphicsRuntime, 'share', 'vulkan', 'implicit_layer.d'), + path.join(graphicsRuntime, 'share', 'vulkan', 'explicit_layer.d'), + ].join(':'); + + const snapConfigRoot = path.join(snapRoot, 'etc', 'xdg'); + const snapDataRoot = path.join(snapRoot, 'usr', 'share'); + const desktopRuntime = resolveTrustedSnapDesktopRuntime( + sourceEnvironment, + snapRoot + ); + const snapLibraryPaths = + getTrustedSnapHostGlLibraryPaths(sourceEnvironment); + if (snapLibraryPaths.length > 0) { + helperEnvironment.SNAP_LIBRARY_PATH = snapLibraryPaths.join(':'); + } else { + delete helperEnvironment.SNAP_LIBRARY_PATH; + } + helperEnvironment.SNAP_ARCH = 'amd64'; + helperEnvironment.SNAP_DESKTOP_ARCH_TRIPLET = SNAP_X64_LIBRARY_TRIPLET; + if (desktopRuntime) { + helperEnvironment.SNAP_DESKTOP_RUNTIME = desktopRuntime; + } else { + delete helperEnvironment.SNAP_DESKTOP_RUNTIME; + } + helperEnvironment.XDG_CONFIG_HOME = snapConfigRoot; + helperEnvironment.XDG_CONFIG_DIRS = [snapConfigRoot, '/etc/xdg'].join(':'); + helperEnvironment.XDG_DATA_HOME = snapDataRoot; + helperEnvironment.XDG_DATA_DIRS = [ + path.join(graphicsRuntime, 'share'), + ...(desktopRuntime ? [path.join(desktopRuntime, 'usr', 'share')] : []), + snapDataRoot, + '/usr/share', + ].join(':'); +} + +/** + * Builds the loader environment shared by the bounded startup probe and each + * real Linux helper session. The validated package profile is authoritative: + * system packages use the system loader, while bundled packages start at + * native/lib and add only fixed trusted Snap or Flatpak graphics roots. + */ +export function createLinuxFrameCopyHelperEnvironment( + environment: NodeJS.ProcessEnv, + nativeDir: string, + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode +): NodeJS.ProcessEnv { + const helperEnvironment = { ...environment }; + for (const variableName of UNSAFE_HELPER_ENVIRONMENT_VARIABLES) { + delete helperEnvironment[variableName]; + } + for (const variableName of Object.keys(helperEnvironment)) { + if (variableName.startsWith('BASH_FUNC_')) { + delete helperEnvironment[variableName]; + } + } + + if (runtimeMode === 'system') { + return helperEnvironment; + } + + const libraryPaths = [path.join(nativeDir, 'lib')]; + const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir); + if (trustedSnapRoot) { + helperEnvironment.PATH = TRUSTED_SNAP_HELPER_PATH; + libraryPaths.push( + ...getTrustedSnapLibraryPaths(environment, trustedSnapRoot) + ); + applyTrustedSnapGraphicsEnvironment( + helperEnvironment, + environment, + trustedSnapRoot + ); + } else if (isTrustedFlatpakRuntime(environment, nativeDir)) { + helperEnvironment.__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS = + TRUSTED_FLATPAK_EGL_EXTERNAL_PLATFORM_CONFIG_DIRS; + } + helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':'); + return helperEnvironment; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts new file mode 100644 index 000000000..25dcb8fbd --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts @@ -0,0 +1,338 @@ +import { SUCCESS_OUTPUT } from './runtime.spec-data'; +import { createFixture } from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy helper failures', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('converts a thrown spawn failure into a stable result', () => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockImplementation(() => { + throw new Error('spawn exploded'); + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-spawn-error', + }); + }); + + it.each([ + { + label: 'timeout', + spawnResult: { + status: null, + signal: 'SIGTERM', + stdout: '', + stderr: '', + error: Object.assign(new Error('timed out'), { + code: 'ETIMEDOUT', + }), + }, + reason: 'helper-probe-timeout', + }, + { + label: 'spawn error', + spawnResult: { + status: null, + signal: null, + stdout: '', + stderr: '', + error: Object.assign(new Error('spawn failed'), { + code: 'EACCES', + }), + }, + reason: 'helper-probe-spawn-error', + }, + { + label: 'nonzero exit', + spawnResult: { + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n', + stderr: '', + }, + reason: 'helper-probe-failed', + }, + { + label: 'signal', + spawnResult: { + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + reason: 'helper-probe-signaled', + }, + { + label: 'invalid JSON', + spawnResult: { + status: 0, + signal: null, + stdout: 'not-json\n', + stderr: '', + }, + reason: 'helper-probe-invalid-output', + }, + { + label: 'multiple lines', + spawnResult: { + status: 0, + signal: null, + stdout: `${SUCCESS_OUTPUT}${SUCCESS_OUTPUT}`, + stderr: '', + }, + reason: 'helper-probe-invalid-output', + }, + { + label: 'wrong protocol', + spawnResult: { + status: 0, + signal: null, + stdout: '{"protocol":2,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n', + stderr: '', + }, + reason: 'helper-probe-protocol-mismatch', + }, + { + label: 'unusable success', + spawnResult: { + status: 0, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"egl-unavailable"}\n', + stderr: '', + }, + reason: 'helper-probe-unusable', + }, + ])('fails closed on helper $label', ({ spawnResult, reason }) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue(spawnResult); + + expect(context.createProbe()(fixture.helperPath)).toEqual( + expect.objectContaining({ usable: false, reason }) + ); + }); + + it.each([ + 'mpv-create-failed', + 'mpv-initialize-failed', + 'gl-context-create-failed', + 'gl-context-bind-failed', + 'mpv-render-context-failed', + 'shared-memory-create-failed', + 'shared-memory-initialize-failed', + ])('preserves the allowlisted helper reason %s', (helperReason) => { + const fixture = createFixture(context.rootDir); + const helperDetail = + helperReason === 'mpv-create-failed' + ? undefined + : 'EGL initialization failed: display unavailable'; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: helperReason, + ...(helperDetail ? { detail: helperDetail } : {}), + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason, + ...(helperDetail ? { helperDetail } : {}), + }); + }); + + it.each([ + ['one printable ASCII character', 'x'], + ['1024 printable ASCII characters', 'x'.repeat(1024)], + ])('preserves %s as helper detail', (_label, helperDetail) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: 'gl-context-create-failed', + detail: helperDetail, + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + helperDetail, + }); + }); + + it.each([ + ['malformed JSON', 'not-json\n'], + [ + 'multiple lines', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed"}\nignored\n', + ], + [ + 'wrong protocol', + '{"protocol":2,"usable":false,"reason":"mpv-create-failed"}\n', + ], + [ + 'wrong usable value', + '{"protocol":1,"usable":true,"reason":"mpv-create-failed"}\n', + ], + [ + 'non-allowlisted reason', + '{"protocol":1,"usable":false,"reason":"loader-injected"}\n', + ], + [ + 'unexpected field', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed","extra":true}\n', + ], + [ + 'invalid detail', + '{"protocol":1,"usable":false,"reason":"mpv-create-failed","detail":1}\n', + ], + ])('does not propagate a helper reason from %s', (_label, stdout) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + }); + + it.each([ + ['empty', ''], + ['control character', 'EGL\tfailure'], + ['trailing line feed', 'EGL failure\n'], + ['DEL character', `EGL${String.fromCharCode(0x7f)}failure`], + ['non-ASCII character', 'EGL échoué'], + ['1025 characters', 'x'.repeat(1025)], + ])( + 'does not propagate any helper fields from %s detail', + (_label, detail) => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: `${JSON.stringify({ + protocol: 1, + usable: false, + reason: 'gl-context-create-failed', + detail, + })}\n`, + stderr: '', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + }); + } + ); + + it('does not trace helper stderr without the exact player trace flag', () => { + const fixture = createFixture(context.rootDir); + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: 'libEGL debug output\n', + }); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + }); + expect(context.writeRuntimeProbeStderr).not.toHaveBeenCalled(); + }); + + it('traces helper stderr as one JSON-escaped line when player tracing is enabled', () => { + const fixture = createFixture(context.rootDir); + const helperStderr = + 'libEGL warning: vendor "mesa"\nfailed path: C:\\driver'; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: helperStderr, + }); + + expect( + context.createProbe({ + env: { + PATH: '/usr/bin', + IPTVNATOR_TRACE_PLAYER: '1', + }, + })(fixture.helperPath) + ).toEqual({ + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + }); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledWith( + `${JSON.stringify({ + event: 'embedded-mpv-helper-runtime-probe-stderr', + stderr: helperStderr, + truncated: false, + })}\n` + ); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1); + expect( + context.writeRuntimeProbeStderr.mock.calls[0][0].split('\n') + ).toHaveLength(2); + }); + + it('bounds traced helper stderr to 16384 characters and reports truncation', () => { + const fixture = createFixture(context.rootDir); + const helperStderr = `${'x'.repeat(16_384)}discarded`; + context.spawnRuntimeProbe.mockReturnValue({ + status: 1, + signal: null, + stdout: '{"protocol":1,"usable":false,"reason":"gl-context-create-failed"}\n', + stderr: helperStderr, + }); + + context.createProbe({ + env: { + PATH: '/usr/bin', + IPTVNATOR_TRACE_PLAYER: '1', + }, + })(fixture.helperPath); + + const trace = JSON.parse( + context.writeRuntimeProbeStderr.mock.calls[0][0] + ); + expect(trace).toEqual({ + event: 'embedded-mpv-helper-runtime-probe-stderr', + stderr: 'x'.repeat(16_384), + truncated: true, + }); + expect(trace.stderr).toHaveLength(16_384); + expect(context.writeRuntimeProbeStderr).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts new file mode 100644 index 000000000..ffded9066 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.spec.ts @@ -0,0 +1,203 @@ +import type { Stats } from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperLaunch } from '../embedded-mpv-frame-copy-runtime'; + +function fakeStat( + kind: 'directory' | 'file' | 'symlink' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => kind === 'symlink', + }; +} + +describe('createLinuxFrameCopyHelperLaunch', () => { + const helperArgs = ['--runtime-probe']; + + it.each([ + ['system', '/opt/iptvnator/native/iptvnator_mpv_helper'], + [ + 'bundled', + '/tmp/.mount-IPTVnator/resources/app.asar.unpacked/electron-backend/native/iptvnator_mpv_helper', + ], + ] as const)( + 'launches a non-Snap %s helper directly', + (runtimeMode, helperPath) => { + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/tmp/hostile', + }, + helperPath, + helperArgs, + runtimeMode, + }) + ).toEqual({ + usable: true, + command: helperPath, + args: helperArgs, + env: + runtimeMode === 'system' + ? { PATH: '/usr/bin' } + : { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join( + path.dirname(helperPath), + 'lib' + ), + }, + }); + } + ); + + it('launches a trusted Snap helper through the connected provider wrapper', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const graphicsRoot = path.join(snapRoot, 'graphics'); + const wrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const lstatSync = jest.fn((candidatePath: string) => { + if (candidatePath === graphicsRoot) { + return fakeStat('directory') as Stats; + } + if (candidatePath === wrapperPath) { + return fakeStat('file') as Stats; + } + throw Object.assign(new Error('missing'), { code: 'ENOENT' }); + }); + const accessSync = jest.fn(); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + }, + helperPath, + helperArgs, + runtimeMode: 'bundled', + fileSystem: { lstatSync, accessSync }, + }) + ).toEqual({ + usable: true, + command: wrapperPath, + args: [helperPath, ...helperArgs], + env: expect.objectContaining({ + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + LD_LIBRARY_PATH: expect.stringContaining( + path.join(nativeDir, 'lib') + ), + }), + }); + expect(lstatSync).toHaveBeenCalledWith(graphicsRoot); + expect(lstatSync).toHaveBeenCalledWith(wrapperPath); + expect(accessSync).toHaveBeenCalledWith( + wrapperPath, + expect.any(Number) + ); + }); + + it.each([ + ['missing mount', 'missing', 'file'], + ['symlink mount', 'symlink', 'file'], + ['missing wrapper', 'directory', 'missing'], + ['symlink wrapper', 'directory', 'symlink'], + ] as const)( + 'fails closed for a trusted Snap with a %s', + (_label, mountKind, wrapperKind) => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const graphicsRoot = path.join(snapRoot, 'graphics'); + const wrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { SNAP: snapRoot }, + helperPath, + helperArgs, + runtimeMode: 'bundled', + fileSystem: { + lstatSync: (candidatePath) => { + const kind = + candidatePath === graphicsRoot + ? mountKind + : wrapperKind; + if (kind === 'missing') { + throw Object.assign(new Error('missing'), { + code: 'ENOENT', + }); + } + return fakeStat(kind) as Stats; + }, + accessSync: () => undefined, + }, + }) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + + expect(wrapperPath).toContain('/graphics/bin/'); + } + ); + + it('fails closed when the provider wrapper is not executable', () => { + const snapRoot = '/var/lib/snapd/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + + expect( + createLinuxFrameCopyHelperLaunch({ + environment: { SNAP: snapRoot }, + helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'), + helperArgs, + runtimeMode: 'bundled', + fileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => { + throw Object.assign(new Error('denied'), { + code: 'EACCES', + }); + }, + }, + }) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts new file mode 100644 index 000000000..2e8cf8c60 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-launch.ts @@ -0,0 +1,103 @@ +import { + accessSync as nodeAccessSync, + constants as fileSystemConstants, + lstatSync as nodeLstatSync, +} from 'fs'; +import type * as nodeFileSystem from 'fs'; +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from './helper-environment'; +import { resolveTrustedSnapRoot } from './trusted-snap-root'; +import type { + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeMode, +} from './types'; + +export interface LinuxFrameCopyHelperLaunchFileSystem { + lstatSync(filePath: string): nodeFileSystem.Stats; + accessSync(filePath: string, mode: number): void; +} + +interface CreateLinuxFrameCopyHelperLaunchOptions { + environment: NodeJS.ProcessEnv; + helperPath: string; + helperArgs: string[]; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + fileSystem?: LinuxFrameCopyHelperLaunchFileSystem; +} + +export type LinuxFrameCopyHelperLaunch = + | { + usable: true; + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + } + | { + usable: false; + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; + }; + +export function createLinuxFrameCopyHelperLaunch( + options: CreateLinuxFrameCopyHelperLaunchOptions +): LinuxFrameCopyHelperLaunch { + const nativeDir = path.dirname(options.helperPath); + const env = createLinuxFrameCopyHelperEnvironment( + options.environment, + nativeDir, + options.runtimeMode + ); + const trustedSnapRoot = + options.runtimeMode === 'bundled' + ? resolveTrustedSnapRoot(options.environment, nativeDir) + : null; + if (!trustedSnapRoot) { + return { + usable: true, + command: options.helperPath, + args: options.helperArgs, + env, + }; + } + + const graphicsRoot = path.join(trustedSnapRoot, 'graphics'); + const providerWrapperPath = path.join( + graphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const fileSystem = options.fileSystem ?? { + lstatSync: nodeLstatSync, + accessSync: nodeAccessSync, + }; + try { + const graphicsRootStat = fileSystem.lstatSync(graphicsRoot); + const providerWrapperStat = fileSystem.lstatSync(providerWrapperPath); + if ( + !graphicsRootStat.isDirectory() || + graphicsRootStat.isSymbolicLink() || + !providerWrapperStat.isFile() || + providerWrapperStat.isSymbolicLink() + ) { + return { + usable: false, + reason: 'snap-graphics-provider-unavailable', + }; + } + fileSystem.accessSync( + providerWrapperPath, + fileSystemConstants.R_OK | fileSystemConstants.X_OK + ); + } catch { + return { + usable: false, + reason: 'snap-graphics-provider-unavailable', + }; + } + + return { + usable: true, + command: providerWrapperPath, + args: [options.helperPath, ...options.helperArgs], + env, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts new file mode 100644 index 000000000..036253afb --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts @@ -0,0 +1,149 @@ +import { unlinkSync, writeFileSync } from 'fs'; +import { + cloneManifest, + createFixture, + mirrorBundledManifestFields, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy packaged manifest policy', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('rejects a missing or malformed manifest without throwing', () => { + const missing = createFixture(context.rootDir); + unlinkSync(missing.manifestPath); + expect(context.createProbe()(missing.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-missing', + }); + + const malformed = createFixture(context.rootDir, 'portable'); + writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 }); + expect(context.createProbe()(malformed.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each([ + ['origin', 'system-libmpv-frame-copy'], + ['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'); + const manifest = cloneManifest(fixture.manifest); + manifest[field] = value; + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each([ + ['system', ['deb', 'pacman']], + ['portable', ['appimage']], + ] as const)( + 'rejects an allowed subset of the exact %s profile targets', + (profile, targets) => { + const fixture = createFixture(context.rootDir, profile); + const manifest = cloneManifest(fixture.manifest); + manifest.targets = targets; + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); + + it('rejects a bundled closure dependency outside the deterministic system allowlist', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const closure = manifest.runtimeDependencyClosure as { + entries: Array<{ needed: string[] }>; + externalDependencies: string[]; + }; + closure.entries[0].needed = ['libambient-only.so.1']; + closure.externalDependencies = ['libambient-only.so.1']; + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('requires externalDependencies to exactly equal the sorted external closure', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const closure = manifest.runtimeDependencyClosure as { + entries: Array<{ needed: string[] }>; + externalDependencies: string[]; + }; + closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6']; + closure.externalDependencies = []; + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('accepts only the exact deterministic external-system library declaration', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + (manifest.externalSystemLibraries as unknown[]).pop(); + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const closure = manifest.runtimeDependencyClosure as { + entries: Array<{ needed: string[] }>; + externalDependencies: string[]; + }; + closure.entries[0].needed = ['libEGL.so.1', 'libc.so.6']; + closure.externalDependencies = ['libEGL.so.1', 'libc.so.6']; + mirrorBundledManifestFields(manifest); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual( + expect.objectContaining({ usable: true, runtimeMode: 'bundled' }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts new file mode 100644 index 000000000..23e736193 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts @@ -0,0 +1,267 @@ +import { isDeepStrictEqual } from 'util'; +import { + BASE_MANIFEST_FIELDS, + BUNDLED_MANIFEST_FIELDS, + DEVELOPMENT_MANIFEST_FIELDS, + EXPECTED_ARTIFACTS, + EXPECTED_DEVELOPMENT_ARTIFACTS, + EXPECTED_DEVELOPMENT_PROCESS_ISOLATION, + EXPECTED_PROCESS_ISOLATION, + PROFILE_CONTRACTS, + validateLinuxSourceArchiveBinding, + SYSTEM_PACKAGE_DEPENDENCIES, + VERSIONED_LIBMPV_PATTERN, +} from './contracts'; +import { validateRuntimeClosure } from './runtime-closure-validator'; +import { validateSourceRuntimePolicy } from './source-runtime-validator'; +import type { RuntimeProfile, ValidManifest, ValidationResult } from './types'; +import { + hasExactFields, + isObject, + validateRuntimeFiles, + validateTargets, + validationFailure, +} from './validation-primitives'; + +export function validatePackagedManifest( + manifest: Record +): ValidationResult { + if ( + manifest.schemaVersion !== 1 || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + typeof manifest.profile !== 'string' || + !Object.prototype.hasOwnProperty.call( + PROFILE_CONTRACTS, + manifest.profile + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + const profile = manifest.profile as RuntimeProfile; + const contract = PROFILE_CONTRACTS[profile]; + if ( + manifest.origin !== contract.origin || + manifest.runtimeMode !== contract.runtimeMode || + !hasExactFields( + manifest, + contract.runtimeMode === 'bundled' + ? BUNDLED_MANIFEST_FIELDS + : BASE_MANIFEST_FIELDS + ) || + typeof manifest.generatedAt !== 'string' || + manifest.generatedAt.trim() === '' || + Number.isNaN(Date.parse(manifest.generatedAt)) || + !validateTargets(manifest.targets, contract.targets) || + !isDeepStrictEqual(manifest.artifacts, EXPECTED_ARTIFACTS) || + !isDeepStrictEqual( + manifest.processIsolation, + EXPECTED_PROCESS_ISOLATION + ) || + manifest.nativeViewFallback !== 'process-isolated mpv --wid' || + typeof manifest.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + if (contract.runtimeMode === 'system') { + if ( + !isDeepStrictEqual( + manifest.packageDependencies, + SYSTEM_PACKAGE_DEPENDENCIES + ) || + !isDeepStrictEqual(manifest.runtimeFiles, []) || + manifest.runtimeTotalBytes !== 0 + ) { + return validationFailure('runtime-manifest-invalid'); + } + return { + value: { + profile, + runtimeMode: 'system', + runtimeFiles: [], + }, + }; + } + + const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles); + if ( + !runtimeFiles || + !isDeepStrictEqual(manifest.packageDependencies, {}) || + !runtimeFiles.some(({ name }) => name === 'libmpv.so') || + !runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) || + manifest.runtimeTotalBytes !== + runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) || + !validateRuntimeClosure( + manifest.runtimeDependencyClosure, + runtimeFiles, + manifest.libmpvSoname, + manifest.externalSystemLibraries + ) || + validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !== + 0 || + !validateSourceRuntimePolicy( + manifest.sourceRuntime, + runtimeFiles, + manifest.runtimeDependencyClosure, + manifest.externalSystemLibraries + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + return { + value: { + profile, + runtimeMode: 'bundled', + runtimeFiles, + }, + }; +} + +function validateSystemDevelopmentSource( + value: unknown, + buildInputMode: 'system-dev' | 'system-build-inputs' +): boolean { + if (buildInputMode === 'system-dev') { + return isDeepStrictEqual(value, { + linuxBackend: 'process-isolated mpv --wid', + warning: 'Development-only unmanaged system libmpv toolchain.', + }); + } + if ( + !isObject(value) || + !hasExactFields(value, [ + 'buildInputs', + 'linuxBackend', + 'sourceDistribution', + ]) || + value.linuxBackend !== 'process-isolated mpv --wid' || + typeof value.sourceDistribution !== 'string' || + value.sourceDistribution.trim() === '' || + !isObject(value.buildInputs) || + !hasExactFields(value.buildInputs, ['libmpvDevPackage', 'mpvPackage']) + ) { + return false; + } + return ['libmpvDevPackage', 'mpvPackage'].every((packageField) => { + const packageName = value.buildInputs[packageField]; + return typeof packageName === 'string' && packageName.trim().length > 0; + }); +} + +export function validateDevelopmentManifest( + manifest: Record +): ValidationResult { + const buildInputMode = manifest.buildInputMode; + if ( + !hasExactFields(manifest, DEVELOPMENT_MANIFEST_FIELDS) || + manifest.schemaVersion !== 1 || + manifest.origin !== 'linux-frame-copy-build' || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + typeof manifest.generatedAt !== 'string' || + manifest.generatedAt.trim() === '' || + Number.isNaN(Date.parse(manifest.generatedAt)) || + !['system-dev', 'system-build-inputs', 'bundled-runtime'].includes( + String(buildInputMode) + ) || + !isDeepStrictEqual(manifest.allowedPackageRuntimeModes, [ + 'system', + 'bundled', + ]) || + !isDeepStrictEqual( + manifest.artifacts, + EXPECTED_DEVELOPMENT_ARTIFACTS + ) || + !isDeepStrictEqual( + manifest.processIsolation, + EXPECTED_DEVELOPMENT_PROCESS_ISOLATION + ) || + manifest.nativeViewFallback !== 'process-isolated mpv --wid' + ) { + return validationFailure('runtime-manifest-invalid'); + } + + if ( + buildInputMode === 'system-dev' || + buildInputMode === 'system-build-inputs' + ) { + if ( + manifest.sourceRuntimeValidated !== false || + !isDeepStrictEqual(manifest.packageRuntimeAvailability, { + system: false, + bundled: false, + }) || + manifest.libmpvSoname !== null || + !isDeepStrictEqual(manifest.runtimeFiles, []) || + manifest.runtimeTotalBytes !== 0 || + manifest.sourceArchive !== null || + !validateSystemDevelopmentSource( + manifest.sourceRuntime, + buildInputMode + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + return { + value: { + profile: 'development', + runtimeMode: 'system', + runtimeFiles: [], + }, + }; + } + + const sourceRuntime = manifest.sourceRuntime; + const runtimeFiles = validateRuntimeFiles(manifest.runtimeFiles); + if ( + buildInputMode !== 'bundled-runtime' || + manifest.sourceRuntimeValidated !== true || + !isDeepStrictEqual(manifest.packageRuntimeAvailability, { + system: true, + bundled: true, + }) || + typeof manifest.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) || + !runtimeFiles || + !runtimeFiles.some(({ name }) => name === 'libmpv.so') || + !runtimeFiles.some(({ name }) => name === manifest.libmpvSoname) || + manifest.runtimeTotalBytes !== + runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) || + !isObject(sourceRuntime) || + (manifest.sourceArchive !== null && + validateLinuxSourceArchiveBinding(manifest.sourceArchive).length !== + 0) || + !validateRuntimeClosure( + sourceRuntime.runtimeDependencyClosure, + runtimeFiles, + manifest.libmpvSoname, + sourceRuntime.externalSystemLibraries + ) || + !validateSourceRuntimePolicy( + sourceRuntime, + runtimeFiles, + sourceRuntime.runtimeDependencyClosure, + sourceRuntime.externalSystemLibraries + ) + ) { + return validationFailure('runtime-manifest-invalid'); + } + + return { + value: { + profile: 'development', + runtimeMode: 'bundled', + runtimeFiles, + }, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts new file mode 100644 index 000000000..4dd40ba8d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts @@ -0,0 +1,246 @@ +import { + chmodSync, + constants as fsConstants, + mkdirSync, + readFileSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import path from 'path'; +import type { RuntimeFile, RuntimeFixture } from './runtime.spec-data'; +import { + cloneManifest, + createFixture, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy package integrity', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('rejects system manifests with a private library directory', () => { + const fixture = createFixture(context.rootDir); + mkdirSync(path.join(fixture.nativeDir, 'lib')); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-library-directory-invalid', + }); + }); + + it.each([ + { + label: 'missing addon', + mutate(fixture: RuntimeFixture) { + unlinkSync(path.join(fixture.nativeDir, 'embedded_mpv.node')); + }, + reason: 'runtime-artifact-missing', + }, + { + label: 'non-executable helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o644); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'setuid helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o4755); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'setgid helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o2755); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'sticky helper', + mutate(fixture: RuntimeFixture) { + chmodSync(fixture.helperPath, 0o1755); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'wrong reader mode', + mutate(fixture: RuntimeFixture) { + chmodSync( + path.join( + fixture.nativeDir, + 'embedded_mpv_frame_reader.node' + ), + 0o600 + ); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'symlinked helper', + mutate(fixture: RuntimeFixture) { + const target = `${fixture.helperPath}.real`; + writeFileSync(target, '#!/bin/sh\n', { mode: 0o755 }); + unlinkSync(fixture.helperPath); + symlinkSync(target, fixture.helperPath); + }, + reason: 'runtime-artifact-invalid', + }, + { + label: 'reader directory', + mutate(fixture: RuntimeFixture) { + const readerPath = path.join( + fixture.nativeDir, + 'embedded_mpv_frame_reader.node' + ); + unlinkSync(readerPath); + mkdirSync(readerPath); + }, + reason: 'runtime-artifact-invalid', + }, + ])('rejects a $label', ({ mutate, reason }) => { + const fixture = createFixture(context.rootDir); + mutate(fixture); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason, + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'missing declared library', + mutate(fixture: RuntimeFixture) { + unlinkSync(path.join(fixture.nativeDir, 'lib', 'libmpv.so.2')); + }, + reason: 'runtime-library-missing', + }, + { + label: 'undeclared library', + mutate(fixture: RuntimeFixture) { + writeFileSync( + path.join(fixture.nativeDir, 'lib', 'libextra.so'), + 'extra' + ); + }, + reason: 'runtime-library-undeclared', + }, + { + label: 'library size mismatch', + mutate(fixture: RuntimeFixture) { + writeFileSync( + path.join(fixture.nativeDir, 'lib', 'libmpv.so.2'), + 'different-length' + ); + }, + reason: 'runtime-library-size-mismatch', + }, + { + label: 'library hash mismatch', + mutate(fixture: RuntimeFixture) { + const runtimePath = path.join( + fixture.nativeDir, + 'lib', + 'libmpv.so.2' + ); + const original = readFileSync(runtimePath); + writeFileSync( + runtimePath, + Buffer.from(original.map((value) => value ^ 0xff)) + ); + }, + reason: 'runtime-library-hash-mismatch', + }, + { + label: 'symlinked library', + mutate(fixture: RuntimeFixture) { + const runtimePath = path.join( + fixture.nativeDir, + 'lib', + 'libmpv.so.2' + ); + const targetPath = path.join( + fixture.nativeDir, + 'libmpv-real.so.2' + ); + writeFileSync( + targetPath, + fixture.runtimeContents['libmpv.so.2'] + ); + unlinkSync(runtimePath); + symlinkSync(targetPath, runtimePath); + }, + reason: 'runtime-library-invalid', + }, + { + label: 'library directory', + mutate(fixture: RuntimeFixture) { + const runtimePath = path.join( + fixture.nativeDir, + 'lib', + 'libmpv.so.2' + ); + unlinkSync(runtimePath); + mkdirSync(runtimePath); + }, + reason: 'runtime-library-invalid', + }, + ])('rejects a $label', ({ mutate, reason }) => { + const fixture = createFixture(context.rootDir, 'portable'); + mutate(fixture); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason, + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it.each(['../libmpv.so.2', '/tmp/libmpv.so.2', 'sub/libmpv.so.2'])( + 'rejects unsafe runtime path %s', + (unsafeName) => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + const runtimeFiles = manifest.runtimeFiles as RuntimeFile[]; + runtimeFiles[0].name = unsafeName; + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); + + it('uses read and execute access checks for declared artifacts', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe(); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + const accessMock = context.fileSystem.accessSync as jest.Mock; + expect(accessMock).toHaveBeenCalledWith( + fixture.helperPath, + fsConstants.R_OK | fsConstants.X_OK + ); + expect(accessMock).toHaveBeenCalledWith( + path.join(fixture.nativeDir, 'embedded_mpv_frame_reader.node'), + fsConstants.R_OK + ); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts new file mode 100644 index 000000000..145eba544 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-validator.ts @@ -0,0 +1,238 @@ +import { createHash } from 'crypto'; +import * as nodeFileSystem from 'fs'; +import path from 'path'; +import { + FRAME_COPY_ADDON_NAME, + FRAME_COPY_HELPER_NAME, + FRAME_COPY_READER_NAME, +} from './contracts'; +import { + validateDevelopmentManifest, + validatePackagedManifest, +} from './manifest-validator'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeFileSystem, + RuntimeFile, + ValidatedPackage, + ValidationResult, +} from './types'; +import { + isMissingFileError, + isValidationFailure, + readManifest, + validationFailure, +} from './validation-primitives'; + +function validateRegularArtifact( + filePath: string, + accessMode: number, + expectedMode: number | null, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): ValidationResult { + let stat: nodeFileSystem.Stats; + try { + stat = fileSystem.lstatSync(filePath); + } catch (error) { + return validationFailure( + isMissingFileError(error) + ? 'runtime-artifact-missing' + : 'runtime-artifact-invalid' + ); + } + + if ( + stat.isSymbolicLink() || + !stat.isFile() || + (expectedMode !== null && (stat.mode & 0o7777) !== expectedMode) + ) { + return validationFailure('runtime-artifact-invalid'); + } + try { + fileSystem.accessSync(filePath, accessMode); + } catch { + return validationFailure('runtime-artifact-invalid'); + } + return { value: stat }; +} + +function pathExistsByLstat( + filePath: string, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): boolean { + try { + fileSystem.lstatSync(filePath); + return true; + } catch (error) { + if (isMissingFileError(error)) { + return false; + } + throw error; + } +} + +function validateBundledRuntimeFiles( + nativeDir: string, + runtimeFiles: RuntimeFile[], + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): ValidationResult { + const libDir = path.join(nativeDir, 'lib'); + let libStat: nodeFileSystem.Stats; + try { + libStat = fileSystem.lstatSync(libDir); + } catch { + return validationFailure('runtime-library-directory-invalid'); + } + if (libStat.isSymbolicLink() || !libStat.isDirectory()) { + return validationFailure('runtime-library-directory-invalid'); + } + + let packagedNames: string[]; + try { + packagedNames = fileSystem.readdirSync(libDir) as string[]; + } catch { + return validationFailure('runtime-library-directory-invalid'); + } + const declaredNames = new Set(runtimeFiles.map(({ name }) => name)); + if (packagedNames.some((name) => !declaredNames.has(name))) { + return validationFailure('runtime-library-undeclared'); + } + + for (const runtimeFile of runtimeFiles) { + const runtimePath = path.join(libDir, runtimeFile.name); + let stat: nodeFileSystem.Stats; + try { + stat = fileSystem.lstatSync(runtimePath); + } catch (error) { + return validationFailure( + isMissingFileError(error) + ? 'runtime-library-missing' + : 'runtime-library-invalid' + ); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + return validationFailure('runtime-library-invalid'); + } + try { + fileSystem.accessSync(runtimePath, nodeFileSystem.constants.R_OK); + } catch { + return validationFailure('runtime-library-invalid'); + } + if (stat.size !== runtimeFile.size) { + return validationFailure('runtime-library-size-mismatch'); + } + + let contents: Buffer; + try { + contents = fileSystem.readFileSync(runtimePath); + } catch { + return validationFailure('runtime-library-invalid'); + } + if (contents.length !== runtimeFile.size) { + return validationFailure('runtime-library-size-mismatch'); + } + const actualSha256 = createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + return validationFailure('runtime-library-hash-mismatch'); + } + } + return { value: true }; +} + +export function validatePackage( + helperPath: string, + manifestPath: string, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem, + manifestContract: EmbeddedMpvFrameCopyManifestContract +): ValidationResult { + const nativeDir = path.dirname(helperPath); + if ( + path.basename(helperPath) !== FRAME_COPY_HELPER_NAME || + path.dirname(manifestPath) !== nativeDir + ) { + return validationFailure('runtime-artifact-invalid'); + } + + const manifestArtifact = validateRegularArtifact( + manifestPath, + nodeFileSystem.constants.R_OK, + 0o644, + fileSystem + ); + if (isValidationFailure(manifestArtifact)) { + return validationFailure( + manifestArtifact.reason === 'runtime-artifact-missing' + ? 'runtime-manifest-missing' + : 'runtime-manifest-invalid' + ); + } + const manifestResult = readManifest(manifestPath, fileSystem); + if (isValidationFailure(manifestResult)) { + return manifestResult; + } + const validManifest = + manifestContract === 'packaged' + ? validatePackagedManifest(manifestResult.value) + : validateDevelopmentManifest(manifestResult.value); + if (isValidationFailure(validManifest)) { + return validManifest; + } + + for (const [artifactPath, accessMode, expectedMode] of [ + [ + path.join(nativeDir, FRAME_COPY_ADDON_NAME), + nodeFileSystem.constants.R_OK, + null, + ], + [ + path.join(nativeDir, FRAME_COPY_READER_NAME), + nodeFileSystem.constants.R_OK, + 0o644, + ], + [ + helperPath, + nodeFileSystem.constants.R_OK | nodeFileSystem.constants.X_OK, + 0o755, + ], + ] as const) { + const artifact = validateRegularArtifact( + artifactPath, + accessMode, + expectedMode, + fileSystem + ); + if (isValidationFailure(artifact)) { + return artifact; + } + } + + const libDir = path.join(nativeDir, 'lib'); + if (validManifest.value.runtimeMode === 'system') { + try { + if (pathExistsByLstat(libDir, fileSystem)) { + return validationFailure('runtime-library-directory-invalid'); + } + } catch { + return validationFailure('runtime-library-directory-invalid'); + } + } else { + const bundledRuntime = validateBundledRuntimeFiles( + nativeDir, + validManifest.value.runtimeFiles, + fileSystem + ); + if (isValidationFailure(bundledRuntime)) { + return bundledRuntime; + } + } + + return { + value: { + manifest: validManifest.value, + helperPath, + nativeDir, + }, + }; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts new file mode 100644 index 000000000..6b6a0f610 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts @@ -0,0 +1,391 @@ +import { + accessSync, + chmodSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, +} from 'fs'; +import path from 'path'; +import { + cloneManifest, + createFixture, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy runtime probe orchestration', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe({ + env: { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': + '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/ambient/libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: + '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', + }, + }); + + expect(probeRuntime(fixture.helperPath)).toEqual({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + }); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(1); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + fixture.helperPath, + ['--runtime-probe'], + { + encoding: 'utf8', + timeout: 3000, + killSignal: 'SIGKILL', + windowsHide: true, + maxBuffer: 16 * 1024 * 1024, + env: { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', + }, + } + ); + expect(context.fileSystem.readFileSync).toHaveBeenCalled(); + }); + + it('invalidates a cached result when helper or manifest bytes change under identical stats', () => { + const fixture = createFixture(context.rootDir); + const fixedStats = new Map([ + [fixture.helperPath, lstatSync(fixture.helperPath)], + [fixture.manifestPath, lstatSync(fixture.manifestPath)], + ]); + context.fileSystem = { + ...context.fileSystem, + lstatSync: jest.fn( + (filePath: string) => + fixedStats.get(filePath) ?? lstatSync(filePath) + ), + }; + const probeRuntime = context.createProbe(); + + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + const changedHelper = readFileSync(fixture.helperPath); + changedHelper[0] ^= 0xff; + writeFileSync(fixture.helperPath, changedHelper); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + fixture.manifest.generatedAt = '2026-07-18T00:00:00.000Z'; + writeManifest(fixture.manifestPath, fixture.manifest); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(3); + }); + + it.each(['portable', 'flatpak'] as const)( + 'validates the exact %s bundled closure and uses only its private library directory', + (profile) => { + const fixture = createFixture(context.rootDir, profile); + const probeRuntime = context.createProbe(); + + expect(probeRuntime(fixture.helperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile, + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + fixture.helperPath, + ['--runtime-probe'], + expect.objectContaining({ + env: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'), + }, + }) + ); + } + ); + + it('runs a packaged Snap probe through the connected graphics provider wrapper', () => { + const actualSnapRoot = path.join(context.rootDir, 'snap-root'); + const actualFixtureRoot = path.join(actualSnapRoot, 'fixture'); + const fixture = createFixture(actualFixtureRoot, 'portable'); + const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics'); + const actualProviderWrapper = path.join( + actualGraphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + mkdirSync(path.dirname(actualProviderWrapper), { recursive: true }); + writeFileSync(actualProviderWrapper, '#!/bin/sh\nexec "$@"\n', { + mode: 0o755, + }); + + const virtualSnapRoot = '/snap/iptvnator/42'; + const linuxTriplet = 'x86_64-linux-gnu'; + const snapLibraries = (...relativePaths: string[]): string[] => + relativePaths.map((relativePath) => + path.join(virtualSnapRoot, relativePath) + ); + const virtualNativeDir = path.join( + virtualSnapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const virtualHelperPath = path.join( + virtualNativeDir, + 'iptvnator_mpv_helper' + ); + const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics'); + const virtualProviderWrapper = path.join( + virtualGraphicsRoot, + 'bin', + 'graphics-core22-provider-wrapper' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === virtualNativeDir || + candidatePath.startsWith(`${virtualNativeDir}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(virtualNativeDir, candidatePath) + ); + } + if ( + candidatePath === virtualGraphicsRoot || + candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`) + ) { + return path.join( + actualGraphicsRoot, + path.relative(virtualGraphicsRoot, candidatePath) + ); + } + return candidatePath; + }; + const virtualFileSystem = { + accessSync: jest.fn((candidatePath: string, mode: number) => + accessSync(translatePath(candidatePath), mode) + ), + lstatSync: jest.fn((candidatePath: string) => + lstatSync(translatePath(candidatePath)) + ), + readFileSync: jest.fn((candidatePath: string) => + readFileSync(translatePath(candidatePath)) + ), + readdirSync: jest.fn((candidatePath: string) => + readdirSync(translatePath(candidatePath)) + ), + }; + const probeRuntime = context.createProbe({ + env: { + PATH: '/snap/bin:/usr/bin', + SNAP: virtualSnapRoot, + SNAP_DESKTOP_RUNTIME: path.join( + virtualSnapRoot, + 'gnome-platform' + ), + SNAP_LIBRARY_PATH: + '/var/lib/snapd/lib/gl:/var/lib/snapd/lib/gl/nvidia', + }, + fileSystem: virtualFileSystem, + }); + + expect(probeRuntime(virtualHelperPath)).toEqual( + expect.objectContaining({ + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + }) + ); + expect(context.spawnRuntimeProbe).toHaveBeenCalledWith( + virtualProviderWrapper, + [virtualHelperPath, '--runtime-probe'], + expect.objectContaining({ + env: expect.objectContaining({ + SNAP: virtualSnapRoot, + LD_LIBRARY_PATH: [ + path.join(virtualNativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + ...snapLibraries( + `graphics/usr/lib/${linuxTriplet}`, + `graphics/usr/lib/${linuxTriplet}/vdpau` + ), + '/usr/lib/x86_64-linux-gnu', + ...snapLibraries( + `gnome-platform/lib/${linuxTriplet}`, + `gnome-platform/usr/lib/${linuxTriplet}`, + `gnome-platform/usr/lib/${linuxTriplet}/mesa`, + `gnome-platform/usr/lib/${linuxTriplet}/mesa-egl`, + `gnome-platform/usr/lib/${linuxTriplet}/dri`, + `gnome-platform/usr/lib/${linuxTriplet}/pulseaudio`, + 'lib', + 'usr/lib', + `lib/${linuxTriplet}`, + `usr/lib/${linuxTriplet}` + ), + ].join(':'), + }), + }) + ); + }); + + it('reports a stable unavailable reason when the Snap graphics provider is disconnected', () => { + const actualSnapRoot = path.join(context.rootDir, 'snap-root'); + const fixture = createFixture( + path.join(actualSnapRoot, 'fixture'), + 'portable' + ); + const actualGraphicsRoot = path.join(actualSnapRoot, 'graphics'); + mkdirSync(actualGraphicsRoot, { recursive: true }); + + const virtualSnapRoot = '/snap/iptvnator/42'; + const virtualNativeDir = path.join( + virtualSnapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const translatePath = (candidatePath: string): string => { + if ( + candidatePath === virtualNativeDir || + candidatePath.startsWith(`${virtualNativeDir}${path.sep}`) + ) { + return path.join( + fixture.nativeDir, + path.relative(virtualNativeDir, candidatePath) + ); + } + const virtualGraphicsRoot = path.join(virtualSnapRoot, 'graphics'); + if ( + candidatePath === virtualGraphicsRoot || + candidatePath.startsWith(`${virtualGraphicsRoot}${path.sep}`) + ) { + return path.join( + actualGraphicsRoot, + path.relative(virtualGraphicsRoot, candidatePath) + ); + } + return candidatePath; + }; + const probeRuntime = context.createProbe({ + env: { SNAP: virtualSnapRoot }, + fileSystem: { + accessSync: (candidatePath, mode) => + accessSync(translatePath(candidatePath), mode), + lstatSync: (candidatePath) => + lstatSync(translatePath(candidatePath)), + readFileSync: (candidatePath) => + readFileSync(translatePath(candidatePath)), + readdirSync: (candidatePath) => + readdirSync(translatePath(candidatePath)), + }, + }); + + expect( + probeRuntime(path.join(virtualNativeDir, 'iptvnator_mpv_helper')) + ).toEqual({ + usable: false, + reason: 'snap-graphics-provider-unavailable', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); + + it('reprobes when the helper identity changes', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe(); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + writeFileSync(fixture.helperPath, '#!/bin/sh\n# changed\n'); + chmodSync(fixture.helperPath, 0o755); + + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2); + }); + + it('reprobes when the manifest identity changes', () => { + const fixture = createFixture(context.rootDir); + const probeRuntime = context.createProbe(); + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + + const manifest = cloneManifest(fixture.manifest); + manifest.generatedAt = '2026-07-17T00:01:00.000Z'; + writeManifest(fixture.manifestPath, manifest); + + expect(probeRuntime(fixture.helperPath).usable).toBe(true); + expect(context.spawnRuntimeProbe).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['linux', 'arm64', 'unsupported-architecture'], + ['linux', 'arm', 'unsupported-architecture'], + ['darwin', 'x64', 'unsupported-platform'], + ] as const)( + 'does not probe unsupported %s/%s runtimes', + (platform, arch, reason) => { + const fixture = createFixture(context.rootDir); + + expect( + context.createProbe({ platform, arch })(fixture.helperPath) + ).toEqual({ + usable: false, + reason, + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts new file mode 100644 index 000000000..2b9ad7e2d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts @@ -0,0 +1,334 @@ +import { spawnSync as nodeSpawnSync } from 'child_process'; +import * as nodeFileSystem from 'fs'; +import path from 'path'; +import { + RUNTIME_MANIFEST_NAME, + RUNTIME_PROBE_MAX_BUFFER_BYTES, + RUNTIME_PROBE_PROTOCOL, + RUNTIME_PROBE_TIMEOUT_MS, +} from './contracts'; +import { createLinuxFrameCopyHelperLaunch } from './helper-launch'; +import { validatePackage } from './package-validator'; +import { EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS } from './types'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeDependencies, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeResult, + EmbeddedMpvHelperRuntimeProbeFailureReason, + ValidManifest, + ValidatedPackage, +} from './types'; +import { + failure, + fileIdentity, + hasExactFields, + isMissingFileError, + isObject, + isValidationFailure, +} from './validation-primitives'; + +const HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST = new Set( + Object.values(EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS) +); +const HELPER_RUNTIME_PROBE_STDERR_LIMIT = 16_384; +const HELPER_RUNTIME_PROBE_STDERR_EVENT = + 'embedded-mpv-helper-runtime-probe-stderr'; + +interface ParsedFailedProbe { + helperReason: EmbeddedMpvHelperRuntimeProbeFailureReason; + helperDetail?: string; +} + +function isSafeHelperDetail(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length >= 1 && + value.length <= 1024 && + !/[^\x20-\x7e]/.test(value) + ); +} + +function parseFailedProbe(stdout: unknown): ParsedFailedProbe | null { + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(0, -1)); + } catch { + return null; + } + if (!isObject(parsed)) { + return null; + } + + const hasDetail = Object.prototype.hasOwnProperty.call(parsed, 'detail'); + const fields = hasDetail + ? ['detail', 'protocol', 'reason', 'usable'] + : ['protocol', 'reason', 'usable']; + if ( + !hasExactFields(parsed, fields) || + parsed.protocol !== RUNTIME_PROBE_PROTOCOL || + parsed.usable !== false || + typeof parsed.reason !== 'string' || + !HELPER_RUNTIME_PROBE_FAILURE_REASON_ALLOWLIST.has(parsed.reason) || + (hasDetail && !isSafeHelperDetail(parsed.detail)) + ) { + return null; + } + return { + helperReason: + parsed.reason as EmbeddedMpvHelperRuntimeProbeFailureReason, + ...(hasDetail ? { helperDetail: parsed.detail as string } : {}), + }; +} + +function parseSuccessfulProbe( + stdout: unknown, + manifest: ValidManifest +): EmbeddedMpvFrameCopyRuntimeResult { + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return failure('helper-probe-invalid-output'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout.slice(0, -1)); + } catch { + return failure('helper-probe-invalid-output'); + } + if (!isObject(parsed)) { + return failure('helper-probe-invalid-output'); + } + if (parsed.protocol !== RUNTIME_PROBE_PROTOCOL) { + return failure('helper-probe-protocol-mismatch'); + } + if (parsed.usable !== true) { + return failure( + parsed.usable === false + ? 'helper-probe-unusable' + : 'helper-probe-invalid-output' + ); + } + if ( + !hasExactFields(parsed, [ + 'libmpv', + 'protocol', + 'renderApi', + 'usable', + ]) || + typeof parsed.libmpv !== 'string' || + parsed.libmpv.trim() === '' || + parsed.renderApi !== 'egl' + ) { + return failure('helper-probe-invalid-output'); + } + return { + usable: true, + profile: manifest.profile, + runtimeMode: manifest.runtimeMode, + libmpv: parsed.libmpv, + renderApi: parsed.renderApi, + }; +} + +function traceHelperProbeStderr( + stderr: unknown, + dependencies: EmbeddedMpvFrameCopyRuntimeDependencies +): void { + if ( + dependencies.env.IPTVNATOR_TRACE_PLAYER !== '1' || + typeof stderr !== 'string' || + stderr.length === 0 + ) { + return; + } + + const boundedStderr = stderr.slice(0, HELPER_RUNTIME_PROBE_STDERR_LIMIT); + const output = `${JSON.stringify({ + event: HELPER_RUNTIME_PROBE_STDERR_EVENT, + stderr: boundedStderr, + truncated: stderr.length > boundedStderr.length, + })}\n`; + try { + dependencies.writeStderr(output); + } catch { + // Opt-in diagnostics must never change the fail-closed probe result. + } +} + +function runHelperProbe( + runtimePackage: ValidatedPackage, + dependencies: EmbeddedMpvFrameCopyRuntimeDependencies +): EmbeddedMpvFrameCopyRuntimeResult { + const launch = createLinuxFrameCopyHelperLaunch({ + environment: dependencies.env, + helperPath: runtimePackage.helperPath, + helperArgs: ['--runtime-probe'], + runtimeMode: runtimePackage.manifest.runtimeMode, + fileSystem: dependencies.fileSystem, + }); + if (launch.usable === false) { + return failure(launch.reason); + } + + let result: ReturnType; + try { + result = dependencies.spawnSync(launch.command, launch.args, { + encoding: 'utf8', + timeout: RUNTIME_PROBE_TIMEOUT_MS, + killSignal: 'SIGKILL', + windowsHide: true, + maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES, + env: launch.env, + }); + } catch { + return failure('helper-probe-spawn-error'); + } + traceHelperProbeStderr(result.stderr, dependencies); + + if ( + result.error && + 'code' in result.error && + result.error.code === 'ETIMEDOUT' + ) { + return failure('helper-probe-timeout'); + } + if (result.error) { + return failure('helper-probe-spawn-error'); + } + if (result.signal) { + return failure('helper-probe-signaled'); + } + if (result.status !== 0) { + const helperFailure = parseFailedProbe(result.stdout); + return helperFailure + ? { + usable: false, + reason: 'helper-probe-failed', + ...helperFailure, + } + : failure('helper-probe-failed'); + } + return parseSuccessfulProbe(result.stdout, runtimePackage.manifest); +} + +/** + * Creates an isolated probe/cache. Production uses the singleton below; + * tests inject filesystem and spawn collaborators through this factory. + */ +export function createEmbeddedMpvFrameCopyRuntimeProbe( + overrides: Partial = {} +): ( + helperPath: string, + manifestContract?: EmbeddedMpvFrameCopyManifestContract +) => EmbeddedMpvFrameCopyRuntimeResult { + const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = { + accessSync: (filePath, mode) => + nodeFileSystem.accessSync(filePath, mode), + lstatSync: (filePath) => nodeFileSystem.lstatSync(filePath), + readFileSync: (filePath) => nodeFileSystem.readFileSync(filePath), + readdirSync: (filePath) => nodeFileSystem.readdirSync(filePath), + }; + const dependencies: EmbeddedMpvFrameCopyRuntimeDependencies = { + platform: process.platform, + arch: process.arch, + env: process.env, + fileSystem: defaultFileSystem, + spawnSync: nodeSpawnSync, + writeStderr: (output) => { + nodeFileSystem.writeSync(process.stderr.fd, output); + }, + ...overrides, + }; + const resultCache = new Map(); + + return ( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged' + ): EmbeddedMpvFrameCopyRuntimeResult => { + if (dependencies.platform !== 'linux') { + return failure('unsupported-platform'); + } + if (dependencies.arch !== 'x64') { + return failure('unsupported-architecture'); + } + + const manifestPath = path.join( + path.dirname(helperPath), + RUNTIME_MANIFEST_NAME + ); + let helperStat: nodeFileSystem.Stats; + let manifestStat: nodeFileSystem.Stats; + try { + helperStat = dependencies.fileSystem.lstatSync(helperPath); + } catch { + return failure('runtime-artifact-missing'); + } + try { + manifestStat = dependencies.fileSystem.lstatSync(manifestPath); + } catch (error) { + return failure( + isMissingFileError(error) + ? 'runtime-manifest-missing' + : 'runtime-manifest-invalid' + ); + } + + let helperContents: Buffer; + let manifestContents: Buffer; + try { + helperContents = dependencies.fileSystem.readFileSync(helperPath); + } catch { + return failure('runtime-artifact-invalid'); + } + try { + manifestContents = + dependencies.fileSystem.readFileSync(manifestPath); + } catch { + return failure('runtime-manifest-invalid'); + } + + const cacheKey = `${manifestContract}\0${fileIdentity( + helperPath, + helperStat, + helperContents + )}\0${fileIdentity(manifestPath, manifestStat, manifestContents)}`; + const cached = resultCache.get(cacheKey); + if (cached) { + return cached; + } + + let result: EmbeddedMpvFrameCopyRuntimeResult; + try { + const runtimePackage = validatePackage( + helperPath, + manifestPath, + dependencies.fileSystem, + manifestContract + ); + result = isValidationFailure(runtimePackage) + ? failure(runtimePackage.reason) + : runHelperProbe(runtimePackage.value, dependencies); + } catch { + result = failure('runtime-probe-internal-error'); + } + resultCache.set(cacheKey, result); + return result; + }; +} + +const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe(); + +/** + * Fail-closed, process-lifetime Linux runtime decision shared by startup and + * the service gate. The helper and manifest identities scope cached results. + */ +export function probeEmbeddedMpvFrameCopyRuntime( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract +): EmbeddedMpvFrameCopyRuntimeResult { + return processRuntimeProbe(helperPath, manifestContract); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts new file mode 100644 index 000000000..8cf7789d6 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-closure-validator.ts @@ -0,0 +1,95 @@ +import { isDeepStrictEqual } from 'util'; +import { + ALLOWED_EXTERNAL_LIBRARY_NAMES, + EXPECTED_EXTERNAL_SYSTEM_LIBRARIES, + SAFE_RUNTIME_NAME_PATTERN, + SHARED_LIBRARY_PATTERN, +} from './contracts'; +import type { RuntimeFile } from './types'; +import { + hasExactFields, + isObject, + isSafeRuntimeName, +} from './validation-primitives'; + +export function validateRuntimeClosure( + value: unknown, + runtimeFiles: RuntimeFile[], + libmpvSoname: string, + externalSystemLibraries: unknown +): boolean { + if ( + !isDeepStrictEqual( + externalSystemLibraries, + EXPECTED_EXTERNAL_SYSTEM_LIBRARIES + ) || + !isObject(value) || + !hasExactFields(value, ['entries', 'externalDependencies']) || + !Array.isArray(value.entries) || + !Array.isArray(value.externalDependencies) || + value.externalDependencies.some( + (dependency) => + typeof dependency !== 'string' || + !SAFE_RUNTIME_NAME_PATTERN.test(dependency) || + !SHARED_LIBRARY_PATTERN.test(dependency) + ) + ) { + return false; + } + + const runtimeNames = runtimeFiles.map(({ name }) => name); + const runtimeNameSet = new Set(runtimeNames); + const closureNames: string[] = []; + const computedExternalDependencies = new Set(); + for (const entry of value.entries) { + if ( + !isObject(entry) || + !hasExactFields(entry, [ + 'name', + 'needed', + 'rpath', + 'runpath', + 'soname', + ]) || + !isSafeRuntimeName(entry.name) || + (entry.soname !== null && !isSafeRuntimeName(entry.soname)) || + !Array.isArray(entry.needed) || + entry.needed.some( + (dependency) => + typeof dependency !== 'string' || + !SAFE_RUNTIME_NAME_PATTERN.test(dependency) || + !SHARED_LIBRARY_PATTERN.test(dependency) + ) || + !isDeepStrictEqual(entry.rpath, []) || + !isDeepStrictEqual(entry.runpath, ['$ORIGIN']) || + new Set(entry.needed).size !== entry.needed.length || + !isDeepStrictEqual([...entry.needed].sort(), entry.needed) + ) { + return false; + } + closureNames.push(entry.name); + for (const dependency of entry.needed) { + if (runtimeNameSet.has(dependency)) { + continue; + } + if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependency)) { + return false; + } + computedExternalDependencies.add(dependency); + } + } + + const linkerAlias = value.entries.find( + (entry) => isObject(entry) && entry.name === 'libmpv.so' + ); + return ( + isDeepStrictEqual(closureNames, runtimeNames) && + new Set(closureNames).size === closureNames.length && + isDeepStrictEqual( + value.externalDependencies, + [...computedExternalDependencies].sort() + ) && + isObject(linkerAlias) && + linkerAlias.soname === libmpvSoname + ); +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts new file mode 100644 index 000000000..84f4c6523 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts @@ -0,0 +1,306 @@ +import { createHash } from 'crypto'; +import { chmodSync, mkdirSync, writeFileSync } from 'fs'; +import path from 'path'; +import type { createEmbeddedMpvFrameCopyRuntimeProbe } from '../embedded-mpv-frame-copy-runtime'; +import { + EXTERNAL_SYSTEM_LIBRARIES, + PINNED_SOURCE_PACKAGE_IDENTITIES, + type RuntimeFile, + type RuntimeFixture, +} from './runtime.spec-data'; + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function createRuntimeFiles( + runtimeContents: Record +): RuntimeFile[] { + return Object.entries(runtimeContents) + .map(([name, contents]) => ({ + name, + size: contents.length, + sha256: sha256(contents), + })) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function createSourceRuntime( + runtimeFiles: RuntimeFile[], + runtimeDependencyClosure: Record +): Record { + const packages = cloneManifest(PINNED_SOURCE_PACKAGE_IDENTITIES); + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + packages, + ffmpeg: { + ...(packages.ffmpeg as Record), + configureFlags: ['--disable-gpl', '--disable-nonfree'], + }, + mpv: { + ...(packages.mpv as Record), + mesonFlags: ['-Dgpl=false', '-Dlibmpv=true'], + }, + sourceDistribution: + 'Publish the exact hwdata archive and pnp.ids with the libdisplay-info source.', + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + runtimeAbi: { + baseline: { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', + }, + files: runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + }, + runtimeDependencyClosure, + externalSystemLibraries: cloneManifest(EXTERNAL_SYSTEM_LIBRARIES), + }; +} + +export function createFixture( + rootDir: string, + profile: 'system' | 'portable' | 'flatpak' = 'system' +): RuntimeFixture { + const nativeDir = path.join(rootDir, profile, 'native'); + const helperPath = path.join(nativeDir, 'iptvnator_mpv_helper'); + const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json'); + mkdirSync(nativeDir, { recursive: true }); + writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + writeFileSync(helperPath, '#!/bin/sh\n', { mode: 0o755 }); + + const bundled = profile !== 'system'; + const runtimeContents = bundled + ? { + 'libmpv.so': Buffer.from('libmpv-linker-alias'), + 'libmpv.so.2': Buffer.from('libmpv-soname'), + } + : {}; + const runtimeFiles = createRuntimeFiles(runtimeContents); + const runtimeDependencyClosure = { + entries: runtimeFiles.map(({ name }) => ({ + name, + soname: name === 'libmpv.so' ? 'libmpv.so.2' : name, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + })), + externalDependencies: [], + }; + const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES); + const sourceRuntime = createSourceRuntime( + runtimeFiles, + runtimeDependencyClosure + ); + const sourceArchive = { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: '7'.repeat(64), + repositoryRevision: '8'.repeat(40), + }; + const manifest: Record = { + schemaVersion: 1, + origin: bundled + ? 'bundled-lgpl-frame-copy' + : 'system-libmpv-frame-copy', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + profile, + runtimeMode: bundled ? 'bundled' : 'system', + targets: + profile === 'system' + ? ['deb', 'pacman', 'rpm'] + : profile === 'portable' + ? ['appimage', 'snap'] + : ['flatpak'], + artifacts: { + addon: { + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }, + frameReader: { + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }, + helper: { + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + packageDependencies: bundled + ? {} + : { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ], + pacman: ['mpv', 'libglvnd', 'mesa'], + }, + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + ...(bundled + ? { + runtimeDependencyClosure, + externalSystemLibraries, + sourceArchive, + sourceRuntime, + } + : {}), + }; + writeManifest(manifestPath, manifest); + if (bundled) { + const libDir = path.join(nativeDir, 'lib'); + mkdirSync(libDir); + for (const [name, contents] of Object.entries(runtimeContents)) { + writeFileSync(path.join(libDir, name), contents, { + mode: 0o644, + }); + } + } + return { + nativeDir, + helperPath, + manifestPath, + manifest, + runtimeContents, + }; +} + +export function createDevelopmentFixture( + rootDir: string, + buildInputMode: 'system-dev' | 'system-build-inputs' | 'bundled-runtime' +): RuntimeFixture { + const bundled = buildInputMode === 'bundled-runtime'; + const fixture = createFixture(rootDir, bundled ? 'portable' : 'system'); + const packagedSourceRuntime = fixture.manifest.sourceRuntime; + const sourceRuntime = + buildInputMode === 'system-dev' + ? { + linuxBackend: 'process-isolated mpv --wid', + warning: + 'Development-only unmanaged system libmpv toolchain.', + } + : buildInputMode === 'system-build-inputs' + ? { + linuxBackend: 'process-isolated mpv --wid', + buildInputs: { + libmpvDevPackage: 'libmpv-dev', + mpvPackage: 'mpv', + }, + sourceDistribution: + 'Linux development inputs are supplied by the host package manager.', + } + : packagedSourceRuntime; + const manifest: Record = { + schemaVersion: 1, + origin: 'linux-frame-copy-build', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + buildInputMode, + sourceRuntimeValidated: bundled, + allowedPackageRuntimeModes: ['system', 'bundled'], + packageRuntimeAvailability: { + system: bundled, + bundled, + }, + artifacts: { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', + }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + 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; + writeManifest(fixture.manifestPath, manifest); + return fixture; +} + +export function probeDevelopmentRuntime( + probeRuntime: ReturnType, + helperPath: string +) { + return ( + probeRuntime as unknown as ( + path: string, + contract: 'development' + ) => ReturnType + )(helperPath, 'development'); +} + +export function mirrorBundledManifestFields( + manifest: Record +): void { + const sourceRuntime = manifest.sourceRuntime as Record; + sourceRuntime.runtimeFiles = cloneManifest(manifest.runtimeFiles); + sourceRuntime.runtimeTotalBytes = manifest.runtimeTotalBytes; + sourceRuntime.runtimeDependencyClosure = cloneManifest( + manifest.runtimeDependencyClosure + ); + sourceRuntime.externalSystemLibraries = cloneManifest( + manifest.externalSystemLibraries + ); +} + +export function writeManifest( + manifestPath: string, + manifest: Record +): void { + writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { + mode: 0o644, + }); + chmodSync(manifestPath, 0o644); +} + +export function cloneManifest(manifest: T): T { + return JSON.parse(JSON.stringify(manifest)) as T; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts new file mode 100644 index 000000000..adbdbef73 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts @@ -0,0 +1,74 @@ +import { spawnSync } from 'child_process'; +import { + accessSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, +} from 'fs'; +import { tmpdir } from 'os'; +import path from 'path'; +import { + createEmbeddedMpvFrameCopyRuntimeProbe, + type EmbeddedMpvFrameCopyRuntimeDependencies, +} from '../embedded-mpv-frame-copy-runtime'; +import { SUCCESS_OUTPUT } from './runtime.spec-data'; + +export interface RuntimeTestContext { + rootDir: string; + spawnRuntimeProbe: jest.Mock; + writeRuntimeProbeStderr: jest.Mock; + fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem']; + createProbe( + overrides?: Partial + ): ReturnType; + dispose(): void; +} + +export function createRuntimeTestContext(): RuntimeTestContext { + const rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-')); + const spawnRuntimeProbe = jest.fn(() => ({ + status: 0, + signal: null, + stdout: SUCCESS_OUTPUT, + stderr: '', + })); + const writeRuntimeProbeStderr = jest.fn(); + const fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem'] = { + accessSync: jest.fn((filePath: string, mode: number) => + accessSync(filePath, mode) + ), + lstatSync: jest.fn((filePath: string) => lstatSync(filePath)), + readFileSync: jest.fn((filePath: string) => readFileSync(filePath)), + readdirSync: jest.fn((filePath: string) => readdirSync(filePath)), + }; + const context: RuntimeTestContext = { + rootDir, + spawnRuntimeProbe, + writeRuntimeProbeStderr, + fileSystem, + createProbe( + overrides: Partial = {} + ) { + return createEmbeddedMpvFrameCopyRuntimeProbe({ + platform: 'linux', + arch: 'x64', + env: { + PATH: '/usr/bin', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/ambient/libs', + LD_PRELOAD: '/tmp/inject.so', + }, + fileSystem: context.fileSystem, + spawnSync: context.spawnRuntimeProbe as typeof spawnSync, + writeStderr: context.writeRuntimeProbeStderr, + ...overrides, + }); + }, + dispose() { + rmSync(context.rootDir, { recursive: true, force: true }); + }, + }; + return context; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts new file mode 100644 index 000000000..81623319a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts @@ -0,0 +1,179 @@ +export const SUCCESS_OUTPUT = + '{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n'; + +export const EXTERNAL_SYSTEM_LIBRARIES = [ + { + name: 'libEGL.so.1', + interface: 'EGL', + reason: 'System graphics-driver interface used by the frame-copy helper.', + }, + { + name: 'libGL.so.1', + interface: 'OpenGL', + reason: 'System OpenGL compatibility interface supplied by the graphics stack.', + }, + { + name: 'libGLX.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.', + }, + { + name: 'libOpenGL.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL interface supplied by the graphics stack.', + }, + { + name: 'libasound.so.2', + interface: 'ALSA', + reason: 'Linux system audio interface intentionally used by libmpv.', + }, + { + name: 'libdrm.so.2', + interface: 'DRM', + reason: 'Kernel graphics interface used by system GBM and VA-API drivers.', + }, + { + name: 'libgbm.so.1', + interface: 'GBM', + reason: 'System graphics-buffer interface used by headless EGL rendering.', + }, + { + name: 'libpulse.so.0', + interface: 'PulseAudio', + reason: 'Linux desktop audio interface intentionally used by libmpv.', + }, + { + name: 'libva-drm.so.2', + interface: 'VA-API DRM', + reason: 'System VA-API DRM interface used for hardware decoding.', + }, + { + name: 'libva.so.2', + interface: 'VA-API', + reason: 'System video-acceleration interface used for hardware decoding.', + }, +]; + +export const PINNED_SOURCE_PACKAGE_IDENTITIES = { + freetype: { + version: '2.13.3', + sourceUrl: + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + sourceSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + license: 'FreeType License (FTL)', + }, + fribidi: { + version: '1.0.16', + sourceUrl: + 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + sourceSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + license: 'LGPL-2.1-or-later', + }, + harfbuzz: { + version: '8.5.0', + sourceUrl: + 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + sourceSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + license: 'MIT', + }, + expat: { + version: '2.8.2', + sourceUrl: + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz', + sourceSha256: + '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + license: 'MIT', + }, + fontconfig: { + version: '2.16.0', + sourceUrl: + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz', + sourceSha256: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + license: 'MIT', + }, + libass: { + version: '0.17.3', + sourceUrl: + 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + sourceSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + license: 'ISC', + }, + openssl: { + version: '3.5.7', + sourceUrl: + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz', + sourceSha256: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + license: 'Apache-2.0', + }, + ffmpeg: { + version: '8.1', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + sourceSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '7.360.1', + sourceUrl: 'https://github.com/haasn/libplacebo.git', + sourceTag: 'v7.360.1', + sourceGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', + sourceSubmodules: [ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', + ], + license: 'LGPL-2.1-or-later', + }, + hwdata: { + version: '0.409', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + sourceSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + buildInput: { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }, + license: 'GPL-2.0-or-later OR XFree86-1.0', + }, + 'libdisplay-info': { + version: '0.1.1', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + sourceSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, + mpv: { + version: '0.41.0', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + sourceSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + license: 'LGPL-2.1-or-later with -Dgpl=false', + }, +}; + +export interface RuntimeFile { + name: string; + size: number; + sha256: string; +} + +export interface RuntimeFixture { + nativeDir: string; + helperPath: string; + manifestPath: string; + manifest: Record; + runtimeContents: Record; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts new file mode 100644 index 000000000..6663bd4cd --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts @@ -0,0 +1,150 @@ +import { + cloneManifest, + createFixture, + writeManifest, +} from './runtime-fixtures.test-helpers'; +import { + createRuntimeTestContext, + type RuntimeTestContext, +} from './runtime-harness.test-helpers'; + +describe('embedded-mpv frame-copy source runtime policy', () => { + let context: RuntimeTestContext; + + beforeEach(() => { + context = createRuntimeTestContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + it.each([ + { + label: 'pinned source identity', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.mpv.sourceSha256 = '0'.repeat(64); + }, + }, + { + label: 'pinned source URL', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.freetype.sourceUrl = + 'https://example.invalid/freetype.tar.xz'; + }, + }, + { + label: 'pinned git tag', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceTag = 'main'; + }, + }, + { + label: 'pinned hwdata build input', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.hwdata.buildInput = { + consumer: 'libdisplay-info', + relativePath: '../pnp.ids', + purpose: + 'PNP vendor lookup table compiled into libdisplay-info.', + }; + }, + }, + { + label: 'git submodule record', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceSubmodules = [ + `${'a'.repeat(40)} ../outside`, + ]; + }, + }, + { + label: 'duplicate git submodule records', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + const record = `${'a'.repeat(40)} 3rdparty/example`; + packages.libplacebo.sourceSubmodules = [record, record]; + }, + }, + { + label: 'unpinned git submodule commit', + mutate(sourceRuntime: Record) { + const packages = sourceRuntime.packages as Record< + string, + Record + >; + packages.libplacebo.sourceSubmodules = [ + `${'f'.repeat(40)} 3rdparty/Vulkan-Headers`, + `${'e'.repeat(40)} 3rdparty/fast_float`, + ]; + }, + }, + { + label: 'portable ABI baseline', + mutate(sourceRuntime: Record) { + const runtimeAbi = sourceRuntime.runtimeAbi as { + baseline: Record; + }; + runtimeAbi.baseline.glibcMaximum = '9.99'; + }, + }, + { + label: 'source-distribution obligation', + mutate(sourceRuntime: Record) { + sourceRuntime.sourceDistribution = 'Sources available.'; + }, + }, + { + label: 'FFmpeg LGPL flags', + mutate(sourceRuntime: Record) { + const ffmpeg = sourceRuntime.ffmpeg as { + configureFlags: string[]; + }; + ffmpeg.configureFlags = ['--disable-nonfree', '--enable-gpl']; + }, + }, + { + label: 'mpv LGPL flags', + mutate(sourceRuntime: Record) { + const mpv = sourceRuntime.mpv as { + mesonFlags: string[]; + }; + mpv.mesonFlags = ['-Dgpl=true', '-Dlibmpv=true']; + }, + }, + ])('rejects an invalid $label', ({ mutate }) => { + const fixture = createFixture(context.rootDir, 'portable'); + const manifest = cloneManifest(fixture.manifest); + mutate(manifest.sourceRuntime as Record); + writeManifest(fixture.manifestPath, manifest); + + expect(context.createProbe()(fixture.helperPath)).toEqual({ + usable: false, + reason: 'runtime-manifest-invalid', + }); + expect(context.spawnRuntimeProbe).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts new file mode 100644 index 000000000..ef5ad6c5f --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts @@ -0,0 +1,247 @@ +import path from 'path'; +import { isDeepStrictEqual } from 'util'; +import { + GIT_COMMIT_PATTERN, + PINNED_SOURCE_PACKAGE_IDENTITIES, + PORTABLE_ABI_BASELINE, + SUBMODULE_RECORD_PATTERN, + VERSION_PATTERN, +} from './contracts'; +import type { RuntimeFile } from './types'; +import { + hasExactFields, + isObject, + isSafeRuntimeName, +} from './validation-primitives'; + +function compareDottedVersions(left: string, right: string): number { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return difference; + } + } + return 0; +} + +function validatesPinnedSourceIdentity( + candidate: unknown, + expected: (typeof PINNED_SOURCE_PACKAGE_IDENTITIES)[keyof typeof PINNED_SOURCE_PACKAGE_IDENTITIES] +): boolean { + if ( + !isObject(candidate) || + candidate.version !== expected.version || + candidate.sourceUrl !== expected.sourceUrl || + candidate.license !== expected.license + ) { + return false; + } + if ( + ('sourceTag' in expected + ? candidate.sourceTag !== expected.sourceTag + : candidate.sourceTag !== undefined) || + ('buildInput' in expected + ? !isDeepStrictEqual(candidate.buildInput, expected.buildInput) + : candidate.buildInput !== undefined) + ) { + return false; + } + if ('sourceSha256' in expected) { + return ( + candidate.sourceSha256 === expected.sourceSha256 && + candidate.sourceGitCommit === undefined && + candidate.sourceSubmodules === undefined + ); + } + return ( + 'sourceSubmodules' in expected && + candidate.sourceGitCommit === expected.sourceGitCommit && + GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) && + candidate.sourceSha256 === undefined && + validatesGitSubmoduleRecords(candidate.sourceSubmodules) && + isDeepStrictEqual(candidate.sourceSubmodules, expected.sourceSubmodules) + ); +} + +function validatesGitSubmoduleRecords(value: unknown): boolean { + if ( + !Array.isArray(value) || + value.length === 0 || + new Set(value).size !== value.length + ) { + return false; + } + return value.every((record) => { + if (typeof record !== 'string') { + return false; + } + const match = record.match(SUBMODULE_RECORD_PATTERN); + if (!match) { + return false; + } + const submodulePath = match[1]; + return ( + !path.posix.isAbsolute(submodulePath) && + !submodulePath + .split('/') + .some((segment) => segment === '.' || segment === '..') + ); + }); +} + +function validateStringFlags(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.every( + (flag) => + typeof flag === 'string' && + flag.length > 0 && + flag.trim() === flag + ) && + new Set(value).size === value.length + ); +} + +function validateRuntimeAbi( + value: unknown, + runtimeFiles: RuntimeFile[] +): boolean { + if ( + !isObject(value) || + !hasExactFields(value, ['baseline', 'files']) || + !isDeepStrictEqual(value.baseline, PORTABLE_ABI_BASELINE) || + !Array.isArray(value.files) + ) { + return false; + } + + const abiFileNames: string[] = []; + for (const record of value.files) { + if ( + !isObject(record) || + !hasExactFields(record, [ + 'name', + 'requiredGlibc', + 'requiredGlibcxx', + ]) || + !isSafeRuntimeName(record.name) + ) { + return false; + } + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ] as const) { + const version = record[field]; + if ( + version !== null && + (typeof version !== 'string' || + !VERSION_PATTERN.test(version) || + compareDottedVersions(version, maximum) > 0) + ) { + return false; + } + } + abiFileNames.push(record.name); + } + + return isDeepStrictEqual( + abiFileNames, + runtimeFiles.map(({ name }) => name) + ); +} + +/** + * The source builder and package verifier own exhaustive build-host, recipe, + * tool-version, URL and external-configuration validation. Startup repeats + * only the immutable policy boundary needed before sandbox relaxation: + * pinned source identities/licenses, LGPL flags, portable ABI, display-data + * distribution, and the exact runtime closure mirrored by the package. + */ +export function validateSourceRuntimePolicy( + value: unknown, + runtimeFiles: RuntimeFile[], + runtimeDependencyClosure: unknown, + externalSystemLibraries: unknown +): boolean { + if ( + !isObject(value) || + value.schemaVersion !== 1 || + value.origin !== 'vendored-lgpl-source-build' || + value.platform !== 'linux' || + value.arch !== 'x64' || + !isObject(value.packages) || + !isObject(value.ffmpeg) || + !isObject(value.mpv) || + !isDeepStrictEqual( + Object.keys(value.packages).sort(), + Object.keys(PINNED_SOURCE_PACKAGE_IDENTITIES).sort() + ) + ) { + return false; + } + + for (const [packageName, expectedIdentity] of Object.entries( + PINNED_SOURCE_PACKAGE_IDENTITIES + )) { + if ( + !validatesPinnedSourceIdentity( + value.packages[packageName], + expectedIdentity + ) + ) { + return false; + } + } + + if ( + !validatesPinnedSourceIdentity( + value.ffmpeg, + PINNED_SOURCE_PACKAGE_IDENTITIES.ffmpeg + ) || + !validateStringFlags(value.ffmpeg.configureFlags) || + !value.ffmpeg.configureFlags.includes('--disable-gpl') || + !value.ffmpeg.configureFlags.includes('--disable-nonfree') || + value.ffmpeg.configureFlags.includes('--enable-gpl') || + value.ffmpeg.configureFlags.includes('--enable-nonfree') || + !validatesPinnedSourceIdentity( + value.mpv, + PINNED_SOURCE_PACKAGE_IDENTITIES.mpv + ) || + !validateStringFlags(value.mpv.mesonFlags) || + !value.mpv.mesonFlags.includes('-Dgpl=false') || + !value.mpv.mesonFlags.includes('-Dlibmpv=true') || + value.mpv.mesonFlags.includes('-Dgpl=true') + ) { + return false; + } + + if ( + typeof value.sourceDistribution !== 'string' || + !/\bhwdata\b/i.test(value.sourceDistribution) || + !/\bpnp\.ids\b/i.test(value.sourceDistribution) || + !/\blibdisplay-info\b/i.test(value.sourceDistribution) || + value.runtimeTotalBytes !== + runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) || + !isDeepStrictEqual(value.runtimeFiles, runtimeFiles) || + !isDeepStrictEqual( + value.runtimeDependencyClosure, + runtimeDependencyClosure + ) || + !isDeepStrictEqual( + value.externalSystemLibraries, + externalSystemLibraries + ) || + !validateRuntimeAbi(value.runtimeAbi, runtimeFiles) + ) { + return false; + } + + return true; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts new file mode 100644 index 000000000..c83432f38 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/trusted-snap-root.ts @@ -0,0 +1,51 @@ +import path from 'path'; + +const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; + +function isPathInside( + parentPath: string, + candidatePath: string, + allowEqual: boolean +): boolean { + const relativePath = path.relative(parentPath, candidatePath); + if (relativePath === '') { + return allowEqual; + } + return ( + relativePath !== '..' && + !relativePath.startsWith(`..${path.sep}`) && + !path.isAbsolute(relativePath) + ); +} + +export function resolveTrustedSnapRoot( + environment: NodeJS.ProcessEnv, + nativeDir: string +): string | null { + const declaredSnapRoot = environment.SNAP; + if ( + !declaredSnapRoot || + !path.isAbsolute(declaredSnapRoot) || + !path.isAbsolute(nativeDir) + ) { + return null; + } + + const normalizedSnapRoot = path.resolve(declaredSnapRoot); + const resemblesReadOnlySnapMount = TRUSTED_SNAP_MOUNT_ROOTS.some( + (mountRoot) => { + const relativePath = path.relative(mountRoot, normalizedSnapRoot); + return ( + isPathInside(mountRoot, normalizedSnapRoot, false) && + relativePath.split(path.sep).filter(Boolean).length >= 2 + ); + } + ); + if ( + !resemblesReadOnlySnapMount || + !isPathInside(normalizedSnapRoot, path.resolve(nativeDir), false) + ) { + return null; + } + return normalizedSnapRoot; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts new file mode 100644 index 000000000..96240b24c --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts @@ -0,0 +1,103 @@ +import type { spawnSync as nodeSpawnSync } from 'child_process'; +import type * as nodeFileSystem from 'fs'; + +export const EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS = { + MPV_CREATE_FAILED: 'mpv-create-failed', + MPV_INITIALIZE_FAILED: 'mpv-initialize-failed', + GL_CONTEXT_CREATE_FAILED: 'gl-context-create-failed', + GL_CONTEXT_BIND_FAILED: 'gl-context-bind-failed', + MPV_RENDER_CONTEXT_FAILED: 'mpv-render-context-failed', + SHARED_MEMORY_CREATE_FAILED: 'shared-memory-create-failed', + SHARED_MEMORY_INITIALIZE_FAILED: 'shared-memory-initialize-failed', +} as const; + +export type EmbeddedMpvHelperRuntimeProbeFailureReason = + (typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS)[keyof typeof EMBEDDED_MPV_HELPER_RUNTIME_PROBE_FAILURE_REASONS]; + +export type EmbeddedMpvFrameCopyRuntimeFailureReason = + | 'unsupported-platform' + | 'unsupported-architecture' + | 'runtime-manifest-missing' + | 'runtime-manifest-invalid' + | 'runtime-artifact-missing' + | 'runtime-artifact-invalid' + | 'runtime-library-directory-invalid' + | 'runtime-library-missing' + | 'runtime-library-undeclared' + | 'runtime-library-invalid' + | 'runtime-library-size-mismatch' + | 'runtime-library-hash-mismatch' + | 'snap-graphics-provider-unavailable' + | 'helper-probe-timeout' + | 'helper-probe-spawn-error' + | 'helper-probe-signaled' + | 'helper-probe-failed' + | 'helper-probe-invalid-output' + | 'helper-probe-protocol-mismatch' + | 'helper-probe-unusable' + | 'runtime-probe-internal-error'; + +export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development'; +export type EmbeddedMpvFrameCopyRuntimeMode = 'system' | 'bundled'; + +export type RuntimeProfile = 'system' | 'portable' | 'flatpak'; +export type RuntimeProbeProfile = RuntimeProfile | 'development'; + +export type EmbeddedMpvFrameCopyRuntimeResult = + | { + usable: true; + profile: RuntimeProbeProfile; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + libmpv: string; + renderApi: 'egl'; + } + | { + usable: false; + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; + helperReason?: EmbeddedMpvHelperRuntimeProbeFailureReason; + helperDetail?: string; + }; + +export interface EmbeddedMpvFrameCopyRuntimeFileSystem { + accessSync(filePath: string, mode: number): void; + lstatSync(filePath: string): nodeFileSystem.Stats; + readFileSync(filePath: string): Buffer; + readdirSync(filePath: string): string[]; +} + +export interface EmbeddedMpvFrameCopyRuntimeDependencies { + platform: NodeJS.Platform; + arch: string; + env: NodeJS.ProcessEnv; + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem; + spawnSync: typeof nodeSpawnSync; + writeStderr(output: string): void; +} + +export interface RuntimeFile { + name: string; + size: number; + sha256: string; +} + +export interface ValidManifest { + profile: RuntimeProbeProfile; + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; + runtimeFiles: RuntimeFile[]; +} + +export interface ValidatedPackage { + manifest: ValidManifest; + helperPath: string; + nativeDir: string; +} + +export interface ValidationSuccess { + value: T; +} + +export interface ValidationFailure { + reason: EmbeddedMpvFrameCopyRuntimeFailureReason; +} + +export type ValidationResult = ValidationSuccess | ValidationFailure; diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts new file mode 100644 index 000000000..68c252415 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/validation-primitives.ts @@ -0,0 +1,162 @@ +import { createHash } from 'crypto'; +import type * as nodeFileSystem from 'fs'; +import path from 'path'; +import { isDeepStrictEqual } from 'util'; +import { + SAFE_RUNTIME_NAME_PATTERN, + SHA256_PATTERN, + SHARED_LIBRARY_PATTERN, +} from './contracts'; +import type { + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeResult, + RuntimeFile, + ValidationFailure, + ValidationResult, +} from './types'; + +export function failure( + reason: EmbeddedMpvFrameCopyRuntimeFailureReason +): EmbeddedMpvFrameCopyRuntimeResult { + return { usable: false, reason }; +} + +export function validationFailure( + reason: EmbeddedMpvFrameCopyRuntimeFailureReason +): ValidationFailure { + return { reason }; +} + +export function isValidationFailure( + result: ValidationResult +): result is ValidationFailure { + return 'reason' in result; +} + +export function isObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +export function hasExactFields( + value: Record, + fields: readonly string[] +): boolean { + return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()); +} + +export function isSafeRuntimeName(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value !== '.' && + value !== '..' && + path.basename(value) === value && + !value.includes('/') && + !value.includes('\\') && + SAFE_RUNTIME_NAME_PATTERN.test(value) && + SHARED_LIBRARY_PATTERN.test(value) + ); +} + +export function isMissingFileError(error: unknown): boolean { + return ( + isObject(error) && + typeof error.code === 'string' && + (error.code === 'ENOENT' || error.code === 'ENOTDIR') + ); +} + +export function fileIdentity( + filePath: string, + stat: nodeFileSystem.Stats, + contents: Buffer +): string { + return [ + path.resolve(filePath), + stat.dev, + stat.ino, + stat.mode, + stat.size, + stat.mtimeMs, + stat.ctimeMs, + createHash('sha256').update(contents).digest('hex'), + ].join(':'); +} + +export function readManifest( + manifestPath: string, + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem +): ValidationResult> { + let contents: Buffer; + try { + contents = fileSystem.readFileSync(manifestPath); + } catch { + return validationFailure('runtime-manifest-invalid'); + } + + try { + const parsed: unknown = JSON.parse(contents.toString('utf8')); + return isObject(parsed) + ? { value: parsed } + : validationFailure('runtime-manifest-invalid'); + } catch { + return validationFailure('runtime-manifest-invalid'); + } +} + +export function validateTargets( + targets: unknown, + allowedTargets: ReadonlySet +): boolean { + const expectedTargets = [...allowedTargets].sort(); + if ( + !Array.isArray(targets) || + targets.length !== expectedTargets.length || + targets.some( + (target) => + typeof target !== 'string' || + target.trim() !== target || + target.toLowerCase() !== target || + !allowedTargets.has(target) + ) + ) { + return false; + } + return isDeepStrictEqual(targets, expectedTargets); +} + +export function validateRuntimeFiles(value: unknown): RuntimeFile[] | null { + if (!Array.isArray(value) || value.length === 0) { + return null; + } + + const runtimeFiles: RuntimeFile[] = []; + const names = new Set(); + for (const candidate of value) { + if ( + !isObject(candidate) || + !hasExactFields(candidate, ['name', 'sha256', 'size']) || + !isSafeRuntimeName(candidate.name) || + !Number.isSafeInteger(candidate.size) || + (candidate.size as number) <= 0 || + typeof candidate.sha256 !== 'string' || + !SHA256_PATTERN.test(candidate.sha256) || + names.has(candidate.name) + ) { + return null; + } + names.add(candidate.name); + runtimeFiles.push({ + name: candidate.name, + size: candidate.size as number, + sha256: candidate.sha256, + }); + } + return isDeepStrictEqual( + runtimeFiles.map(({ name }) => name).sort(), + runtimeFiles.map(({ name }) => name) + ) + ? runtimeFiles + : null; +} diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index 26fa33b55..10698bd55 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import type { Stats } from 'fs'; import path from 'path'; const spawnMock = jest.fn(); @@ -7,6 +8,55 @@ jest.mock('child_process', () => ({ })); import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; + +const HOSTILE_LOADER_ENVIRONMENT = { + BASH_ENV: '/tmp/hostile-bash-env', + ENV: '/tmp/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/tmp/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/tmp/hostile-cdpath', + 'BASH_FUNC_dirname%%': '() { printf /tmp/hostile-provider-root; exit 0; }', + LD_AUDIT: '/tmp/audit.so', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_ORIGIN_PATH: '/tmp/hostile-origin', + LD_PRELOAD: '/tmp/inject.so', + __EGL_VENDOR_LIBRARY_FILENAMES: '/tmp/hostile-egl-vendor.json', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor-dir', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: '/tmp/hostile-egl-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: '/tmp/hostile-egl-platform.json', + GBM_BACKEND: '../../../../../tmp/hostile-gbm', + GBM_BACKENDS_PATH: '/tmp/hostile-gbm-path', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri-path', + MESA_LOADER_DRIVER_OVERRIDE: '../../../../../tmp/hostile-dri', + LIBVA_DRIVER_NAME: '../../../../../tmp/hostile-va', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va-path', + VDPAU_DRIVER_PATH: '/tmp/hostile-vdpau', + VK_DRIVER_FILES: '/tmp/hostile-vulkan-driver.json', + VK_ICD_FILENAMES: '/tmp/hostile-vulkan-icd.json', + VK_ADD_DRIVER_FILES: '/tmp/hostile-vulkan-add-driver.json', + VK_ADD_LAYER_PATH: '/tmp/hostile-vulkan-layers', + VK_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-implicit-layers', + VK_ADD_IMPLICIT_LAYER_PATH: '/tmp/hostile-vulkan-add-implicit-layers', + VK_LAYER_PATH: '/tmp/hostile-vulkan-layer-path', +} as const; + +const GRAPHICS_SELECTOR_ENVIRONMENT = { + LIBGL_ALWAYS_SOFTWARE: '1', + GALLIUM_DRIVER: 'llvmpipe', +} as const; + +function fakeStat( + kind: 'directory' | 'file' +): Pick { + return { + isDirectory: () => kind === 'directory', + isFile: () => kind === 'file', + isSymbolicLink: () => false, + }; +} class FakeHelperProcess extends EventEmitter { exitCode: number | null = null; @@ -36,14 +86,34 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { let frameSourceChanges: Array<{ sessionId: string; shmName: string }>; let adapter: EmbeddedMpvFrameCopyAdapter; - const createAdapter = (helperPath: string | null = '/native/helper') => { + const createAdapter = ( + helperPath: string | null = '/native/helper', + { + runtimeMode = 'system', + environment, + helperLaunchFileSystem, + }: { + runtimeMode?: EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: { + lstatSync(filePath: string): Stats; + accessSync(filePath: string, mode: number): void; + }; + } = {} + ) => { frameSourceChanges = []; return new EmbeddedMpvFrameCopyAdapter({ resolveHelperPath: () => helperPath, + resolveRuntimeMode: () => runtimeMode, + environment, + helperLaunchFileSystem, getScaleFactor: () => 2, onFrameSourceChanged: (sessionId, source) => - frameSourceChanges.push({ sessionId, shmName: source.shmName }), - }); + frameSourceChanges.push({ + sessionId, + shmName: source.shmName, + }), + } as ConstructorParameters[0]); }; beforeEach(() => { @@ -83,6 +153,269 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ]); }); + describe('Linux loader environment', () => { + const originalPlatform = process.platform; + const originalArch = process.arch; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + }); + Object.defineProperty(process, 'arch', { value: originalArch }); + }); + + it('uses a sanitized system environment for the real helper session', () => { + adapter = createAdapter('/opt/iptvnator/native/helper', { + runtimeMode: 'system', + environment: { + PATH: '/usr/bin', + HOME: '/home/user', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + + createSession(); + + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/bin', + HOME: '/home/user', + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + }); + + it('keeps trusted Snap GL roots ahead of generic Snap libraries for playback', () => { + const snapRoot = '/snap/iptvnator/42'; + const nativeDir = path.join( + snapRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + adapter = createAdapter(path.join(nativeDir, 'helper'), { + runtimeMode: 'bundled', + helperLaunchFileSystem: { + lstatSync: (candidatePath) => + fakeStat( + candidatePath.endsWith('/graphics') + ? 'directory' + : 'file' + ) as Stats, + accessSync: () => undefined, + }, + environment: { + PATH: '/snap/bin:/usr/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + SNAP_DESKTOP_ARCH_TRIPLET: 'hostile-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + GBM_BACKENDS_PATH: '/tmp/hostile-gbm', + LIBGL_DRIVERS_PATH: '/tmp/hostile-dri', + LIBVA_DRIVERS_PATH: '/tmp/hostile-va', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/tmp/hostile-egl-platform', + __EGL_VENDOR_LIBRARY_DIRS: '/tmp/hostile-egl-vendor', + VK_LAYER_PATH: '/tmp/hostile-vulkan', + XDG_CONFIG_HOME: '/tmp/hostile-xdg-config-home', + XDG_CONFIG_DIRS: '/tmp/hostile-xdg-config-dirs', + XDG_DATA_HOME: '/tmp/hostile-xdg-data-home', + XDG_DATA_DIRS: '/tmp/hostile-xdg-data-dirs', + ...HOSTILE_LOADER_ENVIRONMENT, + ...GRAPHICS_SELECTOR_ENVIRONMENT, + }, + }); + + createSession(); + + expect(spawnMock.mock.calls[0][0]).toBe( + path.join( + snapRoot, + 'graphics', + 'bin', + 'graphics-core22-provider-wrapper' + ) + ); + expect(spawnMock.mock.calls[0][1][0]).toBe( + path.join(nativeDir, 'helper') + ); + expect(spawnMock.mock.calls[0][2]).toEqual({ + stdio: ['pipe', 'pipe', 'pipe'], + env: { + PATH: '/usr/sbin:/usr/bin:/sbin:/bin', + SNAP: snapRoot, + SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl', + SNAP_ARCH: 'amd64', + SNAP_DESKTOP_ARCH_TRIPLET: 'x86_64-linux-gnu', + SNAP_DESKTOP_RUNTIME: path.join(snapRoot, 'gnome-platform'), + ...GRAPHICS_SELECTOR_ENVIRONMENT, + GBM_BACKENDS_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'gbm' + ), + '/var/lib/snapd/lib/gl/gbm', + ].join(':'), + LIBGL_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + LIBVA_DRIVERS_PATH: path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'egl', + 'egl_external_platform.d' + ), + __EGL_VENDOR_LIBRARY_DIRS: [ + '/var/lib/snapd/lib/glvnd/egl_vendor.d', + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'glvnd', + 'egl_vendor.d' + ), + ].join(':'), + VK_LAYER_PATH: [ + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'implicit_layer.d' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'share', + 'vulkan', + 'explicit_layer.d' + ), + ].join(':'), + XDG_CONFIG_HOME: path.join(snapRoot, 'etc', 'xdg'), + XDG_CONFIG_DIRS: [ + path.join(snapRoot, 'etc', 'xdg'), + '/etc/xdg', + ].join(':'), + XDG_DATA_HOME: path.join(snapRoot, 'usr', 'share'), + XDG_DATA_DIRS: [ + path.join(snapRoot, 'graphics', 'usr', 'share'), + path.join(snapRoot, 'gnome-platform', 'usr', 'share'), + path.join(snapRoot, 'usr', 'share'), + '/usr/share', + ].join(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'graphics', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'vdpau' + ), + '/usr/lib/x86_64-linux-gnu', + path.join( + snapRoot, + 'gnome-platform', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'mesa-egl' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'dri' + ), + path.join( + snapRoot, + 'gnome-platform', + 'usr', + 'lib', + 'x86_64-linux-gnu', + 'pulseaudio' + ), + path.join(snapRoot, 'lib'), + path.join(snapRoot, 'usr', 'lib'), + path.join(snapRoot, 'lib', 'x86_64-linux-gnu'), + path.join(snapRoot, 'usr', 'lib', 'x86_64-linux-gnu'), + ].join(':'), + }, + }); + }); + + it('refuses a Linux session without a validated runtime mode', () => { + adapter = createAdapter('/native/helper', { runtimeMode: null }); + + expect(() => createSession()).toThrow( + 'validated Linux frame-copy runtime' + ); + expect(spawnMock).not.toHaveBeenCalled(); + }); + }); + it('caches helper snapshot events for getSessionSnapshot', () => { const sessionId = createSession(); child.emitStdout({ @@ -149,7 +482,12 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { 'size\twidth=1600\theight=900\n' ); const writesBefore = child.stdin.written.length; - adapter.setBounds(sessionId, { x: -10000, y: -10000, width: 1, height: 1 }); + adapter.setBounds(sessionId, { + x: -10000, + y: -10000, + width: 1, + height: 1, + }); expect(child.stdin.written.length).toBe(writesBefore); }); @@ -192,7 +530,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { ['darwin', 'arm64', true], ['darwin', 'x64', false], ['linux', 'x64', true], - ['linux', 'arm64', true], + ['linux', 'arm64', false], ['win32', 'x64', true], ['freebsd', 'x64', false], ])( diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts index 5edc3c2fa..7c14720b0 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts @@ -7,6 +7,11 @@ import { ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; import { isFrameCopyPlatformSupported } from './embedded-mpv-frame-copy-platform.util'; +import { createLinuxFrameCopyHelperLaunch } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyRuntimeMode, + LinuxFrameCopyHelperLaunchFileSystem, +} from './embedded-mpv-frame-copy-runtime'; import type { NativeEmbeddedMpvAddon, NativeEmbeddedMpvSessionSnapshot, @@ -28,6 +33,9 @@ import type { export interface EmbeddedMpvFrameCopyAdapterOptions { resolveHelperPath: () => string | null; + resolveRuntimeMode: () => EmbeddedMpvFrameCopyRuntimeMode | null; + environment?: NodeJS.ProcessEnv; + helperLaunchFileSystem?: LinuxFrameCopyHelperLaunchFileSystem; getScaleFactor: () => number; onFrameSourceChanged: ( sessionId: string, @@ -76,14 +84,18 @@ function createInitialSnapshot(): NativeEmbeddedMpvSessionSnapshot { export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { private readonly sessions = new Map(); - constructor( - private readonly options: EmbeddedMpvFrameCopyAdapterOptions - ) {} + constructor(private readonly options: EmbeddedMpvFrameCopyAdapterOptions) {} isSupported(): boolean { + if ( + !isFrameCopyPlatformSupported() || + this.options.resolveHelperPath() === null + ) { + return false; + } return ( - isFrameCopyPlatformSupported() && - this.options.resolveHelperPath() !== null + process.platform !== 'linux' || + this.options.resolveRuntimeMode() !== null ); } @@ -104,30 +116,56 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { const scale = this.options.getScaleFactor(); const width = Math.max(16, Math.round(bounds.width * scale)); const height = Math.max(16, Math.round(bounds.height * scale)); + const helperArgs = [ + '--shm-base', + `/${sessionId}`, + '--width', + String(width), + '--height', + String(height), + '--volume', + String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), + // Lip-sync compensation for the video path's added latency + // (~10 ms measured on M1 Pro); tunable until calibration + // lands, see the architecture doc. + ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY + ? [ + '--audio-delay', + process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, + ] + : []), + ]; + let helperCommand = helperPath; + let resolvedHelperArgs = helperArgs; + let helperEnvironment: NodeJS.ProcessEnv | undefined; + if (process.platform === 'linux') { + const runtimeMode = this.options.resolveRuntimeMode(); + if (!runtimeMode) { + throw new Error( + 'A validated Linux frame-copy runtime is not available.' + ); + } + const launch = createLinuxFrameCopyHelperLaunch({ + environment: this.options.environment ?? process.env, + helperPath, + helperArgs, + runtimeMode, + fileSystem: this.options.helperLaunchFileSystem, + }); + if (!launch.usable) { + throw new Error( + 'The connected Snap graphics provider is not available.' + ); + } + helperCommand = launch.command; + resolvedHelperArgs = launch.args; + helperEnvironment = launch.env; + } - const child = spawn( - helperPath, - [ - '--shm-base', - `/${sessionId}`, - '--width', - String(width), - '--height', - String(height), - '--volume', - String(Math.min(Math.max(initialVolume ?? 1, 0), 1)), - // Lip-sync compensation for the video path's added latency - // (~10 ms measured on M1 Pro); tunable until calibration - // lands, see the architecture doc. - ...(process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY - ? [ - '--audio-delay', - process.env.IPTVNATOR_EMBEDDED_MPV_AUDIO_DELAY, - ] - : []), - ], - { stdio: ['pipe', 'pipe', 'pipe'] } - ); + const child = spawn(helperCommand, resolvedHelperArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + ...(helperEnvironment ? { env: helperEnvironment } : {}), + }); console.log( `[embedded-mpv-fc][${sessionId}] spawn ${width}x${height} (pid pending)` @@ -177,7 +215,9 @@ export class EmbeddedMpvFrameCopyAdapter implements NativeEmbeddedMpvAddon { } loadPlayback(sessionId: string, playback: ResolvedPortalPlayback): void { - const fields: string[] = [`url=${encodeProtocolValue(playback.streamUrl)}`]; + const fields: string[] = [ + `url=${encodeProtocolValue(playback.streamUrl)}`, + ]; if (playback.title) { fields.push( `opt.force-media-title=${encodeProtocolValue(playback.title)}` diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts new file mode 100644 index 000000000..5ce94928d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts @@ -0,0 +1,539 @@ +import { + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from 'fs'; +import { createHash } from 'crypto'; +import { createRequire } from 'module'; +import { tmpdir } from 'os'; +import path from 'path'; + +const linkageModulePath = path.resolve( + __dirname, + '../../../embedded-mpv-linux-linkage.cjs' +); +const requireBuildHelper = createRequire(__filename); + +interface RuntimeFileRecord { + name: string; + size: number; + sha256: string; +} + +interface SonameFixture { + exactPath: string; + outputLibDir: string; + runtimeDependencyClosure: { + entries: Array<{ + name: string; + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string | null; + }>; + }; + runtimeFiles: RuntimeFileRecord[]; +} + +function loadLinkageModule(): { + parseReadelfDynamic: (output: string) => { + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string[]; + }; + resolveVerifiedLinuxLibMpvSoname: (options: { + outputLibDir: string; + readDynamicSection: (filePath: string) => string; + runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; + runtimeFiles: RuntimeFileRecord[]; + }) => string; + resolveLinuxFrameCopyLinkageInputs: (options: { + buildInputMode: string; + outputLibDir: string; + packagedLibmpvSoname: string | null; + readDynamicSection: (filePath: string) => string; + runtimeLibDir: string; + }) => { + expectedLibmpvSoname: string | null; + linkerLibraryDir: string; + }; + runWithCleanup: (operation: () => T, cleanup: () => void) => T; + validateLinuxFrameCopyLinkage: (options: { + expectedLibmpvSoname: string; + outputDir: string; + readDynamicSection: (filePath: string) => string; + }) => void; +} { + expect(existsSync(linkageModulePath)).toBe(true); + return requireBuildHelper(linkageModulePath); +} + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord { + return { + name, + size: contents.byteLength, + sha256: sha256(contents), + }; +} + +function readelfDynamic( + entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> +): string { + return entries + .map( + ([tag, value], index) => + ` 0x${index + .toString(16) + .padStart(16, '0')} (${tag}) Library value: [${value}]` + ) + .join('\n'); +} + +describe('Linux Embedded MPV linkage verification', () => { + const temporaryDirectories: string[] = []; + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + function temporaryDirectory(): string { + const directory = mkdtempSync( + path.join(tmpdir(), 'iptvnator-mpv-linkage-') + ); + temporaryDirectories.push(directory); + return directory; + } + + function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { + const outputLibDir = path.join(temporaryDirectory(), 'lib'); + mkdirSync(outputLibDir, { recursive: true }); + const contents = Buffer.from('verified libmpv ELF contents'); + const aliasPath = path.join(outputLibDir, 'libmpv.so'); + const exactPath = path.join(outputLibDir, soname); + writeFileSync(aliasPath, contents); + writeFileSync(exactPath, contents); + + return { + exactPath, + outputLibDir, + runtimeFiles: [ + runtimeFile('libmpv.so', contents), + runtimeFile(soname, contents), + ], + runtimeDependencyClosure: { + entries: [ + { + name: 'libmpv.so', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + { + name: soname, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + ], + }, + }; + } + + it('parses every dynamic tag without hiding duplicate SONAME entries', () => { + const { parseReadelfDynamic } = loadLinkageModule(); + + expect( + parseReadelfDynamic( + readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/forbidden'], + ['RUNPATH', '$ORIGIN/lib'], + ['SONAME', 'libmpv.so.2'], + ['SONAME', 'libmpv.so.3'], + ]) + ) + ).toEqual({ + needed: ['libmpv.so.2'], + rpath: ['/forbidden'], + runpath: ['$ORIGIN/lib'], + soname: ['libmpv.so.2', 'libmpv.so.3'], + }); + }); + + it('resolves the exact libmpv SONAME from closure metadata and verified copied files', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + + expect( + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toBe('libmpv.so.2'); + }); + + it('rejects missing and ambiguous closure SONAME metadata', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const missingFixture = createSonameFixture(); + for (const entry of missingFixture.runtimeDependencyClosure.entries) { + entry.soname = null; + } + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...missingFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exactly one versioned libmpv SONAME/i); + + const ambiguousFixture = createSonameFixture(); + ambiguousFixture.runtimeDependencyClosure.entries.push({ + name: 'libmpv.so.3', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libmpv.so.3', + }); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...ambiguousFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exactly one versioned libmpv SONAME/i); + }); + + it('rejects missing, ambiguous, and mismatched DT_SONAME values', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => readelfDynamic([]), + }) + ).toThrow(/exactly one DT_SONAME/i); + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([ + ['SONAME', 'libmpv.so.2'], + ['SONAME', 'libmpv.so.3'], + ]), + }) + ).toThrow(/exactly one DT_SONAME/i); + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.3']]), + }) + ).toThrow(/does not match validated closure SONAME/i); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'rejects a symlinked copied linker input', + () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + const aliasPath = path.join(fixture.outputLibDir, 'libmpv.so'); + unlinkSync(aliasPath); + symlinkSync(path.basename(fixture.exactPath), aliasPath); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/must be a regular non-symbolic-link file/i); + } + ); + + it('rejects a missing exact runtime record and a mismatched exact file hash', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const missingRecordFixture = createSonameFixture(); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...missingRecordFixture, + runtimeFiles: missingRecordFixture.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so.2' + ), + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exact runtimeFiles record/i); + + const mismatchedFileFixture = createSonameFixture(); + writeFileSync( + mismatchedFileFixture.exactPath, + Buffer.from('tampered exact SONAME file') + ); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...mismatchedFileFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/size|SHA-256/i); + }); + + it('uses and identifies the unmanaged system libmpv only for system development', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const readDynamicSection = jest.fn((filePath: string) => { + expect(filePath).toBe('/opt/libmpv/lib/libmpv.so'); + return readelfDynamic([['SONAME', 'libmpv.so.2']]); + }); + + expect( + resolveLinuxFrameCopyLinkageInputs({ + buildInputMode: 'system-dev', + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: null, + readDynamicSection, + runtimeLibDir: '/opt/libmpv/lib', + }) + ).toEqual({ + expectedLibmpvSoname: 'libmpv.so.2', + linkerLibraryDir: '/opt/libmpv/lib', + }); + expect(readDynamicSection).toHaveBeenCalledTimes(1); + }); + + it('keeps bundled and untrusted build modes on the copied runtime directory', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const readDynamicSection = jest.fn(() => { + throw new Error('must not inspect the ambient system runtime'); + }); + + for (const buildInputMode of [ + 'bundled-runtime', + 'system-build-inputs', + 'unexpected-mode', + ]) { + expect( + resolveLinuxFrameCopyLinkageInputs({ + buildInputMode, + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: + buildInputMode === 'bundled-runtime' + ? 'libmpv.so.2' + : null, + readDynamicSection, + runtimeLibDir: '/usr/lib/x86_64-linux-gnu', + }) + ).toEqual({ + expectedLibmpvSoname: + buildInputMode === 'bundled-runtime' ? 'libmpv.so.2' : null, + linkerLibraryDir: '/native/build/Release/lib', + }); + } + expect(readDynamicSection).not.toHaveBeenCalled(); + }); + + it('rejects ambiguous or unversioned system-development libmpv identities', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const options = { + buildInputMode: 'system-dev', + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: null, + runtimeLibDir: '/usr/lib/x86_64-linux-gnu', + }; + + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so']]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => readelfDynamic([]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => + readelfDynamic([ + ['SONAME', 'libmpv.so.1'], + ['SONAME', 'libmpv.so.2'], + ]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + }); + + function createArtifactFixture(): { + outputDir: string; + readDynamicSection: (filePath: string) => string; + outputs: Record; + } { + const outputDir = temporaryDirectory(); + const outputs: Record = { + 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), + 'embedded_mpv_frame_reader.node': readelfDynamic([]), + iptvnator_mpv_helper: readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['NEEDED', 'libEGL.so.1'], + ['RUNPATH', '$ORIGIN/lib'], + ]), + }; + for (const artifact of Object.keys(outputs)) { + writeFileSync(path.join(outputDir, artifact), 'ELF'); + } + return { + outputDir, + outputs, + readDynamicSection: (filePath: string) => + outputs[path.basename(filePath)], + }; + } + + it('accepts only process-isolated Linux frame-copy linkage', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).not.toThrow(); + }); + + it('rejects a helper linked to the wrong libmpv SONAME', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.3'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); + }); + + it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const rpathFixture = createArtifactFixture(); + rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/host/lib'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: rpathFixture.outputDir, + readDynamicSection: rpathFixture.readDynamicSection, + }) + ).toThrow(/helper must not contain RPATH/i); + + const runpathFixture = createArtifactFixture(); + runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RUNPATH', '$ORIGIN'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: runpathFixture.outputDir, + readDynamicSection: runpathFixture.readDynamicSection, + }) + ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); + }); + + it.each([ + ['embedded_mpv.node', 'addon'], + ['embedded_mpv_frame_reader.node', 'frame reader'], + ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); + }); + + it('rejects missing artifacts and readelf failures', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const missingArtifactFixture = createArtifactFixture(); + unlinkSync( + path.join( + missingArtifactFixture.outputDir, + 'embedded_mpv_frame_reader.node' + ) + ); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: missingArtifactFixture.outputDir, + readDynamicSection: missingArtifactFixture.readDynamicSection, + }) + ).toThrow(/missing.*frame reader/i); + + const readelfFailureFixture = createArtifactFixture(); + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: readelfFailureFixture.outputDir, + readDynamicSection: () => { + throw new Error('readelf is unavailable'); + }, + }) + ).toThrow(/readelf is unavailable/); + }); + + it('runs cleanup before rethrowing the original transaction failure', () => { + const { runWithCleanup } = loadLinkageModule(); + const calls: string[] = []; + const failure = new Error('post-link validation failed'); + + expect(() => + runWithCleanup( + () => { + calls.push('operation'); + throw failure; + }, + () => calls.push('cleanup') + ) + ).toThrow(failure); + expect(calls).toEqual(['operation', 'cleanup']); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 09039e268..3588ed41d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -32,6 +32,16 @@ describe('Embedded MPV native source recording invariants', () => { ), 'utf8' ); + const electronBuilderConfig = JSON.parse( + readFileSync( + path.resolve(__dirname, '../../../../../electron-builder.json'), + 'utf8' + ) + ) as { + snap?: { + plugs?: unknown; + }; + }; const stageRuntimeSource = readFileSync( path.resolve( __dirname, @@ -43,6 +53,10 @@ describe('Embedded MPV native source recording invariants', () => { path.resolve(__dirname, '../../../native/helper/frame_helper_render.h'), 'utf8' ); + const frameHelperSource = readFileSync( + path.resolve(__dirname, '../../../native/helper/mpv_frame_helper.cpp'), + 'utf8' + ); const frameHelperGlSource = readFileSync( path.resolve(__dirname, '../../../native/helper/frame_helper_gl.h'), 'utf8' @@ -570,11 +584,120 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain('cleanNativeBuildIntermediates();'); }); - it('does not stage Linux libmpv runtime libraries', () => { - expect(stageRuntimeSource).toContain( - "if (platform !== 'linux') {\n" + - ' copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter);\n' + - ' }' + it('validates and copies only the staged Linux shared-library closure', () => { + expect(buildScriptSource).toContain( + "require('../../tools/embedded-mpv/linux-runtime-manifest.cjs')" + ); + expect(buildScriptSource).toContain( + 'validateLinuxRuntimeManifest(sourceRuntimeManifest)' + ); + expect(buildScriptSource).toContain( + 'validateLinuxSystemBuildInputManifest(sourceRuntimeManifest)' + ); + expect(buildScriptSource).toContain( + 'copyLinuxRuntimeClosureToNativeBuild(runtime)' + ); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeManifest.runtimeFiles' + ); + expect(buildScriptSource).toContain( + 'SHA-256 mismatch for staged Linux runtime file' + ); + expect(buildScriptSource).toContain( + 'resolveLinuxFrameCopyLinkageInputs({' + ); + expect(buildScriptSource).toContain( + 'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:\n' + + ' linuxLinkageInputs.linkerLibraryDir' + ); + expect(buildScriptSource).toContain( + 'expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname' + ); + expect(buildScriptSource).not.toContain( + 'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir' + ); + expect(buildScriptSource).not.toContain( + 'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir' + ); + }); + + it('writes a profile-neutral Linux frame-copy build manifest', () => { + expect(buildScriptSource).toContain( + "const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']);" + ); + expect(buildScriptSource).toContain("origin: 'linux-frame-copy-build'"); + expect(buildScriptSource).toContain( + 'allowedPackageRuntimeModes: [...LINUX_PACKAGE_RUNTIME_MODES]' + ); + expect(buildScriptSource).toContain( + 'buildInputMode: runtime.buildInputMode' + ); + expect(buildScriptSource).toContain( + 'sourceRuntime: runtime.sourceRuntimeManifest' + ); + expect(buildScriptSource).toContain('runtimeFiles: copiedRuntimeFiles'); + expect(buildScriptSource).not.toContain( + 'writeLinuxProcessRuntimeManifest' + ); + }); + + it('requires a validated bundled source runtime for required Linux builds', () => { + expect(buildScriptSource).toContain( + "sourceRuntimeValidated: buildInputMode === 'bundled-runtime'" + ); + expect(buildScriptSource).toContain( + 'assertRequiredLinuxFrameCopyRuntime(runtime);' + ); + expect(buildScriptSource).toContain( + "runtime.buildInputMode !== 'bundled-runtime' ||" + ); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeValidated !== true' + ); + expect(buildScriptSource).toContain("runtimeFile.name === 'libmpv.so'"); + expect(buildScriptSource).toContain( + 'Required Linux builds must use the validated bundled source runtime containing staged libmpv.' + ); + }); + + it('derives packaged Linux libmpv identity from validated closure SONAME metadata', () => { + expect(buildScriptSource).toContain('resolveVerifiedLinuxLibMpvSoname'); + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeManifest.runtimeDependencyClosure' + ); + expect(buildScriptSource).toContain('libmpvSoname,'); + expect(buildScriptSource).not.toContain( + 'VERSIONED_LINUX_LIBMPV_PATTERN' + ); + expect(buildScriptSource).not.toContain("libmpvSoname: 'libmpv.so.2'"); + }); + + it('marks both package modes available only for a validated bundled build', () => { + expect(buildScriptSource).toContain( + 'runtime.sourceRuntimeValidated === true &&\n' + + " runtime.buildInputMode === 'bundled-runtime' &&\n" + + ' copiedRuntimeFiles.length > 0 &&\n' + + ' libmpvSoname !== null' + ); + expect(buildScriptSource).toContain('system: packageRuntimeAvailable'); + expect(buildScriptSource).toContain('bundled: packageRuntimeAvailable'); + }); + + it('validates Linux post-link isolation before marking the build available', () => { + const postLinkValidation = buildScriptSource.indexOf( + 'validateLinuxFrameCopyLinkage({' + ); + const availabilityMarkerRemoval = buildScriptSource.lastIndexOf( + 'fs.rmSync(unavailableMarkerFile' + ); + + expect(buildScriptSource).toContain( + "spawnSync('readelf', ['-d', filePath]" + ); + expect(postLinkValidation).toBeGreaterThanOrEqual(0); + expect(availabilityMarkerRemoval).toBeGreaterThan(postLinkValidation); + expect(buildScriptSource).toContain( + 'runWithCleanup(buildNativeArtifacts, cleanOutput)' ); }); @@ -597,17 +720,33 @@ describe('Embedded MPV native source recording invariants', () => { ); }); - it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => { + it('requires the pinned Linux source runtime artifact and validates process isolation in CI', () => { expect(buildAndMakeWorkflowSource).toContain( - 'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev' + 'Build and stage pinned LGPL Linux runtime' ); expect(buildAndMakeWorkflowSource).toContain( + 'node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'name: linux-embedded-mpv-runtime' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'path: vendor/embedded-mpv/linux-x64' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'Download pinned Linux Embedded MPV runtime' + ); + expect(buildAndMakeWorkflowSource).not.toContain('libopengl-dev'); + expect(buildAndMakeWorkflowSource).not.toContain( 'Stage Linux embedded MPV build inputs' ); - expect(buildAndMakeWorkflowSource).toContain( - "linuxBackend: 'process-isolated mpv --wid'" - ); expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'"); + expect(buildAndMakeWorkflowSource).toContain( + "manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true" + ); expect(buildAndMakeWorkflowSource).toContain( 'Linux embedded MPV addon must not link directly to libmpv' ); @@ -615,14 +754,11 @@ describe('Embedded MPV native source recording invariants', () => { 'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper' ); expect(buildAndMakeWorkflowSource).toContain( - 'Linux frame-copy helper must link libmpv' + 'Linux frame-copy helper must need libmpv.so.2' ); - expect(buildScriptSource).toContain("origin: 'external-mpv-process'"); - expect(buildScriptSource).toContain('writeLinuxProcessRuntimeManifest'); - expect(buildScriptSource).toContain('runtimeFiles: []'); }); - it('supports Linux system-development inputs without leaving stale frame-copy artifacts', () => { + it('keeps optional Linux system development separate from required staged inputs', () => { expect(buildScriptSource).toContain( "const systemIncludeDir =\n process.env.LIBMPV_INCLUDE_DIR || '/usr/include';" ); @@ -635,6 +771,9 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain( "if (!embeddedMpvRequired && runtime.origin === 'system-dev')" ); + expect(buildScriptSource).toContain( + 'Required Linux builds must use the validated bundled source runtime containing staged libmpv.' + ); expect(buildScriptSource).toContain( 'removeStaleFrameCopyArtifacts(outputDir);' ); @@ -654,6 +793,101 @@ describe('Embedded MPV native source recording invariants', () => { ); }); + it('keeps Electron Builder defaults and exact Snap runtime plugs', () => { + expect(electronBuilderConfig.snap?.plugs).toEqual([ + 'default', + { + 'graphics-core22': { + interface: 'content', + target: '$SNAP/graphics', + 'default-provider': 'mesa-core22', + }, + }, + { + 'shared-memory': { + interface: 'shared-memory', + private: true, + }, + }, + ]); + }); + + it('runs the helper runtime probe through shared memory without media or command loops', () => { + const runtimeProbe = sourceFunctionBody( + frameHelperSource, + 'int runRuntimeProbe(', + 'runRuntimeProbe' + ); + const runtimeProbeShmName = sourceFunctionBody( + frameHelperSource, + 'std::string runtimeProbeShmName(', + 'runtimeProbeShmName' + ); + const main = sourceFunctionBody(frameHelperSource, 'int main(', 'main'); + const runtimeProbeFailure = sourceFunctionBody( + frameHelperSource, + 'int runtimeProbeFailure(', + 'runtimeProbeFailure' + ); + + expect(main).toContain('if (args.runtimeProbe) {'); + expect(main).toContain('return runRuntimeProbe();'); + expect(runtimeProbe).toContain('mpv_create()'); + expect(runtimeProbe).toContain('"idle", "yes"'); + expect(runtimeProbe).toContain('"vo", "libmpv"'); + expect(runtimeProbe).toContain('mpv_initialize(mpv)'); + expect(runtimeProbe).toContain('GlContext gl;'); + expect(runtimeProbe).toContain('gl.create(error)'); + expect(runtimeProbe).toContain('gl.makeCurrent(error)'); + expect(runtimeProbe).toContain('mpv_render_context_create('); + expect(runtimeProbe).toContain('mpv_render_context_free('); + expect(runtimeProbe).toContain('gl.destroy()'); + expect(runtimeProbe).toContain('mpv_terminate_destroy(mpv)'); + expect(runtimeProbe).toContain( + 'frame_helper::ShmRing runtimeProbeRing;' + ); + expect(runtimeProbe).toContain( + 'const std::string shmName = runtimeProbeShmName();' + ); + expect(runtimeProbe).toContain( + 'runtimeProbeRing.create(shmName, 16, 16, 1)' + ); + expect(runtimeProbe).toContain( + 'runtimeProbeRing.header->magic == FRAME_SHM_MAGIC' + ); + expect(runtimeProbe).toContain('runtimeProbeRing.destroy();'); + expect(runtimeProbe).toContain('"shared-memory-create-failed"'); + expect(runtimeProbe).toContain('"shared-memory-initialize-failed"'); + expect(runtimeProbeShmName).toContain('"/impv-fc-runtime-probe-"'); + expect(runtimeProbeShmName).toContain('getpid()'); + expect(runtimeProbeShmName).toContain('GetCurrentProcessId()'); + expect(runtimeProbeShmName).toContain('std::to_string(processId)'); + expect(runtimeProbe).toContain('.num("protocol", 1)'); + expect(runtimeProbe).toContain('.boolean("usable", true)'); + expect(runtimeProbe).toContain('.str("libmpv",'); + expect(runtimeProbe).toContain('.str("renderApi", gl.renderApiName())'); + expect(runtimeProbe).not.toContain('pipeline'); + expect(runtimeProbe).not.toContain('runStdinLoop'); + expect(runtimeProbe).not.toContain('runMpvEventLoop'); + expect(runtimeProbe).not.toContain('loadfile'); + expect(runtimeProbe.match(/emitLine\(/g)).toHaveLength(1); + expect(runtimeProbeFailure).toContain('.num("protocol", 1)'); + expect(runtimeProbeFailure).toContain('.boolean("usable", false)'); + expect(runtimeProbeFailure).toContain('.str("reason", reason)'); + expect(runtimeProbeFailure.match(/emitLine\(/g)).toHaveLength(1); + expect(runtimeProbeFailure).toContain('return 1;'); + }); + + it('identifies the Linux helper runtime probe render API as EGL', () => { + const renderApiName = sourceFunctionBody( + linuxFrameHelperGlSource, + 'const char* renderApiName() const', + 'GlContext::renderApiName' + ); + + expect(renderApiName).toContain('return "egl";'); + }); + it('validates complete EGL candidates and keeps software rendering as the final fallback', () => { const tryCandidate = sourceFunctionBody( linuxFrameHelperGlSource, @@ -817,14 +1051,33 @@ describe('Embedded MPV native build configuration', () => { )?.[1]; expect(linuxAddonConfig?.libraries).not.toContain('-lmpv'); + expect(JSON.stringify(linuxAddonConfig)).not.toContain('-lmpv'); expect(linuxHelperConfig?.libraries).toEqual( - expect.arrayContaining([ - '-lmpv', - '-lEGL', - '-lOpenGL', - '-lgbm', - '-ldl', - ]) + expect.arrayContaining(['-lEGL', '-lGL', '-lgbm', '-ldl']) + ); + expect(linuxHelperConfig?.libraries).not.toContain('-lOpenGL'); + expect(linuxHelperConfig?.libraries).not.toContain('-lmpv'); + expect( + linuxHelperConfig?.libraries.find((library: string) => + library.includes("path.join(dir, 'libmpv.so')") + ) + ).toContain('LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); + expect(JSON.stringify(linuxHelperConfig)).not.toContain( + "LINUX_VERIFIED_RUNTIME_LIBRARY_DIR || '/usr/lib'" + ); + expect(JSON.stringify(linuxHelperConfig)).toContain( + 'Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR' + ); + + const runtimeLinkerFlags = linuxHelperConfig?.ldflags.filter( + (flag: string) => flag.startsWith('-Wl,') + ); + expect(runtimeLinkerFlags).toEqual([ + '-Wl,--enable-new-dtags', + "-Wl,-rpath,'$$ORIGIN/lib'", + ]); + expect(JSON.stringify(runtimeLinkerFlags)).not.toContain( + 'LINUX_NATIVE_LIBRARY_DIR' ); }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts index 3df3c7160..1f85402b2 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.spec.ts @@ -3,24 +3,30 @@ import type { EmbeddedMpvSessionStatus, ResolvedPortalPlayback, } from '@iptvnator/shared/interfaces'; -import { - chmodSync, - existsSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'fs'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import path from 'path'; import type { EmbeddedMpvNativeService as EmbeddedMpvNativeServiceType } from './embedded-mpv-native.service'; const mockSpawnSync = jest.fn(); +const mockIsFrameCopyRuntimeUsable = jest.fn(); +const mockGetFrameCopyRuntimeAvailability = jest.fn(); jest.mock('child_process', () => ({ spawnSync: mockSpawnSync, })); +jest.mock('./embedded-mpv-frame-copy-platform.util', () => { + const actual = jest.requireActual( + './embedded-mpv-frame-copy-platform.util' + ); + return { + ...actual, + getFrameCopyRuntimeAvailability: mockGetFrameCopyRuntimeAvailability, + isFrameCopyRuntimeUsable: mockIsFrameCopyRuntimeUsable, + }; +}); + const powerSaveBlockerMock = { start: jest.fn(), stop: jest.fn(), @@ -138,6 +144,13 @@ describe('EmbeddedMpvNativeService power blocker', () => { mockSpawnSync.mockReturnValue({ status: 0, }); + mockIsFrameCopyRuntimeUsable.mockReset(); + mockIsFrameCopyRuntimeUsable.mockReturnValue(false); + mockGetFrameCopyRuntimeAvailability.mockReset(); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: false, + reason: 'helper-probe-failed', + }); mainWindowGetNativeWindowHandleMock.mockReset(); mainWindowGetNativeWindowHandleMock.mockReturnValue(Buffer.alloc(8)); mainWindowSendMock.mockReset(); @@ -230,14 +243,9 @@ describe('EmbeddedMpvNativeService power blocker', () => { // no helper on disk => the engine env flag is ignored, native keeps // working, and support does not advertise frame-copy. process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - jest.spyOn( - service as unknown as { - resolveFrameCopyHelperPath: () => string | null; - }, - 'resolveFrameCopyHelperPath' - ).mockReturnValue(null); try { expect(service.getActiveEngine()).toBe('native'); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith(); const support = service.getSupport(); expect(support.engine).not.toBe('frame-copy'); startSession('s-fallback', snapshot('loading')); @@ -262,37 +270,23 @@ describe('EmbeddedMpvNativeService power blocker', () => { }); (process.platform === 'win32' ? it.skip : it)( - 'falls back to the native engine when the frame-copy helper is not executable', + 'falls back to the native engine when the frame-copy runtime probe fails', () => { - const tempDir = createTempDir(); - const releaseDir = path.join( - tempDir, - 'apps', - 'electron-backend', - 'native', - 'build', - 'Release' - ); - const helperPath = path.join( - releaseDir, - 'iptvnator_mpv_helper' - ); - mkdirSync(releaseDir, { recursive: true }); - writeFileSync(helperPath, '#!/bin/sh\n'); - chmodSync(helperPath, 0o644); - writeFileSync( - path.join(releaseDir, 'embedded_mpv_frame_reader.node'), - 'reader' - ); - const cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempDir); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; try { expect(service.getActiveEngine()).toBe('native'); expect(service.isFrameCopyAvailable()).toBe(false); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledWith(); + expect(service.getSupport()).toEqual( + expect.objectContaining({ + engine: 'native', + frameCopyAvailable: false, + frameCopyUnavailableReason: 'helper-probe-failed', + }) + ); } finally { delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; - cwdSpy.mockRestore(); } } ); @@ -300,13 +294,11 @@ describe('EmbeddedMpvNativeService power blocker', () => { describe('frame-copy platform gate', () => { const originalArch = process.arch; - function mockHelperPresent(): void { - jest.spyOn( - service as unknown as { - resolveFrameCopyHelperPath: () => string | null; - }, - 'resolveFrameCopyHelperPath' - ).mockReturnValue('/native/iptvnator_mpv_helper'); + function mockRuntimeUsable(): void { + mockIsFrameCopyRuntimeUsable.mockReturnValue(true); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: true, + }); } afterEach(() => { @@ -322,7 +314,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { process.env.DISPLAY = ':0'; process.env.WAYLAND_DISPLAY = 'wayland-0'; process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockRuntimeUsable(); expect(service.getActiveEngine()).toBe('frame-copy'); expect(service.isFrameCopyAvailable()).toBe(true); @@ -342,7 +334,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env.DISPLAY = ':0'; process.env.WAYLAND_DISPLAY = 'wayland-0'; - mockHelperPresent(); + mockRuntimeUsable(); const support = service.getSupport(); expect(support.supported).toBe(false); @@ -355,7 +347,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { process.env.DISPLAY = ':0'; delete process.env.WAYLAND_DISPLAY; mockSpawnSync.mockReturnValue({ status: 1 }); - mockHelperPresent(); + mockRuntimeUsable(); const support = service.getSupport(); expect(support.supported).toBe(false); @@ -370,7 +362,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { delete process.env.WAYLAND_DISPLAY; process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; mockSpawnSync.mockReturnValue({ status: 1 }); - mockHelperPresent(); + mockRuntimeUsable(); expect(service.getSupport()).toEqual( expect.objectContaining({ @@ -383,16 +375,35 @@ describe('EmbeddedMpvNativeService power blocker', () => { it('activates the frame-copy engine on macOS arm64', () => { Object.defineProperty(process, 'arch', { value: 'arm64' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockRuntimeUsable(); expect(service.getActiveEngine()).toBe('frame-copy'); expect(service.isFrameCopyAvailable()).toBe(true); }); + it('supplies the adapter with the runtime mode from the validated Linux capability', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + mockGetFrameCopyRuntimeAvailability.mockReturnValue({ + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + libmpv: '2.3', + renderApi: 'egl', + }); + + expect( + ( + service as unknown as { + resolveFrameCopyRuntimeMode(): string | null; + } + ).resolveFrameCopyRuntimeMode() + ).toBe('bundled'); + }); + it('keeps the frame-copy engine Apple-Silicon-only on macOS', () => { Object.defineProperty(process, 'arch', { value: 'x64' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockIsFrameCopyRuntimeUsable.mockReturnValue(false); expect(service.getActiveEngine()).toBe('native'); expect(service.isFrameCopyAvailable()).toBe(false); @@ -401,7 +412,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { it('skips the native window handle when creating a frame-copy session', () => { Object.defineProperty(process, 'platform', { value: 'linux' }); process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; - mockHelperPresent(); + mockRuntimeUsable(); const frameCopyAddon = createMockAddon(); frameCopyAddon.createSession.mockReturnValueOnce('s-fc'); frameCopyAddon.getSessionSnapshot.mockReturnValueOnce( @@ -425,9 +436,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { // dispatches to the adapter that owns the session, not the // native addon the outer afterEach shutdown would pick. service.disposeSession('s-fc'); - expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith( - 's-fc' - ); + expect(frameCopyAddon.disposeSession).toHaveBeenCalledWith('s-fc'); }); }); @@ -455,9 +464,7 @@ describe('EmbeddedMpvNativeService power blocker', () => { expect.arrayContaining(['render-process-gone', 'did-navigate']) ); - const consoleWarnSpy = jest - .spyOn(console, 'warn') - .mockImplementation(); + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); handlers.get('did-navigate')?.(); expect(addon.disposeSession).toHaveBeenCalledWith('s1'); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 4f967a36a..d30ad596f 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -29,10 +29,12 @@ import { } from '@iptvnator/shared/interfaces'; import { EmbeddedMpvFrameCopyAdapter } from './embedded-mpv-frame-copy.adapter'; import { + getFrameCopyRuntimeAvailability, getEmbeddedMpvAddonCandidatePaths, isFrameCopyRuntimeUsable, resolveFrameCopyHelperPath, } from './embedded-mpv-frame-copy-platform.util'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './embedded-mpv-frame-copy-runtime'; import { EMBEDDED_MPV_EXPERIMENT_ENV, isEmbeddedMpvFeatureEnabled, @@ -112,8 +114,9 @@ export class EmbeddedMpvNativeService { /** * Frame-copy engine: helper process + shm ring + renderer canvas. * Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux - * and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 - * on top of the regular embedded MPV experiment flag. + * x64 and Windows, opted into with + * IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular + * embedded MPV experiment flag. */ private isFrameCopyEngineRequested(): boolean { return ['1', 'true', 'yes', 'on'].includes( @@ -127,10 +130,7 @@ export class EmbeddedMpvNativeService { // Requires the helper binary too: a stale opt-in (cleaned native // build, bad install) must fall back to the native engine instead // of leaving embedded MPV unsupported with no way to recover. - return ( - this.isFrameCopyEngineRequested() && - isFrameCopyRuntimeUsable(() => this.resolveFrameCopyHelperPath()) - ); + return this.isFrameCopyEngineRequested() && isFrameCopyRuntimeUsable(); } getActiveEngine(): EmbeddedMpvEngine { @@ -138,9 +138,31 @@ export class EmbeddedMpvNativeService { } isFrameCopyAvailable(): boolean { - return isFrameCopyRuntimeUsable(() => - this.resolveFrameCopyHelperPath() - ); + return isFrameCopyRuntimeUsable(); + } + + private getFrameCopySupportDetails(): Pick< + EmbeddedMpvSupport, + 'frameCopyAvailable' | 'frameCopyUnavailableReason' + > { + const availability = getFrameCopyRuntimeAvailability(); + if (!('reason' in availability)) { + return { frameCopyAvailable: true }; + } + return { + frameCopyAvailable: false, + frameCopyUnavailableReason: availability.reason, + }; + } + + private resolveFrameCopyRuntimeMode(): EmbeddedMpvFrameCopyRuntimeMode | null { + if (process.platform !== 'linux') { + return null; + } + const availability = getFrameCopyRuntimeAvailability(); + return availability.usable && 'runtimeMode' in availability + ? availability.runtimeMode + : null; } getFrameSource(sessionId: string): EmbeddedMpvFrameSource | null { @@ -150,7 +172,8 @@ export class EmbeddedMpvNativeService { private getFrameCopyAdapter(): EmbeddedMpvFrameCopyAdapter { if (!this.frameCopyAdapter) { this.frameCopyAdapter = new EmbeddedMpvFrameCopyAdapter({ - resolveHelperPath: () => this.resolveFrameCopyHelperPath(), + resolveHelperPath: resolveFrameCopyHelperPath, + resolveRuntimeMode: () => this.resolveFrameCopyRuntimeMode(), getScaleFactor: () => this.getMainWindowScaleFactor(), onFrameSourceChanged: (sessionId, source) => { if (!App.mainWindow || App.mainWindow.isDestroyed()) { @@ -166,12 +189,6 @@ export class EmbeddedMpvNativeService { return this.frameCopyAdapter; } - private resolveFrameCopyHelperPath(): string | null { - // Shared with the startup sandbox gate; kept as an instance method so - // service tests can stub helper discovery per scenario. - return resolveFrameCopyHelperPath(); - } - private getMainWindowScaleFactor(): number { try { if (!App.mainWindow || App.mainWindow.isDestroyed()) { @@ -229,7 +246,7 @@ export class EmbeddedMpvNativeService { supported: false, platform: process.platform, reason: 'Embedded MPV on Linux currently requires X11 or Xwayland. Native Wayland embedding is not supported yet.', - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), }; } @@ -249,7 +266,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: 'frame-copy', - frameCopyAvailable: true, + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } @@ -261,7 +278,7 @@ export class EmbeddedMpvNativeService { supported: false, platform: process.platform, reason: missingLinuxMpvExecutableReason, - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), }; } @@ -279,7 +296,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -345,7 +362,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -377,7 +394,7 @@ export class EmbeddedMpvNativeService { supported: true, platform: process.platform, engine: this.getActiveEngine(), - frameCopyAvailable: this.isFrameCopyAvailable(), + ...this.getFrameCopySupportDetails(), capabilities: this.detectCapabilities(), }; } catch (error) { @@ -709,8 +726,9 @@ export class EmbeddedMpvNativeService { }); }; - App.mainWindow.webContents.on('render-process-gone', (_event, details) => - disposeAll(`process gone (${details.reason})`) + App.mainWindow.webContents.on( + 'render-process-gone', + (_event, details) => disposeAll(`process gone (${details.reason})`) ); // Full navigations/reloads only — in-app Angular routing emits // did-navigate-in-page and must not kill the active session. diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts new file mode 100644 index 000000000..c90ca51d1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.spec.ts @@ -0,0 +1,112 @@ +const mockElectronApp = { + isPackaged: true, +}; + +jest.mock('electron', () => ({ app: mockElectronApp })); + +import { + EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, + runEmbeddedMpvRuntimeDiagnosticOrContinue, +} from './embedded-mpv-runtime-diagnostic'; +import type { FrameCopyRuntimeAvailability } from './embedded-mpv-frame-copy-platform.util'; + +interface DiagnosticHarness { + continueStartup: jest.Mock; + exit: jest.Mock; + getRuntimeAvailability: jest.Mock; + writeStdout: jest.Mock; +} + +function createHarness( + availability: FrameCopyRuntimeAvailability = { + usable: false, + reason: 'runtime-artifact-missing', + } +): DiagnosticHarness { + return { + continueStartup: jest.fn(), + exit: jest.fn(), + getRuntimeAvailability: jest.fn(() => availability), + writeStdout: jest.fn(), + }; +} + +function runDiagnostic( + argv: readonly string[], + harness: DiagnosticHarness +): void { + runEmbeddedMpvRuntimeDiagnosticOrContinue(argv, harness.continueStartup, { + exit: harness.exit, + getRuntimeAvailability: harness.getRuntimeAvailability, + writeStdout: harness.writeStdout, + }); +} + +describe('embedded MPV runtime diagnostic', () => { + it.each([ + [['electron', 'main.js']], + [['electron', 'main.js', `${EMBEDDED_MPV_RUNTIME_PROBE_SWITCH}=1`]], + [['electron', 'main.js', 'embedded-mpv-runtime-probe']], + ])('continues normal startup for argv %j', (argv) => { + const harness = createHarness(); + + runDiagnostic(argv, harness); + + expect(harness.continueStartup).toHaveBeenCalledTimes(1); + expect(harness.getRuntimeAvailability).not.toHaveBeenCalled(); + expect(harness.writeStdout).not.toHaveBeenCalled(); + expect(harness.exit).not.toHaveBeenCalled(); + }); + + it('prints the usable availability as one JSON line, exits zero, and skips startup', () => { + const availability: FrameCopyRuntimeAvailability = { + usable: true, + profile: 'portable', + runtimeMode: 'bundled', + libmpv: '2.3', + renderApi: 'egl', + }; + const harness = createHarness(availability); + + runDiagnostic( + ['electron', 'main.js', EMBEDDED_MPV_RUNTIME_PROBE_SWITCH], + harness + ); + + expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); + expect(harness.writeStdout).toHaveBeenCalledWith( + `${JSON.stringify(availability)}\n` + ); + expect(harness.writeStdout).toHaveBeenCalledTimes(1); + expect(harness.exit).toHaveBeenCalledWith(0); + expect(harness.exit).toHaveBeenCalledTimes(1); + expect(harness.writeStdout.mock.invocationCallOrder[0]).toBeLessThan( + harness.exit.mock.invocationCallOrder[0] + ); + expect(harness.continueStartup).not.toHaveBeenCalled(); + }); + + it('prints the unavailable helper reason as one JSON line, exits nonzero, and skips startup', () => { + const availability: FrameCopyRuntimeAvailability = { + usable: false, + reason: 'helper-probe-failed', + helperReason: 'gl-context-create-failed', + helperDetail: 'EGL initialization failed: display unavailable', + }; + const harness = createHarness(availability); + + runDiagnostic( + [EMBEDDED_MPV_RUNTIME_PROBE_SWITCH, 'electron', 'main.js'], + harness + ); + + expect(harness.getRuntimeAvailability).toHaveBeenCalledTimes(1); + expect(harness.writeStdout).toHaveBeenCalledWith( + '{"usable":false,"reason":"helper-probe-failed","helperReason":"gl-context-create-failed","helperDetail":"EGL initialization failed: display unavailable"}\n' + ); + expect(harness.writeStdout).toHaveBeenCalledTimes(1); + expect(harness.exit).toHaveBeenCalledWith(1); + expect(harness.exit).toHaveBeenCalledTimes(1); + expect(harness.continueStartup).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts new file mode 100644 index 000000000..4c6f23941 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-runtime-diagnostic.ts @@ -0,0 +1,36 @@ +import { writeSync } from 'fs'; +import { + getFrameCopyRuntimeAvailability, + type FrameCopyRuntimeAvailability, +} from './embedded-mpv-frame-copy-platform.util'; + +export const EMBEDDED_MPV_RUNTIME_PROBE_SWITCH = '--embedded-mpv-runtime-probe'; + +interface EmbeddedMpvRuntimeDiagnosticDependencies { + exit(code: number): void; + getRuntimeAvailability(): FrameCopyRuntimeAvailability; + writeStdout(output: string): void; +} + +const defaultDependencies: EmbeddedMpvRuntimeDiagnosticDependencies = { + exit: (code) => process.exit(code), + getRuntimeAvailability: getFrameCopyRuntimeAvailability, + writeStdout: (output) => { + writeSync(process.stdout.fd, output); + }, +}; + +export function runEmbeddedMpvRuntimeDiagnosticOrContinue( + argv: readonly string[], + continueStartup: () => void, + dependencies: EmbeddedMpvRuntimeDiagnosticDependencies = defaultDependencies +): void { + if (!argv.includes(EMBEDDED_MPV_RUNTIME_PROBE_SWITCH)) { + continueStartup(); + return; + } + + const availability = dependencies.getRuntimeAvailability(); + dependencies.writeStdout(`${JSON.stringify(availability)}\n`); + dependencies.exit(availability.usable ? 0 : 1); +} diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index a6bb9d68a..7bd61ae7c 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -9,10 +9,10 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS'; export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE'; export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE'; /** - * Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main - * process config file because it must be readable synchronously before the - * BrowserWindow is created — the engine relaxes the window sandbox for its - * preload frame pump, which cannot change after window creation. + * Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the + * main process config file because it must be readable synchronously before + * the BrowserWindow is created — the engine relaxes the window sandbox for + * its preload frame pump, which cannot change after window creation. */ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY'; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 0e99bd6ee..ea9b08923 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -36,10 +36,8 @@ import { shouldPromotePersistedFrameCopyOptIn, } from './app/services/embedded-mpv-frame-copy-platform.util'; import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; -import { - EMBEDDED_MPV_FRAME_COPY, - store, -} from './app/services/store.service'; +import { runEmbeddedMpvRuntimeDiagnosticOrContinue } from './app/services/embedded-mpv-runtime-diagnostic'; +import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -74,7 +72,7 @@ if ( shouldPromotePersistedFrameCopyOptIn( store.get(EMBEDDED_MPV_FRAME_COPY, false), process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY, - isFrameCopyRuntimeUsable() + isFrameCopyRuntimeUsable ) ) { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; @@ -192,23 +190,25 @@ export default class Main { } } -// handle setup events as quickly as possible -Main.initialize(); +runEmbeddedMpvRuntimeDiagnosticOrContinue(process.argv, () => { + // handle setup events as quickly as possible + Main.initialize(); -// bootstrap app -Main.bootstrapApp(); + // bootstrap app + Main.bootstrapApp(); -// Bootstrap app events after Electron app is ready -app.whenReady().then(async () => { - if (isStartupTraceEnabled()) { - trace('startup', 'app.whenReady'); - } - await Main.bootstrapAppEvents(); -}); - -app.on('before-quit', () => { - shutdownEmbeddedMpv(); - shutdownMpvSession(); - shutdownVlcSession(); - void databaseWorkerClient.shutdown(); + // Bootstrap app events after Electron app is ready + app.whenReady().then(async () => { + if (isStartupTraceEnabled()) { + trace('startup', 'app.whenReady'); + } + await Main.bootstrapAppEvents(); + }); + + app.on('before-quit', () => { + shutdownEmbeddedMpv(); + shutdownMpvSession(); + shutdownVlcSession(); + void databaseWorkerClient.shutdown(); + }); }); diff --git a/apps/electron-backend/tsconfig.spec.json b/apps/electron-backend/tsconfig.spec.json index 976a7011d..ae084b520 100644 --- a/apps/electron-backend/tsconfig.spec.json +++ b/apps/electron-backend/tsconfig.spec.json @@ -1,16 +1,17 @@ { - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/out-tsc", - "esModuleInterop": true, - "module": "commonjs", - "moduleResolution": "node10", - "types": ["jest", "node"] - }, - "include": [ - "jest.config.ts", - "src/**/*.test.ts", - "src/**/*.spec.ts", - "src/**/*.d.ts" - ] + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "esModuleInterop": true, + "allowJs": true, + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] } diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index f664f4945..b0833d40b 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -18,7 +18,7 @@ Source files for the embedded MPV integration: - `libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts` defines the shared session and audio-track contract. - `libs/ui/playback/src/lib/embedded-mpv-player/` owns the Angular UI and controls. -Frame-copy engine sources (experimental, macOS Apple Silicon, Linux and +Frame-copy engine sources (experimental, macOS Apple Silicon, Linux x64, and Windows — see the "Frame-Copy Engine" section below): - `apps/electron-backend/native/helper/` — `iptvnator_mpv_helper` process (`mpv_frame_helper.cpp`, `frame_helper_render.h`, `frame_helper_gl.h`, `frame_helper_io.h`, `frame_shm.h`). @@ -60,20 +60,76 @@ Linux native Wayland embedding is not implemented. When Electron is started on X ## Linux Support Matrix -Embedded MPV on Linux is supported only for x64 desktop builds where Electron runs under X11 or Xwayland and an `mpv` executable is available on `PATH`. Native Wayland embedding is not supported in this implementation. +Official Linux frame-copy packaging is x64-only. The native-view and frame-copy +engines have different runtime requirements: -The experimental frame-copy engine (below) is the exception to both requirements: it renders offscreen through headless EGL into a renderer canvas — no window embedding — and the helper links libmpv itself, so neither the X11/Xwayland constraint nor the system-`mpv`-on-`PATH` probe applies while it is active. It is currently a dev-build-only engine on Linux (the helper links the build host's system `libmpv` and is stripped from packaged apps until the bundled-runtime staging lands). Packaged Linux launchers pass `--ozone-platform=x11` so Wayland desktops use Xwayland when it is available, and `main.ts` appends the same switch on Linux when it is absent so direct binary/AppImage launches from a terminal behave like launcher starts. Explicit user intent is never overridden: both a user-provided `--ozone-platform` switch and the `ELECTRON_OZONE_PLATFORM_HINT` environment variable suppress the fallback. +| Engine | Display path | MPV runtime | +| ----------- | ----------------------------- | -------------------------------------------------------------------------- | +| native-view | X11 or Xwayland | An `mpv` executable on `PATH`; playback is an isolated `mpv --wid` process | +| frame-copy | Headless EGL; no window embed | A separately linked and capability-probed `iptvnator_mpv_helper` | -When the `mpv` executable probe fails inside a Flatpak or Snap sandbox (`FLATPAK_ID`/`SNAP` env present), the support reason explains that sandboxed packages cannot access a system mpv instead of asking the user to install it. +Native Wayland embedding is not implemented for native-view. Frame-copy itself +does not embed a window and can render through EGL on a native Wayland desktop, +although packaged launchers still default Electron to X11/Xwayland unless the +user explicitly supplies an Ozone choice. A user-provided `--ozone-platform` +or `ELECTRON_OZONE_PLATFORM_HINT` is never overridden. -Current release-announcement wording should stay close to this: +Linux packages are built in separate passes because Electron Builder reuses +one unpacked application layout per pass: -- Supported display path: X11 or Xwayland. -- Not supported: native Wayland embedding. -- Validated locally: Ubuntu 24.04 GNOME Wayland session with Electron forced to X11/Xwayland and system `mpv`. -- Validated in CI: Ubuntu 22.04 standard Linux package build and Ubuntu 24.04 Flatpak package build. -- Expected standard packages: `.deb` on Ubuntu/Debian, `pacman` on Arch/Manjaro, `.rpm` on RPM-based distributions, and AppImage on x64 glibc systems, all with system `mpv` installed. -- Sandbox caveat: Flatpak and Snap packages build and continue to support the normal inline/external-player flows, but embedded MPV is not announced as supported there yet because the Linux backend launches `mpv --wid` and those sandboxed formats do not expose the host `mpv` executable to the app by default. +| Profile | Formats | Frame-copy runtime strategy | +| ---------- | ---------------- | -------------------------------------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | System `libmpv.so.2` plus the helper's direct EGL/GL/GBM interfaces; exact dependencies are listed below | +| `portable` | AppImage, Snap | Bundled pinned LGPL-compatible runtime under `native/lib` | +| `flatpak` | Flatpak | The same bundled pinned LGPL-compatible runtime under `native/lib` | + +System package dependencies are fail-closed and format-specific: + +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` + +The DEB contract deliberately names `libmpv2`, not a loose `libmpv` +alternative, and explicitly names the GLVND `libGL.so.1` interface because +`libmpv2` does not pull it in for the helper. The helper uses `-lGL`, not +`-lOpenGL`; this matches the graphics interface supplied by distributions and +Snap's `mesa-core22`. Release CI verifies that contract on Ubuntu 24.04 +(Noble). Ubuntu 22.04 (Jammy) provides `libmpv1`, so its DEB cannot enable this +system-runtime frame-copy path; use the x64 AppImage there instead. + +Every x64 layout contains the addon, frame reader, helper, and a normalized +`embedded-mpv-runtime.json`. The Electron executable, Electron libraries, +`embedded_mpv.node`, and the frame reader must not link libmpv; only the helper +may do so. AppImage, Snap, and Flatpak retain dynamically linked, replaceable +runtime libraries and ship the corresponding source/build metadata. Their +native directory also contains hash-validated `embedded-mpv-notices.json`, +`THIRD_PARTY_NOTICES.txt`, and `licenses//**`. DEB, RPM, Pacman, and +marker-only packages intentionally contain neither a private `native/lib` +directory nor the bundled-runtime legal payload. + +The build-time `electron-backend/native` tree is excluded from `app.asar`. +`afterPack` is the only owner of +`resources/app.asar.unpacked/electron-backend/native`, so each profile receives +exactly its normalized payload and ARM packages cannot retain a hidden x64 +helper, runtime, manifest, or notice copy in the archive. Both unpacked-layout +and final Linux artifact verification enumerate `app.asar` and fail if any +entry remains below `/electron-backend/native/`. + +The pristine `afterPack` and unpacked-layout checks recursively inspect +Electron-owned shared libraries. An extracted Snap has already overlaid its +package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload +root, so the post-target verifier excludes exactly those two target-provided +trees while continuing to scan every other directory recursively. Electron +libraries are still required to be regular files and free of libmpv linkage; +Snap runtime symlinks are outside that ownership boundary. + +ARM Linux packages remain marker-only. They never borrow x64 native artifacts, +even when build environment variables claim a matching staged architecture. +Consequently frame-copy is not advertised there, and the normal inline/external +players remain available. On x64, any missing or unusable frame-copy dependency +falls back to native-view without crashing; if native-view also lacks X11 or a +system `mpv` executable, Embedded MPV is reported unavailable with a stable +diagnostic reason. The flow is: @@ -117,7 +173,16 @@ The renderer never gets direct native-module access. It can only call the preloa - dispose session - subscribe to session updates -Settings uses the preload support API as an availability and capability check. Unsupported paths return before loading the addon when platform, experiment gating, addon presence, bundled runtime presence, or the Linux `mpv` executable check fails. Supported paths load `embedded_mpv.node` so the renderer can receive capability flags from the actual addon binary. Avoid calling this support API from global workspace startup paths; use an explicit user action or idle preparation path when a renderer surface only needs to reveal optional Embedded MPV UI. +Settings uses the preload support API as an availability and capability check. +Unsupported paths return before loading the addon when platform, experiment +gating, native artifacts, the packaged runtime manifest, the Linux helper +probe, or the native-view `mpv` executable check fails. Support diagnostics +include a stable `frameCopyUnavailableReason`; it is tracing/support data, not +user-facing copy. Supported paths load `embedded_mpv.node` so the renderer can +receive capability flags from the actual addon binary. Avoid calling this +support API from global workspace startup paths; use an explicit user action +or idle preparation path when a renderer surface only needs to reveal optional +Embedded MPV UI. When `embedded-mpv` is the saved player, the settings store schedules an idle `prepareEmbeddedMpv()` call. This intentionally moves the first native addon load away from the click-to-play path. It can still block the Electron main process briefly because Node native addon loading is synchronous, but doing it during idle is less visible than doing it when the user clicks a video. Actual MPV session creation still happens on playback because it needs the current Electron window handle and viewport bounds. @@ -194,19 +259,126 @@ service and the adapter): Enabling it: the `Settings > Playback > Embedded MPV: frame-copy engine` checkbox (shown only when support reports `frameCopyAvailable`) persists to -the main-process config store (`electron-conf`), which `main.ts` reads -before creating the window and translates into the env flag; an explicitly -set env var (including `0`) wins over the stored preference, but cannot bypass -the platform/runtime safety gate. Frame-copy can relax the window sandbox only +the main-process config store (`electron-conf`), which `main.ts` reads before +creating the window and translates into the env flag; an explicitly set env +var (including `0`) wins over the stored preference, but cannot bypass the +platform/runtime safety gate. Frame-copy can relax the window sandbox only when embedded MPV itself is enabled for the current run (packaged app or the -regular development experiment flag) and discovery finds both an executable -(`X_OK`) helper and a readable regular frame-reader addon in the same native -directory. Packaged discovery is limited to packaged resource locations and -never falls through to writable cwd/dist development paths. A disabled base -experiment keeps the renderer sandbox enabled and embedded MPV unavailable. -When the base feature is enabled, a missing, mode-stripped, or incomplete -frame-copy runtime keeps the sandbox enabled and falls back to the native -engine. +regular development experiment flag) and one process-wide capability decision +has succeeded. On Linux x64 that decision validates the profile manifest, +regular-file/access modes, the complete declared bundled closure and hashes, +then runs `iptvnator_mpv_helper --runtime-probe` with a three-second timeout. +The probe loads dependencies through the normal ELF loader, initializes an +idle libmpv client, creates EGL/OpenGL plus mpv render contexts, then +creates, maps, validates, and destroys a minimal `16x16` shared-memory ring +named `/impv-fc-runtime-probe-`. It never opens media or enters the media +or command loops. Shared-memory creation/mapping and header-initialization +failures emit the stable helper reasons `shared-memory-create-failed` and +`shared-memory-initialize-failed`. The probe must emit exactly one protocol-v1 +JSON line and return zero. When the helper exits nonzero with an otherwise +exact failure line, the application availability diagnostic keeps the +fail-closed top-level reason `helper-probe-failed` and may add only the +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 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 +escaping keeps embedded newlines on that single line, the `stderr` field is +limited to the first 16,384 characters, and the `truncated` boolean is always +present. A missing flag, empty capture, or trace-writer failure produces no +trace and never changes the cached availability result or the application +diagnostic's stdout protocol. + +The startup probe and every playback helper session use the same sanitized +loader environment selected by the validated manifest's cached `runtimeMode`. +Both remove ambient ELF audit/preload/origin/library overrides, direct +EGL/GBM/GL/VA/Vulkan driver and layer paths, shell startup/options, tracing +hooks, and exported Bash functions. The extracted-artifact verifier applies +the same deny-set before its direct helper smoke, while preserving +feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. The system profile +then uses the default system loader without a private path. Bundled profiles +put the validated packaged `native/lib` first. AppImage and Flatpak resolve the +declared external graphics/audio interfaces through their normal host or +sandbox loader. +For the exact `com.fourgray.iptvnator` Flatpak payload under `/app`, the helper +reconstructs Freedesktop Platform 24.08's immutable +`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value: +`/etc/egl/egl_external_platform.d:/usr/lib/x86_64-linux-gnu/GL/egl/egl_external_platform.d:/usr/share/egl/egl_external_platform.d`. +All ambient EGL/GBM/GL/VA/Vulkan path overrides remain removed. Freedesktop's +GL extension `add-ld-path` is supplied through the sandbox loader cache, so no +ambient `LD_LIBRARY_PATH` is needed. Flatpak CI runs the application-level +`--embedded-mpv-runtime-probe`; direct helper execution is only a package +layout check and cannot substitute for the real gate. +The installed-Snap smoke enables `IPTVNATOR_TRACE_PLAYER=1`, +`EGL_LOG_LEVEL=debug`, and `LIBGL_DEBUG=verbose`, so GLVND/Mesa loader failures +remain observable through the bounded stderr record while the same hostile +ambient-path assertions and fail-closed application gate stay active. +Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below +`/var/lib/snapd/lib/gl` follow `native/lib`. The exact +`$SNAP/graphics/usr/lib/x86_64-linux-gnu` content-provider roots come next, +followed by the core22 base `/usr/lib/x86_64-linux-gnu`, then the GNOME +platform's fixed x64 library, Mesa, DRI, and PulseAudio roots only when +`SNAP_DESKTOP_RUNTIME` resolves exactly to `$SNAP/gnome-platform`; generic +`$SNAP` roots remain last. Core22 must precede that older desktop content +runtime so its compatible `libedit.so.2` wins instead of the GNOME copy that +requires unavailable `libtinfo.so.5`. The helper also rebuilds the GBM, GL/VA +driver, EGL vendor/platform, and Vulkan layer variables from those trusted +roots. Caller-provided triplets, graphics-driver paths, and out-of-root loader +entries are ignored. +Both the bounded probe and playback execute the helper through +`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. Before either launch, +the app requires the mounted graphics root to be a real directory and the +wrapper to be a regular, non-symlinked, readable executable. A missing or +disconnected provider therefore reports the stable +`snap-graphics-provider-unavailable` reason instead of attempting a partial +loader setup. Because that provider wrapper is a non-interactive Bash script, +the child environment also removes shell startup/options, exported +`BASH_FUNC_*` functions, and tracing hooks, and fixes `PATH` to core22 system +directories. This prevents ambient shell configuration from replacing the +probe or its `dirname` lookup before the helper executes. + +The Snap is `base: core22` with strict confinement. It keeps Electron Builder's +default plugs and adds an auto-connected private `shared-memory` plug plus the +`graphics-core22` content plug targeting a real empty mode-0755 +`$SNAP/graphics`, with `mesa-core22` as default provider. `mesa-core22` +supplies the shared +EGL/GL/GLX/GBM/DRM/VA userspace; the existing GNOME content runtime supplies +ALSA/PulseAudio. These providers are external shared snaps, so their binaries, +source, notices, and installed bytes are not part of the IPTVnator Snap or its +compliance archive. CI installs and explicitly connects both providers for a +locally installed `--dangerous` artifact, then runs the application-level +probe under strict confinement. + +The package carries the empty content target itself because core22 does not +create `$SNAP` content targets while packing. Metadata verification rejects a +missing, non-directory, symlinked, non-empty, or incorrectly permissioned +target. It also requires exactly the canonical provider-data layouts: +`/usr/share/libdrm` binds from `$SNAP/graphics/libdrm`, and +`/usr/share/drirc.d` symlinks to `$SNAP/graphics/drirc.d`. No additional or +duplicate layout entry is accepted. + +Private shared memory gives the app a confined, snap-specific POSIX shm +namespace rather than global cross-snap access. The packaging-only +`--embedded-mpv-runtime-probe` application switch invokes the same complete +manifest, mode, hash, linkage, environment, and bounded helper probe used at +startup before any BrowserWindow is created. It writes exactly one availability +JSON line and exits zero only when frame-copy is usable. Consequently the +installed-Snap smoke validates the actual confinement and shared-memory +lifecycle required by playback, not only direct helper execution. The smoke +first disconnects `graphics-core22` and requires the application diagnostic to +emit `usable:false` with reason `snap-graphics-provider-unavailable` and +controlled exit code `1`; it then reconnects the provider and requires the +same diagnostic to succeed. +Packaged addon, frame-reader, and helper discovery is limited to package-owned +`app.asar.unpacked` resource locations and never falls through to writable +cwd/dist development paths. Those fallbacks are development-only. A disabled +base experiment or any failed capability check keeps the renderer sandbox +enabled and falls back to the native engine. The result is cached by +helper/manifest identity for the process lifetime, so the startup and service +gates cannot disagree. Changing the toggle requires an app restart because web preferences are fixed at window creation. @@ -232,14 +404,12 @@ the executable resolves it from its own directory. The after-pack hook restores the POSIX helper's executable mode after the asset copy, and optional/skipped native rebuilds remove stale helper/reader artifacts before reporting frame-copy availability. This cleanup prevents known leftover build -output; it is not a compatibility check for a complete but version-mismatched -runtime pair. Linux packages deliberately do NOT ship the helper yet: it links -the build host's system `libmpv`, which packaged apps cannot assume is -installed, so centralized after-pack preparation strips both possible helper -basenames and package validation rejects either one if it survives. The -support probe therefore reports frame-copy unavailable in Linux packages. -The engine is dev-build-only on Linux until bundled-libmpv runtime staging -lands (PORTING.md milestone 4). +output; the manifest and runtime probe are the compatibility check for a +complete Linux runtime. Linux x64 packages retain the helper and frame reader: +system packages resolve the declared `libmpv.so.2` through their package +manager, while portable and sandboxed profiles resolve the source-built closure +through `$ORIGIN/lib`. Foreign-architecture packages remove all native +artifacts and contain only the unavailable marker. Trade-offs and constraints: @@ -251,12 +421,12 @@ Trade-offs and constraints: MessagePort (costs one extra copy + GC churn since Electron ports clone ArrayBuffers) or a WebCodecs-based path. - Scope: on macOS Apple Silicon only by owner decision (2026-07-10); - Intel Macs keep the native-view engine. Linux (any arch) is ported — + Intel Macs keep the native-view engine. Official Linux frame-copy is x64 — headless EGL, works under native Wayland since nothing embeds into a - window; dev builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`, - `libopengl-dev` and `libgbm-dev` (the helper links system libmpv, which - is legal out-of-process — the in-process libmpv ban still binds the - addon). The helper logs the chosen EGL display tier and the GL renderer + window; local system builds need `libmpv-dev`, `libegl-dev`, `libgl-dev`, + and `libgbm-dev`. The helper links libmpv, which is legal + out-of-process; the in-process-libmpv ban still binds the addon and frame + reader. The helper logs the chosen EGL display tier and the GL renderer string to stderr. If an early tier selects Mesa software rendering (for example, while a proprietary NVIDIA driver is reachable through the default display or GBM), it probes the remaining tiers and uses software only when @@ -491,39 +661,82 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent- Current development behavior: - The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS. -- The build script first looks for staged inputs at `vendor/embedded-mpv/-/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths. -- When the staged-input path is used, it must contain `include/mpv/client.h` and `runtime-manifest.json`. macOS and Windows staging also contains the platform runtime files that are bundled into the app. +- The build script first looks for staged inputs at `vendor/embedded-mpv/-/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries. `LIBMPV_INCLUDE_DIR` overrides the header root. `LINUX_NATIVE_LIBRARY_DIR` is a link-time override and must name a directory already visible to the system dynamic loader; it is never inherited as helper `LD_LIBRARY_PATH`. +- When the staged-input path is used, it must contain `include/mpv/client.h`, + `runtime-manifest.json`, and the platform runtime/build files. The Linux + source builder also stages the complete declared `.so` closure. - The compiled `.node` addon is copied into `dist/apps/electron-backend/native/embedded_mpv.node`. -- Bundled runtime files are copied into `dist/apps/electron-backend/native/lib/` for macOS and Windows. macOS copies `.dylib` and non-`.dylib` Mach-O dependencies; Windows copies the staged `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import libraries. Linux writes an `external-mpv-process` manifest and intentionally leaves `libmpv.so` out of the package. -- Linux does not bundle or load `libmpv` in the Electron process. The addon can compile against staged or system-development MPV headers. Its native engine still depends on an X11/Xwayland window handle plus an `mpv` executable on `PATH`; the dev-only frame-copy helper is a separate process linked to system `libmpv` and renders through headless EGL, so it bypasses those native-engine prerequisites. +- Bundled runtime files are copied into + `dist/apps/electron-backend/native/lib/`. macOS copies `.dylib` and + non-`.dylib` Mach-O dependencies; Windows copies the staged + `mpv-2.dll`/`libmpv-2.dll`/`mpv.dll`/`libmpv.dll` runtime name plus import + libraries. Linux source-runtime builds copy only the manifest-declared + closure. +- Linux never bundles or loads libmpv in the Electron process. The native-view + addon remains X11/process-only; the inverse rule applies to frame-copy: + `iptvnator_mpv_helper` must link exactly the declared `libmpv.so.2`, while + the addon and frame reader must not. - `afterPack` copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` on macOS, Windows, and Linux so the addon, manifest, and runtime libraries are filesystem-addressable. +- Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`; + package verification rejects any archived native entry so `afterPack` + remains the single profile-aware owner. -Current release caveat: +Linux release profiles: -- Release packaging requires a `vendored-lgpl` runtime manifest on macOS and Windows, and an `external-mpv-process` manifest on Linux. -- The Linux addon is built once per CI host architecture (x64). Linux packages for other architectures (arm64, armv7l) must not ship that foreign addon: `afterPack` replaces the native directory with an `embedded-mpv-unavailable.txt` marker explaining that embedded MPV is not bundled for that architecture, and package-layout verification rejects a foreign-architecture `embedded_mpv.node` while requiring the marker. +- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=system` builds DEB, RPM, and Pacman. + `afterPack` removes the private `lib` directory, writes a + `system-libmpv-frame-copy` manifest, and package metadata requires the exact + libmpv plus EGL/GL/GBM package set listed above. The DEB path is verified + on Ubuntu 24.04+; Ubuntu 22.04 users need the x64 AppImage because Jammy only + provides `libmpv1`. +- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=portable` builds AppImage and Snap with + the pinned source-built closure and a `bundled-lgpl-frame-copy` manifest. +- `IPTVNATOR_LINUX_FRAME_COPY_PROFILE=flatpak` builds Flatpak with the same + source-built closure and manifest origin. Its app-level probe reconstructs + only the exact Freedesktop 24.08 EGL external-platform search path inside the + trusted `/app` payload. +- The three profiles are separate packaging passes; mixing target sets fails + closed. Linux packages for other architectures (arm64, armv7l) must not ship + x64 native artifacts. `afterPack` replaces the native directory with + `embedded-mpv-unavailable.txt`, and package verification requires that + marker. +- Every packaged manifest names its exact artifacts, profile/targets, libmpv + SONAME, loader closure, byte sizes, SHA-256 hashes, package dependencies, and + native-view fallback. Artifact modes and ELF dependency isolation are + verified after packaging. - macOS release packaging rejects embedded MPV binaries linked to `/opt/homebrew` or `/usr/local`. -- Windows release packaging verifies that the platform runtime file is present when Embedded MPV is required. Linux release packaging verifies that the addon and manifest are present, no bundled `libmpv.so` files slipped into the package, and no development-only frame-copy helper survived `afterPack`. +- Windows release packaging verifies that the platform runtime file is present + when Embedded MPV is required. - Local development can opt into Homebrew `libmpv` only by setting `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`; packaged release validation rejects that runtime origin. -Before public release, packaging must: +Release packaging must: -- stage an LGPL-compatible `libmpv` runtime for each macOS/Windows release platform/architecture, and stage Linux MPV headers/build metadata for Linux +- stage an LGPL-compatible libmpv runtime for each bundled release + platform/architecture, including the pinned Linux x64 source runtime - collect indirect macOS dependencies expressed as absolute paths, `@loader_path`, or `@rpath` - rewrite macOS install names and dependency paths to app-relative paths such as `@loader_path` - code-sign and notarize the full macOS dependency set - ensure Windows runtime staging includes both the DLL and the import library used by `node-gyp` -- ensure Linux native builds do not gain a direct `libmpv` dependency; the runtime playback path is `mpv --wid` in a separate process -- publish the corresponding FFmpeg/libmpv source and build metadata for bundled macOS/Windows runtimes; Linux should document the distribution package versions used as build inputs +- ensure Linux Electron/addon/reader binaries do not gain a direct libmpv + dependency and the helper does +- execute the helper capability probe in each intended x64 package environment +- publish corresponding source archives, git/submodule records, checksums, + exact flags, local patches, and build scripts for every bundled runtime -Users on macOS and Windows do not need the MPV GUI application for this architecture. Linux currently requires an `mpv` executable because the supported backend is process-isolated. If the native addon/runtime prerequisites or Linux `mpv` executable are missing, embedded MPV is hidden/unsupported and the existing inline/external players remain available. +Users on macOS and Windows do not need the MPV GUI application for this +architecture. Linux native-view still requires an `mpv` executable. Linux +frame-copy system packages need their declared libmpv and EGL/GL/GBM +packages, while AppImage, Snap, and Flatpak carry their own runtime closure. +If frame-copy prerequisites are missing, x64 falls back to native-view; if all +Embedded MPV prerequisites are unavailable, the existing inline/external +players remain available. ## Runtime Staging Runtime staging tooling lives in: -- `/Users/4gray/Code/iptvnator/tools/embedded-mpv/` -- `/Users/4gray/Code/iptvnator/vendor/embedded-mpv/` +- `tools/embedded-mpv/` +- `vendor/embedded-mpv/` Release runtime policy: @@ -547,11 +760,83 @@ pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix ``` -During temporary PR and `master` artifact testing, CI can restore an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/-/` runtime and skip the expensive source build or archive staging path where one exists. The cache only contains `include/`, `lib/`, and `runtime-manifest.json`; it never contains the compiled `embedded_mpv.node` addon because that target depends on Electron headers, ABI, architecture, and build environment. Runtime cache entries are saved only from trusted repository refs, and tagged public macOS release builds continue to rebuild from pinned sources until a dedicated signed and attested runtime artifact flow exists. Windows CI uses a checksum-pinned `win32-x64` runtime archive configured through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256` repository variables or secrets on cache miss. Non-tag artifact builds have a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback so PR builds can produce a Windows embedded MPV artifact before repository variables are configured; tagged releases still require explicit repository configuration. The Windows archive helper accepts normal `lib/` + `bin/` prefixes and common `mpv-dev-lgpl` flat archives, including `libmpv-2.dll` names, and preserves the DLL basename expected by the import library; when the archive does not include `runtime-manifest.json`, it generates a minimal manifest from the archive URL/path and checksum. Linux stages Ubuntu package build inputs only; adding pinned source builders for Windows and Linux remains a separate release-hardening task. +Linux x64 builds the release runtime from pinned source inputs and stages it +before compiling the helper: -The CI builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, and HarfBuzz `8.5.0`. FFmpeg disables autodetected external libraries so Homebrew libraries cannot silently enter the runtime. Libplacebo is checked out from git with the submodules required by its Meson build because the generated GitHub archive does not include submodule contents. Even with Vulkan disabled, libplacebo still compiles Vulkan stubs and needs `3rdparty/Vulkan-Headers`. The generated manifest records source URLs, archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, FFmpeg configure flags, and mpv Meson flags. The staging step normalizes macOS/Windows manifests to `origin: vendored-lgpl`, which release package validation requires on those platforms. +```bash +pnpm embedded-mpv:build-runtime:linux -- /tmp/embedded-mpv-linux-prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/embedded-mpv-linux-prefix +``` -The Electron backend build consumes the staged runtime/build inputs and copies macOS/Windows runtime files into the native build output. Linux consumes staged MPV headers when available or distribution development headers for local builds, writes an `external-mpv-process` manifest, and does not copy `libmpv.so` into the package. macOS additionally rewrites Mach-O paths so `embedded_mpv.node` loads `@loader_path/lib/libmpv.2.dylib` instead of a machine-local Homebrew path. After `install_name_tool` rewrites any addon or runtime binary, the build re-signs that binary with an ad-hoc signature for local development. Release packaging still performs the normal app signing and notarization later. +The Linux builder is intentionally host-restricted to Linux x64. It checks +minimum build-tool versions, uses an owned staging directory plus atomic +publish, rejects host pkg-config/runtime leakage, rewrites every bundled +library to an `$ORIGIN` RUNPATH, and enforces the portable ABI ceilings +`GLIBC_2.35` and `GLIBCXX_3.4.30`. It also verifies the exact libmpv SONAME, +complete dependency closure, and absence of build-prefix paths. + +CI may restore exact-keyed caches for staged +`vendor/embedded-mpv/-/` runtimes. The Linux cache contains +only generated headers, libraries, the runtime manifest, and immutable source +inputs: exact archives, a clean recursive libplacebo checkout, and collected +license inputs. It never contains `embedded_mpv.node`, generated notices, or +the finished source-compliance archive. Runtime cache entries are saved only +from trusted repository refs. The Linux cache key covers the builder/stager, +notice generator, pinned sources, and toolchain. On every run, including a +cache hit, CI validates the cached hashes and clean checkout, regenerates the +notices for the current runtime manifest, converts libplacebo into a +VCS-metadata-free working-tree snapshot while retaining the validated +commit/submodule record, and creates +`linux-frame-copy-runtime-sources.tar.xz` for the current repository revision +and binary diff with normalized tar metadata. + +The workflow keeps the macOS/Windows package matrix independent from the Linux +runtime prerequisite. Only the three Linux profile jobs depend on the runtime +builder; both matrices reuse one YAML-anchored step list to prevent packaging +logic drift. Draft release assembly still requires both matrices, so a public +release cannot silently omit a promised platform. + +Windows CI uses a checksum-pinned `win32-x64` runtime archive configured +through `IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL` and +`IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256`. Non-tag artifact builds have +a pinned `zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` fallback; tagged +releases require explicit repository configuration. Upstream retains only its +latest 30 daily builds, so the fallback and any repository-variable copy must +be refreshed as one URL/checksum pair before expiry. A long-lived mirror must +publish the matching source/build records and license notices with the binary. +The archive helper accepts normal `lib/` + `bin/` prefixes and common flat +archives, preserves the DLL basename encoded by the import library, and +generates minimal build metadata only when the archive lacks it. + +The Linux builder pins FFmpeg `8.1`, mpv `0.41.0`, libplacebo `7.360.1`, +libass `0.17.3`, FreeType `2.13.3`, FriBidi `1.0.16`, HarfBuzz `8.5.0`, +Expat `2.8.2`, Fontconfig `2.16.0`, OpenSSL `3.5.7`, hwdata `0.409`, and +libdisplay-info `0.1.1`. FFmpeg disables autodetected external libraries. +Libplacebo is checked out at an exact git commit with all required submodules. +The hwdata archive and its `pnp.ids` build input are pinned so +libdisplay-info cannot silently consume `/usr/share/hwdata` from the builder. +The generated manifest records source URLs/checksums or git commits, +submodules, licenses, exact flags, build-host/toolchain data, runtime hashes, +and the dynamic closure. FFmpeg/mpv remain LGPL-compatible and dynamically +linked; codecs outside that build configuration are not implied. + +`generate-linux-runtime-notices.cjs` collects the exact upstream license files +for every pinned package and all recursive libplacebo submodules. Generation +is fail-closed for a missing, undeclared, symlinked, size-mismatched, or +hash-mismatched file. Portable and Flatpak package hooks copy only the +validated notice manifest, aggregate notice, and per-package license tree; +package-layout verification revalidates that legal payload against the +embedded source-runtime manifest. + +The Electron backend build consumes the staged runtime/build inputs. On Linux +it links the helper against the verified staged `libmpv.so.2`, never against a +generic host `-lmpv`, then verifies the helper's `DT_NEEDED` and +`$ORIGIN/lib` RUNPATH with `readelf`. The addon and frame reader are checked +for the opposite invariant. The profile-aware packaging hook later retains or +removes the private closure. Local Linux builds may still use distribution +headers/libraries, but a required package build must use the staged manifest. +macOS additionally rewrites Mach-O paths and re-signs modified local binaries; +release signing/notarization still happens later. For local development before the vendored runtime exists, Homebrew can be used explicitly: @@ -593,7 +878,88 @@ For tagged macOS builds, CI must: For Windows builds, CI must restore the `win32-x64` staged runtime cache or stage the checksum-pinned runtime archive before `pnpm run build:backend`. The Windows job must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=win32`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for backend build, package make, and package-layout verification. CI narrows `electron-builder.json` to x64 Windows targets while only a `win32-x64` runtime is available. The Windows job is pinned to `windows-2022` until the Electron `node-gyp` toolchain can identify Visual Studio 18 from `windows-latest`. -For Linux builds, CI must set `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, `IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, and `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` after staging the Ubuntu package build inputs. Linux package verification checks the `external-mpv-process` manifest and confirms that no bundled `libmpv.so` files are present. +For Linux builds, CI first builds or restores the pinned x64 source runtime and +stages it under `vendor/embedded-mpv/linux-x64`. It then runs three isolated +packaging passes with `IPTVNATOR_EMBEDDED_MPV_PLATFORM=linux`, +`IPTVNATOR_EMBEDDED_MPV_ARCH=x64`, +`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 the exact pinned commit and +six recursive submodule records, notice/license inputs, runtime metadata, +current revision/diff, and build tooling. Each submodule record is canonical +`full-commit safe/path`; clone-depth-dependent `git describe` annotations are +discarded. 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. 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. The public-release verifier separately reapplies the exact +strict `meta/snap.yaml` graphics/shared-memory/layout contract and enumerates +the extracted `resources/app.asar`; any archived +`electron-backend/native/**` entry fails before Store publication. The bounded +ASAR header reader uses only Node built-ins plus released local tooling, keeping +this check runnable from the clean tag checkout without `node_modules`. 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. @@ -605,7 +971,8 @@ The feature is still experimental. The largest risks are native-process risks, n - packaging can fail if `libmpv` or one of its platform runtime dependencies is missing, unsigned where signing applies, or linked to the wrong runtime path - macOS graphics behavior can vary across Intel, Apple Silicon, external displays, fullscreen transitions, and hardware decoding paths - Windows `HWND` and Linux X11/Xwayland embedding need packaged-app smoke coverage for focus, resize, and fullscreen behavior -- Linux native Wayland is unsupported until a dedicated Wayland embedding path exists +- Linux native-view remains unsupported on native Wayland; frame-copy has no + window-embedding dependency but still requires a working EGL probe - Homebrew `libmpv` builds can target a newer macOS version than IPTVnator's declared deployment target It is reasonable to ship the code in-tree behind the current experiment flag. It is not yet safe to make it the default player. It can be exposed as desktop experimental if support detection is strict, the UI clearly labels it experimental, and fallback to Video.js or external MPV/VLC stays available. @@ -616,8 +983,18 @@ If an embedded session fails to initialize, the app should keep the user in cont Do not expose embedded MPV broadly until these pass on every supported target: -- macOS/Windows packaged app starts without system `mpv` installed; Linux reports Embedded MPV unsupported with a clear message when system `mpv` is missing -- bundled `libmpv` and dependent runtime files pass macOS/Windows package validation; Linux package validation confirms the external-process manifest and absence of bundled `libmpv.so` +- macOS/Windows packaged apps start without system `mpv`; Linux x64 + frame-copy starts in each declared package profile, and a missing frame-copy + dependency falls back without crashing +- bundled libmpv and dependent runtime files pass package validation; + DEB/RPM/Pacman contain no private closure and declare the exact system + dependency +- Electron, its shipped libraries, `embedded_mpv.node`, and the frame reader + have no direct libmpv `DT_NEEDED`; the helper resolves the exact declared + libmpv runtime +- AppImage, DEB, RPM, Pacman, Snap, and Flatpak payloads pass extraction, + manifest/mode/ELF checks and the applicable helper probe; ARM payloads are + marker-only - macOS bundled `libmpv` and dependent dylibs pass code signing and notarization - VOD resume starts near the saved offset - series EOF emits `ended` and embedded MPV auto-continues only inside the current season diff --git a/docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md b/docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md new file mode 100644 index 000000000..a090863f3 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-linux-embedded-mpv-frame-copy-packaging.md @@ -0,0 +1,658 @@ +# Linux Embedded MPV Frame-Copy Packaging Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a verified Linux x64 frame-copy runtime in AppImage, DEB, RPM, Pacman, Snap, and Flatpak while preserving out-of-process libmpv isolation and honest native-view fallback. + +**Architecture:** Split official Linux packaging into system-runtime and bundled-runtime passes because Electron Builder reuses one unpacked layout per pass. A versioned manifest plus a real helper `--runtime-probe` gates frame-copy before BrowserWindow creation; only the helper may link libmpv, and foreign-architecture packages retain the unavailable marker. + +**Tech Stack:** Electron 41, Node/TypeScript, C++17/N-API, libmpv render API, EGL/OpenGL/GBM, ELF/RPATH tooling, electron-builder 26, Nx/Jest/node:test, Playwright, GitHub Actions, AppImage/DEB/RPM/Pacman/Snap/Flatpak. + +--- + +## File Map + +### Runtime contracts and staging + +- Create `tools/embedded-mpv/linux-runtime-manifest.cjs` + - Parse, normalize, and validate Linux frame-copy runtime manifests. +- Create `tools/embedded-mpv/build-linux-runtime.mjs` + - Build the pinned LGPL-compatible FFmpeg/libass/libplacebo/libmpv prefix. +- Modify `tools/embedded-mpv/stage-runtime.mjs` + - Stage Linux shared libraries and reject incomplete release manifests. +- Modify `apps/electron-backend/build-embedded-mpv.js` + - Build against staged Linux libmpv, copy the bundled closure, and emit the + profile-neutral build manifest. +- Modify `apps/electron-backend/native/binding.gyp` + - Keep helper RPATH relative and remove build-host RPATH. +- Modify `package.json` + - Expose the Linux runtime build command. + +### Packaging profiles and validation + +- Create `tools/packaging/linux-frame-copy-profile.cjs` + - Own profile names, target sets, manifest origins, and package dependencies. +- Create `tools/packaging/linux-frame-copy-profile.test.mjs` + - Verify profile/target/dependency mapping and invalid combinations. +- Modify `electron-builder.json` + - Declare DEB/RPM/Pacman libmpv dependencies. +- Modify `tools/packaging/embedded-mpv-frame-copy-files.cjs` + - Package Linux helper/reader and select/remove private runtime by profile. +- Modify `tools/packaging/embedded-mpv-packaging.cjs` + - Validate Linux ELF linkage, manifest, files, modes, RPATH, and isolation. +- Modify `tools/packaging/embedded-mpv-arch.test.mjs` + - Cover system, bundled, malformed, and foreign-architecture layouts. +- Modify `tools/packaging/electron-after-pack.cjs` + - Pass the required Linux profile into preparation and validation. +- Modify `tools/packaging/verify-electron-package-layout.mjs` + - Verify the expected profile for every unpacked layout. +- Modify `tools/packaging/project.json` + - Add new tests and source inputs. + +### Runtime capability probe + +- Modify `apps/electron-backend/native/helper/frame_helper_gl.h` + - Provide a context-only probe that does not create a playback session. +- Modify `apps/electron-backend/native/helper/mpv_frame_helper.cpp` + - Implement the versioned `--runtime-probe` JSON protocol. +- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts` + - Validate manifest/files and run/cache the bounded helper probe. +- Create `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts` + - Cover all fail-closed paths and success caching. +- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts` + - Resolve an artifact set and delegate usability to the runtime probe. +- Modify `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts` + - Keep path/security coverage and add manifest/probe integration cases. +- Modify `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` + - Surface stable fallback diagnostics without changing native-view safety. + +### Linux CI, package smoke, and documentation + +- Create `tools/packaging/verify-linux-frame-copy-runtime.mjs` + - Inspect real package payload ELF/modes/manifest and invoke the helper probe. +- Create `tools/packaging/verify-linux-frame-copy-runtime.test.mjs` + - Unit-test verifier parsing and failure reporting with fixtures. +- Create `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts` + - Exercise packaged capability, a deterministic media frame, and fallback. +- Modify `.github/workflows/build-and-make.yaml` + - Build/cache runtime, split profiles, inspect every format, and run sandbox + and container smoke coverage. +- Modify `docs/architecture/embedded-mpv-native.md` +- Modify `tools/embedded-mpv/README.md` +- Modify `vendor/embedded-mpv/README.md` +- Modify `AGENTS.md` +- Modify `CLAUDE.md` + - Document the final contract and verification matrix. + +## Task 1: Define Linux Packaging Profiles + +**Files:** + +- Create: `tools/packaging/linux-frame-copy-profile.cjs` +- Create: `tools/packaging/linux-frame-copy-profile.test.mjs` +- Modify: `electron-builder.json` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write failing profile tests** + +Cover the exact public API: + +```js +assert.deepEqual(resolveLinuxFrameCopyProfile('system'), { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', +}); +assert.deepEqual(resolveLinuxFrameCopyProfile('portable').targets, [ + 'appimage', + 'snap', +]); +assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak').targets, ['flatpak']); +assert.throws(() => resolveLinuxFrameCopyProfile('standard'), /Unsupported/); +assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { + deb: 'libmpv2', + rpm: 'mpv-libs', + pacman: 'mpv', +}); +assert.deepEqual(validateLinuxProfileTargets('system', ['deb', 'AppImage']), [ + 'Linux frame-copy profile "system" cannot build target "appimage".', +]); +``` + +- [ ] **Step 2: Run RED** + +```bash +node --test tools/packaging/linux-frame-copy-profile.test.mjs +``` + +Expected: FAIL because the profile module does not exist. + +- [ ] **Step 3: Implement the profile module and package dependencies** + +Export immutable `LINUX_FRAME_COPY_PROFILES`, +`LINUX_SYSTEM_PACKAGE_DEPENDENCIES`, `resolveLinuxFrameCopyProfile()`, and +`validateLinuxProfileTargets()`. Add `deb.depends += libmpv2`, +`rpm.depends += mpv-libs`, and `pacman.depends += mpv` without replacing +Electron Builder's existing defaults. + +- [ ] **Step 4: Register and run GREEN** + +Add the new test to `packaging:test`, then run: + +```bash +pnpm nx test packaging --skip-nx-cache +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add electron-builder.json tools/packaging +git commit -m "feat(packaging): define Linux frame-copy profiles" +``` + +## Task 2: Stage A Pinned LGPL Linux Runtime + +**Files:** + +- Create: `tools/embedded-mpv/linux-runtime-manifest.cjs` +- Create: `tools/embedded-mpv/linux-runtime-manifest.test.mjs` +- Create: `tools/embedded-mpv/build-linux-runtime.mjs` +- Modify: `tools/embedded-mpv/stage-runtime.mjs` +- Modify: `package.json` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write failing manifest/staging tests** + +Use temporary prefixes to prove: + +```js +assert.equal(validateLinuxRuntimeManifest(validManifest).length, 0); +assert.match( + validateLinuxRuntimeManifest({ + ...validManifest, + ffmpeg: { configureFlags: ['--enable-gpl'] }, + })[0], + /--enable-gpl/ +); +assert.match( + validateLinuxRuntimeManifest({ + ...validManifest, + mpv: { mesonFlags: ['-Dgpl=true'] }, + })[0], + /-Dgpl=false/ +); +``` + +Exercise `stage-runtime.mjs linux x64 ` and assert it copies +`libmpv.so.2` plus all manifest-declared `.so` files and records byte sizes. + +- [ ] **Step 2: Run RED** + +```bash +node --test tools/embedded-mpv/linux-runtime-manifest.test.mjs +``` + +Expected: FAIL because the validator/build/staging contract is absent. + +- [ ] **Step 3: Implement the source builder** + +Reuse the pinned package versions already used by the macOS builder. Linux +FFmpeg flags must include: + +```text +--enable-shared --disable-static --disable-programs --disable-doc +--disable-debug --disable-autodetect --disable-gpl --disable-nonfree +--enable-pic --enable-pthreads +``` + +Linux mpv flags must include: + +```text +-Dgpl=false -Dlibmpv=true -Dcplayer=false -Dtests=false +-Dlua=disabled -Djavascript=disabled -Dcplugins=disabled +-Dlibarchive=disabled -Dlibbluray=disabled -Ddvdnav=disabled +-Dcdda=disabled -Ddvbin=disabled -Dvulkan=disabled +-Dplain-gl=enabled -Degl=enabled -Dgbm=enabled +``` + +Record downloaded SHA-256 values, git commits/submodules, exact flags, runtime +file names/sizes, and source-distribution obligations. Pin the hwdata v0.409 +archive and record its `pnp.ids` as a build input to libdisplay-info 0.1.1. +Stage private `hwdata.pc` metadata and run libdisplay-info's Meson setup with a +prefix-only pkg-config environment so the upstream +`/usr/share/hwdata/pnp.ids` fallback is unreachable. Include the exact hwdata +archive and its `GPL-2.0-or-later OR XFree86-1.0` notice in the release source +bundle. + +- [ ] **Step 4: Implement Linux staging** + +Require `include/mpv/client.h`, a versioned `libmpv.so.*`, and a valid manifest. +Copy only manifest-declared shared libraries and preserve SONAME symlinks as +materialized regular files so Electron Builder cannot lose them. + +- [ ] **Step 5: Run GREEN and static policy checks** + +```bash +pnpm nx test packaging --skip-nx-cache +node --check tools/embedded-mpv/build-linux-runtime.mjs +node --check tools/embedded-mpv/stage-runtime.mjs +``` + +Expected: PASS and no GPL/nonfree-enabling flag in generated policy fixtures. + +- [ ] **Step 6: Commit** + +```bash +git add package.json tools/embedded-mpv tools/packaging/project.json +git commit -m "feat(embedded-mpv): stage LGPL Linux runtime" +``` + +## Task 3: Build A Relocatable Isolated Helper + +**Files:** + +- Modify: `apps/electron-backend/native/binding.gyp` +- Modify: `apps/electron-backend/build-embedded-mpv.js` +- Test: `apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts` + +- [ ] **Step 1: Extend source-policy tests** + +Assert the Linux helper has `$ORIGIN/lib` and no absolute build-host RPATH, +the addon does not use `-lmpv`, the staged closure is copied, and the build +manifest identifies both allowed package modes. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: FAIL on the current absolute `LINUX_NATIVE_LIBRARY_DIR` RPATH and +`external-mpv-process`-only manifest. + +- [ ] **Step 3: Update native build integration** + +Link the helper against staged `libmpv.so`, retain only: + +```text +-Wl,--enable-new-dtags +-Wl,-rpath,$ORIGIN/lib +``` + +Copy the staged shared-library closure to build output for downstream bundled +profiles, but keep `embedded_mpv.node` dynamically independent of libmpv. +Write a build manifest that carries the runtime metadata without prematurely +choosing `system` versus `portable`. + +- [ ] **Step 4: Run GREEN** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/electron-backend/native/binding.gyp \ + apps/electron-backend/build-embedded-mpv.js \ + apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +git commit -m "feat(embedded-mpv): build relocatable Linux helper" +``` + +## Task 4: Package And Validate Each Runtime Mode + +**Files:** + +- Modify: `tools/packaging/embedded-mpv-frame-copy-files.cjs` +- Modify: `tools/packaging/embedded-mpv-packaging.cjs` +- Modify: `tools/packaging/embedded-mpv-arch.test.mjs` +- Modify: `tools/packaging/electron-after-pack.cjs` +- Modify: `tools/packaging/verify-electron-package-layout.mjs` + +- [ ] **Step 1: Write failing package-layout tests** + +Create realistic temp layouts and prove: + +- system mode requires executable helper, reader, system manifest, no + `native/lib/libmpv*`; +- bundled mode requires all manifest files and rejects missing/runtime-prefix + links; +- helper mode `0644` is rejected; +- reader symlinks/directories are rejected; +- Linux addon or Electron `DT_NEEDED libmpv` is rejected; +- helper without `DT_NEEDED libmpv.so.2` is rejected; +- foreign-arch layout contains only the unavailable marker. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test packaging --skip-nx-cache +``` + +Expected: FAIL because Linux helpers are deleted and validation forbids them. + +- [ ] **Step 3: Implement profile-aware preparation** + +For x64: + +- restore helper mode `0755`; +- always retain the frame reader; +- `system`: remove private runtime and write normalized system manifest; +- `portable`/`flatpak`: retain only manifest-declared closure and write bundled + manifest; +- reject missing `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` when Embedded MPV is + required. + +For foreign architectures, keep the current unavailable marker behavior and +remove every native artifact. + +- [ ] **Step 4: Implement ELF/package validation** + +Use `readelf -d` for `NEEDED` and RPATH/RUNPATH inspection. Resolve bundled +closure recursively from the private directory and permit only a documented +glibc/driver/system allowlist outside it. Keep validation host-aware: pure +manifest/mode checks run everywhere; ELF inspection is required on Linux CI. + +- [ ] **Step 5: Run GREEN** + +```bash +pnpm nx test packaging --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add tools/packaging +git commit -m "feat(packaging): ship Linux frame-copy artifacts" +``` + +## Task 5: Add The Real Runtime Capability Probe + +**Files:** + +- Modify: `apps/electron-backend/native/helper/frame_helper_gl.h` +- Modify: `apps/electron-backend/native/helper/mpv_frame_helper.cpp` +- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts` +- Create: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts` +- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts` +- Modify: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts` +- Modify: `apps/electron-backend/src/app/services/embedded-mpv-native.service.ts` + +- [ ] **Step 1: Write failing TypeScript probe tests** + +Inject filesystem and `spawnSync` collaborators. Cover: + +```ts +expect(probeRuntime(validArtifacts, successSpawn).usable).toBe(true); +expect(probeRuntime(validArtifacts, timeoutSpawn).reason).toBe( + 'helper-probe-timeout' +); +expect(probeRuntime(validArtifacts, nonzeroSpawn).reason).toBe( + 'helper-probe-failed' +); +expect(probeRuntime(validArtifacts, invalidJsonSpawn).reason).toBe( + 'helper-probe-invalid-output' +); +expect(probeRuntime(missingManifest, successSpawn).reason).toBe( + 'runtime-manifest-missing' +); +expect(successSpawn).toHaveBeenCalledTimes(1); +``` + +The last assertion calls the public probe twice and proves process-lifetime +caching. + +- [ ] **Step 2: Run RED** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-frame-copy-runtime.spec +``` + +Expected: FAIL because the runtime probe module does not exist. + +- [ ] **Step 3: Implement helper `--runtime-probe`** + +Emit exactly one line: + +```json +{ "protocol": 1, "usable": true, "libmpv": "2.x", "renderApi": "egl" } +``` + +Exit nonzero with a JSON `reason` when `mpv_create`, `mpv_initialize`, or the +EGL/OpenGL context probe fails. Do not create shared memory, open a URL, or +enter the normal command loop. + +- [ ] **Step 4: Implement fail-closed main-process probing** + +Validate manifest/artifacts first, then run: + +```ts +spawnSync(helperPath, ['--runtime-probe'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + env: probeEnvironment, +}); +``` + +Cache by helper/manifest identity. Never throw across startup; return a stable +reason and make `isFrameCopyRuntimeUsable()` depend on `.usable`. + +- [ ] **Step 5: Run GREEN and related regression tests** + +```bash +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns='embedded-mpv-frame-copy-(runtime|platform).util.spec|app.spec|embedded-mpv-native.service.spec' +``` + +Expected: PASS; unavailable runtime keeps sandbox enabled and selects native. + +- [ ] **Step 6: Commit** + +```bash +git add apps/electron-backend/native/helper \ + apps/electron-backend/src/app/services +git commit -m "feat(embedded-mpv): probe Linux frame-copy runtime" +``` + +## Task 6: Split Linux CI And Inspect Every Artifact + +**Files:** + +- Create: `tools/packaging/verify-linux-frame-copy-runtime.mjs` +- Create: `tools/packaging/verify-linux-frame-copy-runtime.test.mjs` +- Modify: `.github/workflows/build-and-make.yaml` +- Modify: `tools/packaging/project.json` + +- [ ] **Step 1: Write failing verifier tests** + +Test payload discovery for `.AppImage`, `.deb`, `.rpm`, Pacman archive, +`.snap`, and `.flatpak`, plus clear errors for a missing helper, wrong mode, +wrong profile, direct addon libmpv linkage, and unresolved helper dependency. + +- [ ] **Step 2: Run RED** + +```bash +node --test tools/packaging/verify-linux-frame-copy-runtime.test.mjs +``` + +Expected: FAIL because the verifier does not exist. + +- [ ] **Step 3: Implement artifact verification** + +Provide `--artifact --profile `. Extract/mount into a temp +directory, find the native layout, run manifest/mode/ELF checks, and execute +`iptvnator_mpv_helper --runtime-probe` in the package's intended environment. +Always clean temporary mounts/directories. + +- [ ] **Step 4: Split and harden CI** + +Change Linux matrix entries to: + +```yaml +- os: linux + linux_profile: system +- os: linux + linux_profile: portable +- os: linux + linux_profile: flatpak +``` + +Filter targets exactly per profile and set +`IPTVNATOR_LINUX_FRAME_COPY_PROFILE`. Build/cache the pinned runtime once per +source/tool hash. Add format-specific extraction/installation tools and invoke +the verifier for every produced artifact. + +Run system formats in matching containers with `libmpv2`, `mpv-libs`, or +`mpv`; run AppImage directly with extraction fallback; install and probe Snap +and Flatpak inside their sandboxes. + +- [ ] **Step 5: Run GREEN and workflow source regressions** + +```bash +pnpm nx test packaging --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache --runInBand \ + --testPathPatterns=embedded-mpv-native-source.spec +``` + +Expected: PASS and source tests prove all three profiles and six formats. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/build-and-make.yaml tools/packaging +git commit -m "ci: verify Linux frame-copy packages" +``` + +## Task 7: Add Packaged Playback And Fallback Smoke Coverage + +**Files:** + +- Create: `apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts` +- Modify: `.github/workflows/build-and-make.yaml` + +- [ ] **Step 1: Write the packaged E2E** + +Use the existing Electron test fixtures and a generated two-second local media +fixture. Assert the support response reports `frameCopyAvailable: true`, +activate the engine, load the fixture, observe a nonzero frame generation and +playing/paused snapshot, then relaunch with the runtime hidden and assert +native engine selection without a main-process crash. + +- [ ] **Step 2: Run the closest local parse/list check** + +```bash +pnpm nx lint electron-backend-e2e +pnpm nx show project electron-backend-e2e +``` + +Expected: PASS on macOS; the actual test is Linux-packaged-only. + +- [ ] **Step 3: Wire the Linux packaged smoke** + +Run the spec against the unpacked x64 bundled layout under software EGL +(`LIBGL_ALWAYS_SOFTWARE=1`) and keep a hardware-enabled smoke as a separate +non-blocking diagnostic when the CI runner exposes DRI. + +- [ ] **Step 4: Commit** + +```bash +git add apps/electron-backend-e2e/src/embedded-mpv-frame-copy-packaged.e2e.ts \ + .github/workflows/build-and-make.yaml +git commit -m "test(embedded-mpv): smoke packaged Linux frame-copy" +``` + +## Task 8: Update Canonical Documentation + +**Files:** + +- Modify: `docs/architecture/embedded-mpv-native.md` +- Modify: `tools/embedded-mpv/README.md` +- Modify: `vendor/embedded-mpv/README.md` +- Modify: `AGENTS.md` +- Modify: `CLAUDE.md` + +- [ ] **Step 1: Replace the dev-only Linux contract** + +Document x64 support across all six formats, the three profiles, dependency +names, runtime manifest/probe, codec baseline, source obligations, fallback, +and ARM unavailable behavior. Keep `AGENTS.md` and `CLAUDE.md` synchronized. + +- [ ] **Step 2: Verify documentation consistency** + +```bash +rg -n "dev-build-only|stripped from packages|must not bundle libmpv" \ + AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md vendor/embedded-mpv/README.md +git diff --check +``` + +Expected: no stale Linux frame-copy shipping claim; any remaining +“must not bundle” text applies specifically to Electron/addon or system +profiles. + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md CLAUDE.md docs/architecture/embedded-mpv-native.md \ + tools/embedded-mpv/README.md vendor/embedded-mpv/README.md +git commit -m "docs: document Linux frame-copy packages" +``` + +## Task 9: Final Verification Matrix + +**Files:** No production edits unless verification exposes a defect. + +- [ ] **Step 1: Run local project discovery and affected checks** + +```bash +pnpm nx show projects +pnpm nx test packaging --skip-nx-cache +pnpm nx test electron-backend --skip-nx-cache --runInBand +pnpm nx lint packaging --skip-nx-cache +pnpm nx lint electron-backend --skip-nx-cache +pnpm nx lint electron-backend-e2e --skip-nx-cache +pnpm nx build electron-backend --configuration=production --skip-nx-cache +``` + +Expected: PASS or an explicitly recorded platform-only native-build skip on +macOS without a vendored runtime. + +- [ ] **Step 2: Verify isolation source and local artifacts** + +```bash +rg -n -- '-lmpv|libmpv' apps/electron-backend/native/binding.gyp \ + tools/packaging apps/electron-backend/build-embedded-mpv.js +git diff --check origin/master...HEAD +git status --short +``` + +Expected: only the helper target links libmpv; no unstaged/unexplained files. + +- [ ] **Step 3: Record the exact evidence matrix** + +Report separately: + +- verified locally on macOS: Node/Jest/lint/build/static/package-policy tests; +- structurally verified but not executable locally: Linux runtime builder and + artifact extraction code; +- requires Linux CI: ELF resolution, actual six-format packages, package + managers, Snap/Flatpak confinement, EGL/GBM, frame production, and fallback + with libmpv removed. + +- [ ] **Step 4: Stop before publication** + +Do not push, open a PR, publish artifacts, or merge. Leave the fully checked +local branch ready for explicit user confirmation in a new task. diff --git a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md new file mode 100644 index 000000000..94745ccaf --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -0,0 +1,263 @@ +# Linux Embedded MPV Frame-Copy Packaging Design + +**Date:** 2026-07-17 + +**Status:** Approved + +## Goal + +Ship a genuinely usable Embedded MPV frame-copy runtime in every official +Linux x64 package format produced by IPTVnator: AppImage, DEB, RPM, Pacman, +Snap, and Flatpak. Keep libmpv outside the Electron process, retain the +existing native-view engine as a safe fallback, and never advertise +frame-copy from artifact presence alone. + +Linux arm64 and armv7l remain out of scope for native Embedded MPV artifacts. +The current CI cross-packages those architectures from an x64 host and cannot +produce or exercise matching native addons. Those packages must continue to +carry an explicit unavailable marker and must not contain x64 native binaries. + +## Evidence From The Existing Implementation + +- `apps/electron-backend/native/binding.gyp` builds three distinct artifacts: + the native-view addon, the N-API shared-memory frame reader, and the + `iptvnator_mpv_helper` process. On Linux only the helper links `-lmpv`; the + addon uses X11/Xext/dlopen and must remain free of libmpv linkage. +- `tools/packaging/embedded-mpv-frame-copy-files.cjs` deliberately deletes the + helper from every Linux package. +- `tools/packaging/embedded-mpv-packaging.cjs` rejects Linux packages that + contain either the helper or libmpv, and accepts only the + `external-mpv-process` manifest origin. +- `resolveFrameCopyHelperPath()` currently treats an executable helper plus a + readable reader addon as a usable runtime. It does not prove that the ELF + loader can resolve libmpv or that libmpv/EGL initialization works. +- `.github/workflows/build-and-make.yaml` builds and verifies the Linux helper + against Ubuntu's system libmpv, then relies on the after-pack hook to remove + it. The same x64 build output is used for foreign-architecture Linux + packages, which receive the unavailable marker. +- Electron Builder creates one unpacked application layout before producing + multiple distributable targets. A system-runtime layout and a bundled + portable-runtime layout therefore cannot safely share one packaging pass. + +## Selected Distribution Strategy + +Linux packaging is split into explicit profiles: + +| Profile | Formats | libmpv strategy | +| ---------- | ---------------- | -------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | Depend on the distribution package and resolve `libmpv.so.2` from the host | +| `portable` | AppImage, Snap | Bundle the pinned LGPL-compatible runtime closure under `native/lib` | +| `flatpak` | Flatpak | Bundle the same pinned LGPL-compatible runtime closure under `native/lib` | + +The official CI matrix must run these profiles independently. The packaging +hook receives the profile through a required environment value and validates +that the selected target set matches the runtime mode. It must fail closed if +an official x64 package is requested with an absent, incomplete, or ambiguous +profile. + +System package dependencies are: + +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` + +These names match the current Debian, Fedora, and Arch package databases and +cover every direct helper interface: libmpv, EGL, GL, and GBM. The helper +links `libGL.so.1` rather than `libOpenGL.so.0`, matching both the distro +contracts and Snap's graphics provider. System +packages do not copy libmpv into IPTVnator. The helper keeps an `$ORIGIN/lib` +RUNPATH first for a consistent binary, but naturally resolves the system SONAME +when the private directory is absent. + +Portable and sandboxed packages use a source-built runtime rather than copying +the Ubuntu runner's mpv package. The runtime build is checksum/version pinned, +uses FFmpeg without GPL/nonfree switches and mpv with `-Dgpl=false`, records +sources and exact flags in the manifest, and keeps shared libraries replaceable +under the LGPL. The minimal codec baseline is FFmpeg's built-in LGPL decoders, +demuxers, protocols, and software scaling/resampling plus libass text +subtitles. Hardware decoding remains opportunistic through host Mesa/driver +interfaces and must fall back to software decoding. + +The source build also pins the `hwdata` v0.409 archive and its SHA-256 because +libdisplay-info 0.1.1 compiles `pnp.ids` into its generated vendor lookup +table. The builder stages that file with private `hwdata.pc` metadata and +restricts libdisplay-info's native pkg-config search to the staged prefix, so +Meson's `/usr/share/hwdata/pnp.ids` fallback cannot make the runtime depend on +unrecorded host data. The runtime manifest records this build-input +relationship. Release source bundles must include the exact hwdata archive and +its dual-license notice (`GPL-2.0-or-later OR XFree86-1.0`) alongside the +MIT-licensed libdisplay-info source. + +The strict Snap uses `base: core22`, a private `shared-memory` plug, and an +exact `graphics-core22` content plug targeting a real empty mode-0755 +`$SNAP/graphics` with external `mesa-core22` as default provider. The graphics provider supplies +EGL/GL/GLX/GBM/DRM/VA; Electron Builder's GNOME content runtime supplies +ALSA/PulseAudio. Those shared providers are not copied into IPTVnator's Snap or +source/notices archive. Because core22 does not synthesize `$SNAP` content +targets, the package hook creates the empty directory and extracted-artifact +validation checks its type and emptiness. The metadata also declares exactly +the canonical graphics layouts: `/usr/share/libdrm` binds from +`$SNAP/graphics/libdrm`, and `/usr/share/drirc.d` symlinks to +`$SNAP/graphics/drirc.d`. Locally installed `--dangerous` artifacts explicitly +install and connect both providers in CI, disconnect `graphics-core22` to +require an unavailable application diagnostic with exit code `1`, then +reconnect it and require success. + +## Runtime Layout And Linkage + +The x64 packaged native directory is: + +```text +resources/app.asar.unpacked/electron-backend/native/ + embedded_mpv.node + embedded_mpv_frame_reader.node + iptvnator_mpv_helper + embedded-mpv-runtime.json + lib/ + libmpv.so.2 + libavcodec.so.* + libavformat.so.* + libavutil.so.* + libavfilter.so.* + libswresample.so.* + libswscale.so.* + libass.so.* + ...other non-system runtime dependencies +``` + +For `system`, `lib/` is absent and the manifest declares the required SONAME +and package-family dependency. For `portable` and `flatpak`, `lib/` contains +the complete non-system dependency closure. ELF dependencies inside that +closure and the helper use only SONAMEs plus `$ORIGIN`-relative RPATH/RUNPATH; +they may not retain build-prefix paths. + +`embedded_mpv.node`, the Electron executable (`iptvnator.bin`), and Electron's +shipped libraries must not have a direct `DT_NEEDED` entry for libmpv. +`iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a +profile-specific choice. The source `electron-backend/native{,/**/*}` tree is +excluded from `app.asar`; `afterPack` is the sole owner of the normalized +unpacked native directory, and package checks reject every archived native +entry. The pristine Electron tree is scanned recursively +before target packaging. Because Snap later overlays package-manager +`lib/**`/`usr/lib/**` trees into the payload root, its extracted-target scan +excludes exactly those two target-provided trees while remaining recursive +everywhere else. Electron-library symlinks outside those roots fail closed. + +The manifest records: + +- schema version, platform, architecture, profile, and runtime origin; +- required helper/reader names and executable/readable expectations; +- libmpv SONAME and either system package requirements or bundled files; +- source package versions, URLs/checksums, license identifiers, and exact + FFmpeg/mpv build flags for bundled profiles; +- the pinned hwdata `pnp.ids` build input consumed by libdisplay-info; +- runtime closure and total byte size; +- the native-view backend contract and the fact that only the helper links + libmpv. + +## Honest Capability Detection + +The helper gains a side-effect-free `--runtime-probe` mode. It must: + +1. load through the normal ELF loader and therefore prove that all `DT_NEEDED` + dependencies resolve; +2. create and initialize an idle libmpv handle with `vo=libmpv`; +3. create the platform render pipeline far enough to prove EGL/OpenGL/GBM + availability without opening media; +4. create, map, validate, and destroy the minimal shared-memory ring required + by playback; +5. emit one versioned JSON result and exit promptly with status zero only on + success. + +The main process invokes this probe synchronously with a bounded timeout before +BrowserWindow creation. Probe success is cached for the process lifetime. +The probe environment prepends the packaged `native/lib` directory only when +the manifest declares a bundled runtime. The system profile does not inject a +private loader path. Snap additionally rebuilds its loader and graphics-driver +variables from validated host GL, `$SNAP/graphics`, exact GNOME-platform, and +generic core22 roots; ambient preload/audit/library/driver overrides and +caller-provided architecture triplets are not inherited. The direct provider +wrapper launch also removes shell startup/options, tracing hooks, and exported +functions and fixes `PATH` to immutable core22 system directories. + +Packaging CI invokes the full main-process gate with the exact +`--embedded-mpv-runtime-probe` application switch. It executes before +BrowserWindow startup, writes one availability JSON line, and exits zero only +for a usable runtime. CI does not treat a direct helper invocation or an +environment opt-in as proof of packaged capability. + +Frame-copy is usable only when all of the following are true: + +- platform and architecture are supported; +- helper and reader are regular files with correct access modes; +- the runtime manifest is present, parses, matches Linux/x64, names the actual + artifacts, and uses an allowed profile/origin; +- every manifest-declared bundled file exists as a readable regular file; +- `--runtime-probe` succeeds and returns the expected protocol version. + +Any failure returns `false`, keeps the renderer sandbox enabled, and makes the +native service choose native-view. The capability result includes a stable +reason code for tracing and diagnostics but does not crash startup. + +If dependencies disappear after startup, helper spawn/early-exit remains a +session error and follows the existing renderer fallback path. The helper is +never loaded into Electron as a library. + +## Packaging And CI Validation + +Unit and packaging tests cover: + +- profile-to-target mapping and rejection of mixed system/bundled passes; +- Linux runtime staging, manifest normalization, closure collection, RPATH, + file modes, and stale-artifact cleanup; +- package validation for system, portable, Flatpak, foreign architecture, and + malformed/incomplete manifests; +- capability probe timeout, nonzero exit, invalid JSON, manifest mismatch, + missing dependency, and successful result caching; +- exclusion of all native payloads from `app.asar`, including marker-only ARM + and system-package stale x64 artifacts; +- helper probe protocol and failure behavior; +- package metadata dependencies for DEB/RPM/Pacman. + +Linux CI must: + +1. build or restore the pinned x64 LGPL runtime; +2. build the addon, reader, and helper once against that staged runtime; +3. prove with `readelf`/`ldd` that Electron and `embedded_mpv.node` do not link + libmpv and the helper does; +4. package the three profiles independently; +5. unpack or mount each produced format and validate its real payload, modes, + manifest, RPATH, dependency closure, and profile; +6. install/run the application-level packaged gate inside the actual Snap and + Flatpak (with the exact Freedesktop 24.08 EGL external-platform path + reconstructed inside `/app`), and probe the AppImage payload; +7. install system packages in matching disposable distro containers and run + the helper probe after the declared libmpv dependency is installed; +8. run a packaged Electron smoke test that confirms frame-copy capability, + creates a helper session against a deterministic local media fixture, sees + at least one frame/snapshot, and then repeats with libmpv hidden or removed + to prove non-crashing native-view fallback. + +Checks that require Linux kernel/package tooling or GPU/EGL are CI-only. +macOS development can run all pure Node/Jest tests and static source checks, +but cannot establish Linux ELF, package-manager, sandbox, or rendering +behavior. + +## Documentation And Release Compliance + +Update `docs/architecture/embedded-mpv-native.md`, +`tools/embedded-mpv/README.md`, `vendor/embedded-mpv/README.md`, +`AGENTS.md`, and `CLAUDE.md`. The docs must describe the x64 format matrix, +profile selection, manifest/probe contract, process-isolation invariant, +fallback behavior, source-distribution obligations, codec baseline, and ARM +status. + +Release artifacts must publish the generated runtime manifest and exact source +archives/metadata required by the recorded LGPL source-distribution statement. +The libplacebo payload is a VCS-metadata-free working-tree snapshot with exact +commit/submodule records, so clone-local `.git` state cannot perturb the +compliance tar. Automated Snap publication must wait for a public `v*` release +that already contains both the Snap assets and the exact source archive. Snap +Store publication remains outside this implementation and requires its +separate release workflow; repository integration follows explicit maintainer +authorization. diff --git a/electron-builder.json b/electron-builder.json index a874298dd..fca1b100e 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -20,6 +20,7 @@ "filter": ["**/*"] }, "electron-backend/**/*", + "!electron-backend/native{,/**/*}", "web/**/*", "!**/*.map" ], @@ -130,8 +131,32 @@ "grade": "stable", "summary": "IPTV application for M3U playlists, Xtream Codes API, and Stalker portals", "executableArgs": ["--ozone-platform=x11"], + "plugs": [ + "default", + { + "graphics-core22": { + "interface": "content", + "target": "$SNAP/graphics", + "default-provider": "mesa-core22" + } + }, + { + "shared-memory": { + "interface": "shared-memory", + "private": true + } + } + ], "environment": { "DISABLE_WAYLAND": "1" + }, + "layout": { + "/usr/share/libdrm": { + "bind": "$SNAP/graphics/libdrm" + }, + "/usr/share/drirc.d": { + "symlink": "$SNAP/graphics/drirc.d" + } } }, "win": { diff --git a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts index 7c26a1253..74fd73112 100644 --- a/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts +++ b/libs/shared/interfaces/src/lib/embedded-mpv-session.interface.ts @@ -37,11 +37,16 @@ export interface EmbeddedMpvSupport { engine?: EmbeddedMpvEngine; /** * True when this machine could run the frame-copy engine (macOS arm64 - * or Linux, with the helper binary present), regardless of whether it - * is active. + * or Linux x64, after its helper/runtime capability gate), regardless of + * whether it is active. * Drives the Settings toggle; switching engines requires an app restart. */ frameCopyAvailable?: boolean; + /** + * Stable fail-closed capability reason when frameCopyAvailable is false. + * Intended for startup tracing and support diagnostics, not user copy. + */ + frameCopyUnavailableReason?: string; } /** diff --git a/package.json b/package.json index cc2cfde26..76f64db8a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "verify:package-layout": "node tools/packaging/verify-electron-package-layout.mjs", "embedded-mpv:build-native:homebrew": "IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1 nx run electron-backend:build-embedded-mpv", "embedded-mpv:build-runtime": "node tools/embedded-mpv/build-macos-runtime.mjs", + "embedded-mpv:build-runtime:linux": "node tools/embedded-mpv/build-linux-runtime.mjs", "embedded-mpv:stage-runtime": "node tools/embedded-mpv/stage-runtime.mjs", "embedded-mpv:stage-runtime:macos": "node tools/embedded-mpv/stage-macos-runtime.mjs", "embedded-mpv:stage-runtime:windows-archive": "node tools/embedded-mpv/stage-windows-runtime-archive.mjs", @@ -211,7 +212,8 @@ "tslib": "^2.8.1", "tsx": "4.21.0", "typescript": "5.9.3", - "typescript-eslint": "^8.46.2" + "typescript-eslint": "^8.46.2", + "yaml": "2.8.2" }, "pnpm": { "overrides": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c1e5b8aa..b151b699a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -447,6 +447,9 @@ importers: typescript-eslint: specifier: ^8.46.2 version: 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + yaml: + specifier: 2.8.2 + version: 2.8.2 packages: diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index a4807823b..677122365 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -1,21 +1,33 @@ # Embedded MPV Runtime -This folder contains tooling for preparing MPV runtime/build inputs for IPTVnator's experimental embedded MPV player. macOS and Windows bundle `libmpv`; Linux uses staged MPV headers for compilation and launches the system `mpv` executable at runtime. On Linux, `apps/electron-backend/build-embedded-mpv.js` also falls back to system headers when nothing is staged (`libmpv-dev`; override with `LIBMPV_INCLUDE_DIR`/`LINUX_NATIVE_LIBRARY_DIR`), so a plain distro dev setup builds without staging. The frame-copy helper (`iptvnator_mpv_helper`) additionally needs `libegl-dev`, `libgl-dev`, `libopengl-dev` (for the unversioned glvnd `libOpenGL.so` the linker resolves `-lOpenGL` against), and `libgbm-dev`, and links the system `libmpv` — allowed because it is a separate process; the in-process-libmpv ban still binds the addon. On Windows the same binding.gyp run builds `iptvnator_mpv_helper.exe` against the staged vendored runtime (import library + DLL, resolved from the helper's own directory at runtime) plus `opengl32.lib`; no toolchain beyond the MSVC workload and Windows SDK that node-gyp already requires. +This directory owns the source builders, staging, manifests, and archive +helpers for IPTVnator's experimental Embedded MPV runtime. + +The Linux architecture has a strict process boundary: + +- Electron, `embedded_mpv.node`, and `embedded_mpv_frame_reader.node` must not + load or link libmpv. +- Native-view starts a separate system `mpv --wid` process. +- Frame-copy starts `iptvnator_mpv_helper`; only that helper may link libmpv. + +Do not weaken this boundary to simplify packaging. A missing helper/runtime +must make frame-copy unavailable and leave native-view as the safe x64 +fallback. ## Runtime Policy -Release builds must use an LGPL-compatible runtime: +Release builds use an LGPL-compatible, dynamically linked runtime: -- FFmpeg must be built without `--enable-gpl` and without `--enable-nonfree`. -- mpv must be built with `-Dlibmpv=true` and `-Dgpl=false`. -- The runtime must be dynamically linked so users can inspect and replace LGPL libraries. -- The exact source URLs, versions, build flags, local patches, and checksums must be published with the release. +- FFmpeg is built without `--enable-gpl` and `--enable-nonfree`. +- mpv is built with `-Dlibmpv=true` and `-Dgpl=false`. +- Bundled libraries remain individually replaceable under `native/lib`. +- Exact source URLs, versions, checksums or git commits, submodules, licenses, + build flags, local patches, and build scripts are published with the release. -Do not ship the Homebrew `mpv` runtime. It is acceptable only for local development when `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` is set, and release packaging rejects it. +Homebrew mpv is local-development-only. It requires +`IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1`, and release validation rejects it. -## Expected Layout - -The native addon build consumes: +## Generated Layout ```text vendor/embedded-mpv/ @@ -29,126 +41,349 @@ vendor/embedded-mpv/ runtime-manifest.json win32-x64/ include/mpv/client.h - lib/libmpv-2.dll # or mpv-2.dll/mpv.dll/libmpv.dll - lib/libmpv.dll.a # or mpv.lib/mpv-2.lib + lib/libmpv-2.dll # accepted basename variants are preserved + lib/libmpv.dll.a # or an MSVC import library runtime-manifest.json linux-x64/ include/mpv/client.h + lib/libmpv.so + lib/libmpv.so.2 + lib/ + notices/embedded-mpv-notices.json + notices/THIRD_PARTY_NOTICES.txt + notices/licenses// runtime-manifest.json ``` -The generated `lib/` and `include/` directories are release inputs, not source files. They are ignored by git by default. +These directories are generated release inputs and are ignored by git. +`runtime-manifest.json` is the profile-neutral source/build manifest. Packaging +writes a normalized `embedded-mpv-runtime.json` beside the native artifacts. +Bundled Linux profiles flatten the three notice entries from `notices/` into +that same native directory; system and marker-only profiles remove them. -## Staging A Built Runtime +## Building And Staging -After building an LGPL-compatible prefix for one platform/architecture, stage it with: +Stage an existing compatible prefix with: ```bash -pnpm embedded-mpv:stage-runtime -- darwin arm64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime -- darwin x64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime -- win32 x64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime -- linux x64 /path/to/lgpl-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /path/to/prefix +pnpm embedded-mpv:stage-runtime -- darwin x64 /path/to/prefix +pnpm embedded-mpv:stage-runtime -- win32 x64 /path/to/prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /path/to/prefix ``` -For compatibility, the legacy macOS-only staging command is still available: +Build the pinned macOS or Linux source runtime first when no prefix exists: ```bash -pnpm embedded-mpv:stage-runtime:macos -- arm64 /path/to/lgpl-prefix -pnpm embedded-mpv:stage-runtime:macos -- x64 /path/to/lgpl-prefix +pnpm embedded-mpv:build-runtime -- arm64 /tmp/macos-prefix +pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/macos-prefix + +pnpm embedded-mpv:build-runtime:linux -- /tmp/linux-prefix +pnpm embedded-mpv:stage-runtime -- linux x64 /tmp/linux-prefix ``` -The prefix must contain `include/mpv/client.h` and the platform runtime/build files: +The Linux builder runs only on Linux x64. It requires the tool versions and +system development interfaces declared in `build-linux-runtime.cjs`, including +Meson 1.6 or newer, gperf 3.1 or newer, Ninja, CMake, NASM, pkg-config, +patchelf, and `readelf`. +It builds into an owned staging directory and publishes atomically, so it will +not delete or overwrite an arbitrary destination. -- macOS: `lib/libmpv.2.dylib` or `lib/libmpv.dylib` plus all non-system dylib dependencies -- Windows: `lib/mpv.lib`, `lib/mpv-2.lib`, or `libmpv.dll.a`, and `bin/` or `lib/` containing `mpv-2.dll`, `libmpv-2.dll`, `mpv.dll`, or `libmpv.dll` -- Linux: `include/mpv/client.h`; CI also records the `libmpv-dev` and `mpv` package versions used as build inputs. Linux runtime playback uses the system `mpv` executable and does not bundle `libmpv.so`. +The pinned Linux source stack currently includes FFmpeg 8.1, mpv 0.41.0, +libplacebo 7.360.1, libass 0.17.3, FreeType 2.13.3, FriBidi 1.0.16, +HarfBuzz 8.5.0, Expat 2.8.2, Fontconfig 2.16.0, OpenSSL 3.5.7, hwdata +0.409, and libdisplay-info 0.1.1. The builder stages a private pinned +`pnp.ids`/`hwdata.pc`; libdisplay-info is not allowed to consume the build +host's `/usr/share/hwdata`. -If the prefix contains `runtime-manifest.json`, the staging script copies its build metadata into the vendored manifest. At minimum, record: +Before publication, the Linux builder verifies: -- FFmpeg version, source URL, checksum, configure flags, and patches -- mpv version, source URL, checksum, Meson flags, and patches -- source-distribution URL for the corresponding release +- every archive digest and git/submodule commit; +- the exact FFmpeg/mpv flags and LGPL policy; +- an exact `libmpv.so.2` SONAME and complete reachable shared-library closure; +- `$ORIGIN` RUNPATHs with no build-prefix paths or undeclared host fallback; +- the external system-library allowlist; +- `GLIBC_2.35` and `GLIBCXX_3.4.30` ABI ceilings; +- file hashes, byte sizes, build inputs, licenses, and source obligations. -## Building The CI Runtime +## Linux Package Profiles -Tagged macOS release builds build the runtime from pinned source archives before `electron-backend:build`. The workflow can also enable this path temporarily for macOS PR artifact testing: +Set one exact `IPTVNATOR_LINUX_FRAME_COPY_PROFILE` per packaging pass: -```bash -pnpm embedded-mpv:build-runtime -- arm64 /tmp/embedded-mpv-prefix -pnpm embedded-mpv:stage-runtime -- darwin arm64 /tmp/embedded-mpv-prefix -``` +| Profile | Formats | Runtime handling | +| ---------- | ---------------- | ---------------------------------------------------------------------------- | +| `system` | DEB, RPM, Pacman | Remove `native/lib`; require the format-specific system runtime listed below | +| `portable` | AppImage, Snap | Retain the pinned LGPL closure under `native/lib` | +| `flatpak` | Flatpak | Retain the same pinned LGPL closure under `native/lib` | -Linux CI does not build libmpv from source. It installs Ubuntu runner packages -(`libmpv-dev` and `mpv`), stages their headers and build metadata under -`vendor/embedded-mpv/linux-x64/`, and requires the native addon/package layout -to be present. Linux playback does not load or bundle `libmpv` in the Electron -process; the addon creates an X11 child window and starts a system `mpv --wid` -process at runtime. +The system helper directly links libmpv, EGL, GL, and GBM. Package metadata +therefore declares the full interface set: -Windows CI does not build libmpv from source. It restores an exact-keyed cache -for `vendor/embedded-mpv/win32-x64/`; on cache miss it stages a checksum-pinned -LGPL-compatible archive from repository configuration: +- DEB: `libmpv2`, `libegl1`, `libgl1`, `libgbm1` +- RPM: `mpv-libs`, `libglvnd-egl`, `libglvnd-glx`, `mesa-libgbm` +- Pacman: `mpv`, `libglvnd`, `mesa` -```text -IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL -IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 -``` +The helper links `libGL.so.1` (`-lGL`) rather than `libOpenGL.so.0`; the +former is the direct GL interface supplied by all three system contracts and +Snap's `mesa-core22`. -The values can be repository variables or secrets. Prefer variables when PR -artifact builds from same-repository branches should include Embedded MPV. For -non-tag artifact builds only, the workflow falls back to a checksum-pinned -`zhongfly/mpv-winbuild` `mpv-dev-lgpl-x86_64` archive when those variables are -unset. Tagged release builds must provide the repository configuration -explicitly. +The DEB metadata is release-tested on Ubuntu 24.04 (Noble). Ubuntu 22.04 +(Jammy) only provides `libmpv1`; use the x64 AppImage on that distribution +rather than relaxing the runtime contract. CI explicitly installs the distro +Mesa software renderer for headless smoke. IPTVnator does not add DRI-driver +packages as direct dependencies; any transitive graphics-driver stack remains +under the distro's dependency policy. -The Windows job is pinned to `windows-2022` while the current Electron -`node-gyp` toolchain cannot identify Visual Studio 18 from `windows-latest`. +The Snap is `base: core22` with strict confinement. It retains Electron +Builder's default plugs and adds an auto-connected private `shared-memory` +plug plus `graphics-core22`, targeting a real empty mode-0755 `$SNAP/graphics` +with external `mesa-core22` as default provider. The graphics provider supplies +EGL/GL/GLX/GBM/DRM/VA, while Electron Builder's exact GNOME content runtime +supplies ALSA/PulseAudio. Neither provider is bundled into IPTVnator's Snap, +source archive, notices, or package-size accounting. The package hook creates +the empty content target because core22 does not synthesize one; the extracted +artifact verifier rejects a missing, redirected, non-empty, or wrongly +permissioned target. Snap metadata must also contain exactly the canonical +graphics-provider layouts: bind `/usr/share/libdrm` from +`$SNAP/graphics/libdrm`, and symlink `/usr/share/drirc.d` to +`$SNAP/graphics/drirc.d`. -The archive must contain a Windows x64 prefix with `include/mpv/client.h`, a -libmpv import library, and `mpv-2.dll`/`mpv.dll` or -`libmpv-2.dll`/`libmpv.dll`. The archive can use either the normal prefix layout -(`lib/` and `bin/`) or the common `mpv-dev-lgpl` flat layout with the import -library and DLL in the archive root. The staged runtime preserves the DLL -basename from the archive because Windows import libraries encode the DLL name -that native binaries must load at runtime. Package validation reads the -frame-copy helper's PE imports and requires that exact DLL basename beside -`iptvnator_mpv_helper.exe`; a different accepted MPV DLL name or a copy only -under `native/lib/` is not sufficient. If -`runtime-manifest.json` is missing, CI generates a minimal manifest from the -archive URL/path and checksum; release-ready runtime archives should still -provide full source/build metadata. +The bounded probe and every playback helper share one sanitized loader +environment derived from the validated, cached runtime mode. Ambient +ELF audit/preload/origin/library overrides, direct EGL/GBM/GL/VA/Vulkan paths, +shell startup/options, tracing hooks, exported Bash functions, and +caller-provided architecture triplets are removed or replaced. The +extracted-artifact verifier uses the same deny-set for its direct helper smoke +and preserves feature/debug selectors such as `LIBGL_ALWAYS_SOFTWARE`. System +packages then use the default loader; bundled packages put their validated +`native/lib` first. Packaged addon/helper lookup is package-owned +`app.asar.unpacked` only; cwd/dist candidates are development-only. +AppImage and Flatpak use normal host/sandbox lookup for the declared external +interfaces. Inside the exact packaged Flatpak `/app` context, the helper +reconstructs only Freedesktop Platform 24.08's immutable +`__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS` value; the GL extension's +`add-ld-path` remains available through the sandbox loader cache. Flatpak CI +therefore invokes `flatpak run com.fourgray.iptvnator +--embedded-mpv-runtime-probe` instead of executing the helper around the +application gate. In a genuine Snap mount, filtered `SNAP_LIBRARY_PATH` GL roots +under `/var/lib/snapd/lib/gl` come next, then the fixed x64 +`$SNAP/graphics` roots, then the core22 base +`/usr/lib/x86_64-linux-gnu`, exact `$SNAP/gnome-platform` graphics/audio roots, +and finally generic `$SNAP` library roots. Keeping the base ABI ahead of the +older GNOME content runtime prevents its `libedit.so.2` from injecting an +unavailable `libtinfo.so.5` dependency into mesa-core22's software renderer. +The helper rebuilds GBM, GL/VA driver, EGL vendor/platform, and Vulkan layer +variables from those trusted locations. A Linux session without the validated +cached mode is rejected before spawn. +Both the bounded probe and playback execute through +`$SNAP/graphics/bin/graphics-core22-provider-wrapper`. The graphics mount must +be a real directory and the wrapper a regular, non-symlinked, readable +executable. Otherwise the gate returns the stable +`snap-graphics-provider-unavailable` reason before spawning the helper. The +wrapper child also drops shell startup/options, tracing hooks, and exported +`BASH_FUNC_*` functions, and uses a fixed core22 system `PATH`; ambient Bash +configuration therefore cannot replace the probe before helper execution. -During temporary PR and `master` artifact testing, CI restores an exact-keyed GitHub Actions cache for the staged `vendor/embedded-mpv/-/` runtime before falling back to the macOS source build or Windows runtime archive where available. The cache key includes the target platform, architecture, macOS deployment target, Xcode version when available, a hash of the Windows runtime checksum when applicable, and hashes of the runtime build/staging scripts. Cache entries are saved only from trusted repository refs and are treated strictly as a speed optimization; tagged macOS release builds continue to rebuild from pinned sources unless a future signed and attested runtime artifact flow is introduced. +Installed-Snap CI disconnects `graphics-core22`, requires the application-level +diagnostic to emit `snap-graphics-provider-unavailable` and exit with the +controlled status `1`, then reconnects the provider and requires a successful +diagnostic. This keeps the canonical layouts and missing-provider fallback in +the same regression contract. -The builder currently pins: +Profiles cannot share one Electron Builder pass because its targets reuse the +same unpacked application directory. A missing or unsupported profile, or a +target from another profile, fails packaging. -- FFmpeg `8.1`, configured without `--enable-gpl` or `--enable-nonfree`, and with autodetected external libraries disabled -- mpv `0.41.0`, configured with `-Dlibmpv=true -Dgpl=false` -- libplacebo `7.360.1`, checked out from git with the `glad`, Python template, `fast_float`, and `Vulkan-Headers` submodules required by its Meson build -- libass `0.17.3` plus FreeType, FriBidi, and HarfBuzz - -The build manifest records source URLs, downloaded archive SHA-256 values where applicable, libplacebo git commit/submodule metadata, and the exact FFmpeg/mpv flags. The staged macOS/Windows manifest is normalized to `origin: vendored-lgpl`, which is the only embedded MPV runtime origin allowed in required macOS/Windows release packaging. +Linux frame-copy release artifacts are x64-only. Non-x64 packages are always +marker-only even if environment variables point at the x64 staged runtime. ## Build Integration -`apps/electron-backend/build-embedded-mpv.js` builds the native addon against the staged runtime/build inputs, copies macOS/Windows runtime libraries into `apps/electron-backend/native/build/Release/lib/`, rewrites macOS Mach-O paths to `@loader_path`, and writes `embedded-mpv-runtime.json`. Linux builds use the staged (or system) MPV headers and system X11 development libraries, write an `external-mpv-process` manifest, and the addon must not copy or link directly to `libmpv`; CI validates this with package checks and `ldd`. The frame-copy helper executable built by the same run is the inverse: CI verifies it DOES link `libmpv` (separate process). +`apps/electron-backend/build-embedded-mpv.js` builds the addon, frame reader, +and helper against the staged inputs. On Linux it links the helper to the +verified staged libmpv path rather than a generic host `-lmpv`, then checks +with `readelf` that: -For local macOS development with Homebrew `mpv`, use: +- the helper has exactly the declared libmpv `DT_NEEDED`; +- the helper RUNPATH is `$ORIGIN/lib`; +- the addon and frame reader have no libmpv dependency; +- no runtime dependency contains an absolute/build-prefix loader path. + +The package hook copies native artifacts into +`app.asar.unpacked/electron-backend/native/`, selects the system or bundled +layout, restores exact file modes, writes the packaged manifest, and validates +the bundled legal payload. AppImage, Snap, and Flatpak receive +`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and +`licenses//**`; DEB, RPM, Pacman, and marker-only packages must not +retain them. Package validation also scans the Electron executable and all +shipped Electron libraries for a direct libmpv dependency. Before target +packaging, that scan is recursive over the pristine Electron tree. After Snap +has merged its template runtime into the payload root, the post-target scan +excludes exactly its package-manager `lib/**` and `usr/lib/**` trees while +remaining recursive everywhere else. + +At startup, Linux x64 frame-copy is advertised only after the main process +validates that manifest/files and successfully executes: + +```bash +iptvnator_mpv_helper --runtime-probe +``` + +The bounded probe initializes idle libmpv plus EGL/OpenGL and mpv render +contexts, then creates, maps, validates, and destroys a minimal `16x16` +shared-memory ring named `/impv-fc-runtime-probe-`. It does not open media +or enter media/command loops. A timeout, loader failure, malformed protocol, +missing file, hash mismatch, unusable graphics path, or shm lifecycle failure +returns a stable reason and keeps the BrowserWindow sandbox enabled. The +application diagnostic retains `helper-probe-failed` as the top-level reason +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 +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. +Empty captures, disabled tracing, and trace-writer failures do not emit a +record or alter availability. The installed-Snap probe therefore tests the +private shared-memory confinement +needed by playback rather than only loader and graphics startup. +Packaging CI invokes the same gate through +`snap run iptvnator --embedded-mpv-runtime-probe`. This packaging-only +application switch runs before BrowserWindow startup, emits one availability +JSON line, and returns zero only for a usable runtime; it never directly loads +libmpv in Electron. The installed-Snap smoke adds `EGL_LOG_LEVEL=debug` and +`LIBGL_DEBUG=verbose` under that bounded trace channel to expose GLVND/Mesa +loader failures without weakening the hostile-environment gate. + +Electron Builder excludes `electron-backend/native{,/**/*}` from `app.asar`. +Only `afterPack` writes the profile-normalized +`app.asar.unpacked/electron-backend/native` tree. Layout and final-artifact +verification enumerate `app.asar` and reject any stale native entry, preventing +hidden x64 helpers, bundled libraries, or notices in system and marker-only +packages. + +## CI And Source Distribution + +Linux CI builds or restores the pinned source runtime once, then packages and +verifies `system`, `portable`, and `flatpak` independently. Every artifact is +extracted for manifest, mode, package-metadata, ELF-isolation, and helper-probe +checks. System formats are probed after their declared dependency is installed; +Snap and Flatpak also require a sandboxed probe where the runner supports it. +For a locally installed `--dangerous` Snap, CI explicitly installs and +connects `mesa-core22` and `gnome-3-28-1804`, verifies both connections, and +then runs the application-level diagnostic under Xvfb. +The Linux packaging matrix alone depends on the runtime-builder job. macOS and +Windows use an independent matrix, while both matrices share the same anchored +step list; draft release assembly remains atomic and requires both matrices. + +The Linux runtime cache contains only staged headers/libraries/manifest plus +immutable source inputs: exact downloaded archives (including hwdata), a clean +recursive libplacebo checkout, and collected license files. It never caches +finished notices or the compliance tarball. After either a build or cache hit, +CI revalidates those inputs, regenerates `vendor/embedded-mpv/linux-x64/notices` +for the current runtime manifest, and creates +`linux-frame-copy-runtime-sources.tar.xz` for the current repository +revision/diff. Before archiving, the clean cached libplacebo checkout is +converted into a non-dereferenced working-tree snapshot with every `.git` +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 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. +Submodule identity is canonicalized as `full-commit safe/path`; optional +clone-depth-dependent `git describe` annotations are discarded. +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 +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. +The public-release boundary also reapplies the exact strict +`meta/snap.yaml` graphics/shared-memory/layout contract and enumerates the +extracted `resources/app.asar`, rejecting any archived +`electron-backend/native/**` payload. Its bounded ASAR header reader depends +only on Node built-ins and released local tooling, so verification remains +runnable in the clean tag checkout without `node_modules`. +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 +`iptvnator_mpv_helper.exe`. Tagged releases require explicit repository +configuration; the public fallback is for non-tag artifacts only. The upstream +keeps only its latest 30 daily builds, so the fallback URL and checksum plus any +matching repository variables must be refreshed as one pair before they age +out. A permanent mirror must publish the corresponding source/build records and +license notices with the binary. + +## Local Development + +Linux can use distribution development packages for an unshipped local build +(`libmpv-dev`, EGL/GL/GBM development files, and X11 headers). Overrides: +`LIBMPV_INCLUDE_DIR` selects the header root. `LINUX_NATIVE_LIBRARY_DIR` +selects a link-time library directory that must already be visible to the +system dynamic loader; it is not inherited as a helper `LD_LIBRARY_PATH`. +Required/release package builds must use the pinned staged runtime and manifest. + +On macOS: ```bash pnpm run serve:backend:embedded-mpv ``` -The script rebuilds the native addon with `IPTVNATOR_EMBEDDED_MPV_ALLOW_HOMEBREW=1` before starting Electron with the experimental player enabled. Use this only for local testing; release packaging rejects the resulting `homebrew-dev` runtime manifest. +This explicitly permits Homebrew for the local native build and enables the +experiment. It is not a release path. -The `afterPack` hook copies `dist/apps/electron-backend/native/` into `app.asar.unpacked/electron-backend/native/` so the addon, runtime manifest, and runtime libraries are available as real files where needed. Linux packages include the addon and manifest, but no bundled `libmpv.so`, and the hook strips `iptvnator_mpv_helper` from Linux packages (it links the build host's system libmpv; the frame-copy engine stays dev-build-only on Linux until bundled-runtime staging lands). - -During release packaging, `tools/packaging/electron-after-pack.cjs` verifies that macOS/Windows packages use a `vendored-lgpl` runtime/build input set. macOS artifacts additionally verify that Mach-O dependencies have no `/opt/homebrew` or `/usr/local` dynamic links for embedded MPV. Linux artifacts verify that the addon and `external-mpv-process` manifest are present, that no bundled `libmpv.so` files are present, and the runtime support check verifies that `mpv` is available on `PATH`. - -Set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` when packaging a release artifact that must include Embedded MPV. The same variable is temporarily enabled for macOS PR and `master` push artifacts while the bundled runtime is being tested. Linux CI packaging requires Embedded MPV after staging the Ubuntu package build inputs. Windows CI packaging now requires Embedded MPV for x64 artifacts: the job restores the staged runtime cache or stages the checksum-pinned runtime archive, then fails backend build, package make, or package-layout verification if the addon/runtime is missing. - -## Platform Notes - -- macOS keeps the existing libmpv render-context backend because mpv `wid` stays black inside Electron on macOS. -- Windows uses an embedded child `HWND` and passes it to mpv through `wid`. The experimental frame-copy engine instead renders offscreen through WGL into the app canvas (no child window) and shares frames over a session-local named file mapping. -- Linux uses an X11 child window and starts a system `mpv --wid` process for that window. Native Wayland is not supported in v1; run under X11/Xwayland so `DISPLAY` is set and `mpv` can honor the X11 window id. The experimental frame-copy engine has no window embedding at all (offscreen EGL into a renderer canvas) and therefore works under native Wayland — dev builds only for now. +See `docs/architecture/embedded-mpv-native.md` for the runtime capability, +fallback, controls, and packaged-release contracts. diff --git a/tools/embedded-mpv/build-linux-runtime.cjs b/tools/embedded-mpv/build-linux-runtime.cjs new file mode 100644 index 000000000..a8b4d370c --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.cjs @@ -0,0 +1,1693 @@ +'use strict'; + +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', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +]); + +const SOURCE_PACKAGES = Object.freeze( + [ + { + id: 'freetype', + version: '2.13.3', + sourceKind: 'archive', + sourceUrl: + 'https://download.savannah.gnu.org/releases/freetype/freetype-2.13.3.tar.xz', + expectedSha256: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + license: 'FreeType License (FTL)', + }, + { + id: 'fribidi', + version: '1.0.16', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/fribidi/fribidi/releases/download/v1.0.16/fribidi-1.0.16.tar.xz', + expectedSha256: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + license: 'LGPL-2.1-or-later', + }, + { + id: 'harfbuzz', + version: '8.5.0', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/harfbuzz/harfbuzz/releases/download/8.5.0/harfbuzz-8.5.0.tar.xz', + expectedSha256: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + license: 'MIT', + }, + { + id: 'expat', + version: '2.8.2', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz', + expectedSha256: + '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + license: 'MIT', + }, + { + id: 'fontconfig', + version: '2.16.0', + sourceKind: 'archive', + sourceUrl: + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz', + expectedSha256: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + license: 'MIT', + }, + { + id: 'libass', + version: '0.17.3', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/libass/libass/releases/download/0.17.3/libass-0.17.3.tar.xz', + expectedSha256: + 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + license: 'ISC', + }, + { + id: 'openssl', + version: '3.5.7', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz', + expectedSha256: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + license: 'Apache-2.0', + }, + { + id: 'ffmpeg', + version: '8.1', + sourceKind: 'archive', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + expectedSha256: + 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + license: 'LGPL-2.1-or-later', + }, + { + id: 'libplacebo', + version: '7.360.1', + sourceKind: 'git', + 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', + }, + { + id: 'hwdata', + version: '0.409', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + expectedSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + license: 'GPL-2.0-or-later OR XFree86-1.0', + buildInput: HWDATA_BUILD_INPUT, + }, + { + id: 'libdisplay-info', + version: '0.1.1', + sourceKind: 'archive', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + expectedSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, + { + id: 'mpv', + version: '0.41.0', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + expectedSha256: + 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + license: 'LGPL-2.1-or-later with -Dgpl=false', + }, + ].map((sourcePackage) => Object.freeze(sourcePackage)) +); + +const BUILD_ORDER = Object.freeze( + SOURCE_PACKAGES.map((sourcePackage) => sourcePackage.id) +); + +const FFMPEG_CONFIGURE_FLAGS = Object.freeze([ + '--enable-shared', + '--disable-static', + '--disable-programs', + '--disable-doc', + '--disable-debug', + '--disable-autodetect', + '--disable-gpl', + '--disable-nonfree', + '--disable-version3', + '--enable-pic', + '--enable-pthreads', + '--enable-openssl', + '--disable-gnutls', + '--disable-mbedtls', + '--disable-libtls', + '--enable-network', + '--disable-protocols', + '--enable-protocol=file', + '--enable-protocol=http', + '--enable-protocol=https', + '--enable-protocol=httpproxy', + '--enable-protocol=tcp', + '--enable-protocol=tls', + '--enable-protocol=udp', + '--enable-protocol=crypto', + '--enable-protocol=data', + '--enable-demuxer=hls', + '--enable-vaapi', + '--disable-vdpau', + '--disable-vulkan', + '--disable-libdrm', + '--disable-cuda-llvm', + '--disable-cuvid', + '--disable-nvdec', + '--disable-nvenc', + '--disable-xlib', + '--disable-sdl2', + '--disable-openal', +]); + +const MPV_MESON_FLAGS = Object.freeze([ + '-Dgpl=false', + '-Dlibmpv=true', + '-Dcplayer=false', + '-Dbuild-date=false', + '-Dtests=false', + '-Dfuzzers=false', + '-Ddisable-packet-pool=false', + '-Dcdda=disabled', + '-Dcplugins=disabled', + '-Ddvbin=disabled', + '-Ddvdnav=disabled', + '-Diconv=enabled', + '-Djavascript=disabled', + '-Djpeg=disabled', + '-Dlcms2=disabled', + '-Dlibarchive=disabled', + '-Dlibavdevice=disabled', + '-Dlibbluray=disabled', + '-Dlua=disabled', + '-Dpthread-debug=disabled', + '-Drubberband=disabled', + '-Dsdl2-gamepad=disabled', + '-Duchardet=disabled', + '-Duwp=disabled', + '-Dvapoursynth=disabled', + '-Dvector=enabled', + '-Dwin32-threads=disabled', + '-Dx11-clipboard=disabled', + '-Dzimg=disabled', + '-Dzlib=disabled', + '-Dalsa=enabled', + '-Daudiounit=disabled', + '-Dcoreaudio=disabled', + '-Davfoundation=disabled', + '-Djack=disabled', + '-Dopenal=disabled', + '-Daudiotrack=disabled', + '-Daaudio=disabled', + '-Dopensles=disabled', + '-Doss-audio=disabled', + '-Dpipewire=disabled', + '-Dpulse=enabled', + '-Dsdl2-audio=disabled', + '-Dsndio=disabled', + '-Dwasapi=disabled', + '-Dcaca=disabled', + '-Dcocoa=disabled', + '-Dd3d11=disabled', + '-Ddirect3d=disabled', + '-Ddmabuf-wayland=disabled', + '-Ddrm=enabled', + '-Degl=enabled', + '-Degl-android=disabled', + '-Degl-angle=disabled', + '-Degl-angle-lib=disabled', + '-Degl-angle-win32=disabled', + '-Degl-drm=disabled', + '-Degl-wayland=disabled', + '-Degl-x11=disabled', + '-Dgbm=enabled', + '-Dgl=enabled', + '-Dgl-cocoa=disabled', + '-Dgl-dxinterop=disabled', + '-Dgl-win32=disabled', + '-Dgl-x11=disabled', + '-Dsdl2-video=disabled', + '-Dshaderc=disabled', + '-Dsixel=disabled', + '-Dspirv-cross=disabled', + '-Dplain-gl=enabled', + '-Dvdpau=disabled', + '-Dvdpau-gl-x11=disabled', + '-Dvaapi=enabled', + '-Dvaapi-drm=enabled', + '-Dvaapi-wayland=disabled', + '-Dvaapi-win32=disabled', + '-Dvaapi-x11=disabled', + '-Dvulkan=disabled', + '-Dwayland=disabled', + '-Dx11=disabled', + '-Dxv=disabled', + '-Dandroid-media-ndk=disabled', + '-Dcuda-hwaccel=disabled', + '-Dcuda-interop=disabled', + '-Dd3d-hwaccel=disabled', + '-Dd3d9-hwaccel=disabled', + '-Dgl-dxinterop-d3d9=disabled', + '-Dios-gl=disabled', + '-Dvideotoolbox-gl=disabled', + '-Dvideotoolbox-pl=disabled', + '-Dmacos-10-15-4-features=disabled', + '-Dmacos-11-features=disabled', + '-Dmacos-11-3-features=disabled', + '-Dmacos-12-features=disabled', + '-Dmacos-cocoa-cb=disabled', + '-Dmacos-media-player=disabled', + '-Dmacos-touchbar=disabled', + '-Dswift-build=disabled', + '-Dwin32-smtc=disabled', + '-Dhtml-build=disabled', + '-Dmanpage-build=disabled', + '-Dpdf-build=disabled', +]); + +const BUILD_RECIPES = Object.freeze({ + freetype: Object.freeze({ + buildSystem: 'configure', + sharedOnly: true, + args: Object.freeze([ + '--enable-shared', + '--disable-static', + '--without-brotli', + '--without-bzip2', + '--without-harfbuzz', + '--without-png', + '--without-zlib', + ]), + }), + fribidi: Object.freeze({ + buildSystem: 'configure', + sharedOnly: true, + args: Object.freeze([ + '--enable-shared', + '--disable-static', + '--disable-docs', + '--disable-bin', + ]), + }), + harfbuzz: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([ + '-Dglib=disabled', + '-Dgobject=disabled', + '-Dcairo=disabled', + '-Dchafa=disabled', + '-Dicu=disabled', + '-Dfreetype=enabled', + '-Dtests=disabled', + '-Dintrospection=disabled', + '-Ddocs=disabled', + '-Dutilities=disabled', + '-Dbenchmark=disabled', + ]), + }), + expat: Object.freeze({ + buildSystem: 'cmake', + sharedOnly: true, + args: Object.freeze([ + '-DEXPAT_SHARED_LIBS=ON', + '-DEXPAT_BUILD_TOOLS=OFF', + '-DEXPAT_BUILD_EXAMPLES=OFF', + '-DEXPAT_BUILD_TESTS=OFF', + '-DEXPAT_BUILD_DOCS=OFF', + ]), + }), + fontconfig: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([ + '-Ddoc=disabled', + '-Dtests=disabled', + '-Dtools=disabled', + '-Dcache-build=disabled', + '-Dnls=disabled', + '-Dxml-backend=expat', + '-Dbaseconfig-dir=/etc/fonts', + '-Dconfig-dir=/etc/fonts/conf.d', + '-Dtemplate-dir=/usr/share/fontconfig/conf.avail', + '-Dcache-dir=/var/cache/fontconfig', + '-Dxml-dir=/usr/share/xml/fontconfig', + ]), + }), + libass: Object.freeze({ + buildSystem: 'configure', + sharedOnly: true, + args: Object.freeze([ + '--enable-shared', + '--disable-static', + '--enable-fontconfig', + '--disable-coretext', + '--disable-directwrite', + '--disable-libunibreak', + ]), + }), + openssl: Object.freeze({ + buildSystem: 'openssl', + sharedOnly: true, + args: Object.freeze([ + 'shared', + 'no-apps', + 'no-docs', + 'no-tests', + 'no-engine', + 'no-legacy', + 'no-module', + 'no-weak-ssl-ciphers', + '--openssldir=/etc/ssl', + ]), + }), + ffmpeg: Object.freeze({ + buildSystem: 'ffmpeg', + sharedOnly: true, + args: FFMPEG_CONFIGURE_FLAGS, + }), + libplacebo: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([ + '-Dopengl=enabled', + '-Dvulkan=disabled', + '-Dvk-proc-addr=disabled', + '-Dglslang=disabled', + '-Dshaderc=disabled', + '-Dlcms=disabled', + '-Ddovi=disabled', + '-Dlibdovi=disabled', + '-Ddemos=false', + '-Dtests=false', + '-Dbench=false', + '-Dfuzz=false', + '-Dunwind=disabled', + '-Dxxhash=disabled', + ]), + }), + hwdata: Object.freeze({ + buildSystem: 'data', + sharedOnly: false, + args: Object.freeze([]), + }), + 'libdisplay-info': Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([]), + }), + mpv: Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: MPV_MESON_FLAGS, + }), +}); + +const REQUIRED_TOOLS = Object.freeze([ + 'cc', + 'cmake', + 'curl', + 'git', + 'gperf', + 'make', + 'meson', + 'nasm', + 'ninja', + 'patchelf', + 'perl', + 'pkg-config', + 'python3', + 'readelf', + 'tar', +]); + +const MINIMUM_TOOL_VERSIONS = Object.freeze({ + cc: '9.0.0', + cmake: '3.16.0', + curl: '7.71.0', + git: '2.30.0', + gperf: '3.1.0', + make: '4.0.0', + meson: '1.6.0', + nasm: '2.15.05', + ninja: '1.10.0', + patchelf: '0.14.0', + perl: '5.30.0', + 'pkg-config': '0.29.0', + python3: '3.8.0', + readelf: '2.35.0', + tar: '1.30.0', +}); + +const DEFAULT_SYSTEM_PKG_CONFIG_DIRS = Object.freeze([ + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/lib64/pkgconfig', + '/usr/lib/pkgconfig', + '/usr/share/pkgconfig', +]); + +const EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES = Object.freeze([ + 'alsa', + 'egl', + 'gbm', + 'gl', + 'libdrm', + 'libpulse', + 'libva', + 'libva-drm', +]); + +const GLIBC_TOOLCHAIN_ALLOWLIST = Object.freeze([ + 'ld-linux-x86-64.so.2', + 'libc.so.6', + 'libdl.so.2', + 'libgcc_s.so.1', + 'libm.so.6', + 'libpthread.so.0', + 'librt.so.1', + 'libstdc++.so.6', +]); + +const EXTERNAL_SYSTEM_LIBRARIES = Object.freeze( + [ + { + name: 'libEGL.so.1', + interface: 'EGL', + reason: 'System graphics-driver interface used by the frame-copy helper.', + }, + { + name: 'libGL.so.1', + interface: 'OpenGL', + reason: 'System OpenGL compatibility interface supplied by the graphics stack.', + }, + { + name: 'libGLX.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL dispatch interface supplied by the graphics stack.', + }, + { + name: 'libOpenGL.so.0', + interface: 'OpenGL', + reason: 'GLVND OpenGL interface supplied by the graphics stack.', + }, + { + name: 'libasound.so.2', + interface: 'ALSA', + reason: 'Linux system audio interface intentionally used by libmpv.', + }, + { + name: 'libdrm.so.2', + interface: 'DRM', + reason: 'Kernel graphics interface used by system GBM and VA-API drivers.', + }, + { + name: 'libgbm.so.1', + interface: 'GBM', + reason: 'System graphics-buffer interface used by headless EGL rendering.', + }, + { + name: 'libpulse.so.0', + interface: 'PulseAudio', + reason: 'Linux desktop audio interface intentionally used by libmpv.', + }, + { + name: 'libva-drm.so.2', + interface: 'VA-API DRM', + reason: 'System VA-API DRM interface used for hardware decoding.', + }, + { + name: 'libva.so.2', + interface: 'VA-API', + reason: 'System video-acceleration interface used for hardware decoding.', + }, + ].map((externalLibrary) => Object.freeze(externalLibrary)) +); + +const RUNTIME_EXTERNAL_CONFIGURATION = Object.freeze({ + fontconfig: Object.freeze({ + configDirectory: '/etc/fonts', + templateDirectory: '/usr/share/fontconfig', + cacheDirectory: '/var/cache/fontconfig', + ownership: 'system', + }), + openssl: Object.freeze({ + configFile: '/etc/ssl/openssl.cnf', + certificateFile: '/etc/ssl/cert.pem', + certificateDirectory: '/etc/ssl/certs', + ownership: 'system', + }), +}); + +const OUTPUT_OWNERSHIP_MARKER = '.iptvnator-linux-runtime-owner'; +const OUTPUT_OWNERSHIP_MARKER_CONTENT = + 'iptvnator-embedded-mpv-linux-runtime-v1\n'; + +const PORTABLE_ABI_BASELINE = Object.freeze({ + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', +}); + +const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const externalSystemLibraryNames = new Set( + EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name) +); +const allowedExternalLibraryNames = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...externalSystemLibraryNames, +]); + +function assertArchiveMatchesPin(sourcePackage, actualSha256) { + if (actualSha256 !== sourcePackage.expectedSha256) { + throw new Error( + `${sourcePackage.id} archive SHA-256 mismatch: expected ${sourcePackage.expectedSha256}, received ${actualSha256}.` + ); + } +} + +function assertGitCommitMatchesPin(sourcePackage, actualGitCommit) { + if (actualGitCommit !== sourcePackage.expectedGitCommit) { + throw new Error( + `${sourcePackage.id} git commit mismatch: expected ${sourcePackage.expectedGitCommit}, received ${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 canonicalizeGitSubmoduleStatus(output) { + if (typeof output !== 'string') { + throw new Error('Git submodule status output must be a string.'); + } + if (output.trim() === '') { + return []; + } + + const records = []; + const seenRecords = new Set(); + const seenPaths = new Set(); + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line === '') { + continue; + } + if (/^[-+U]/.test(line)) { + throw new Error( + `Git submodule checkout is not clean: ${rawLine.trimEnd()}` + ); + } + const match = line.match( + /^([a-f0-9]{40,64})\s+([A-Za-z0-9_+./-]+)(?:\s+\([^\r\n]*\))?$/ + ); + if (!match) { + throw new Error( + `Git submodule status contains an unsafe or malformed record: ${rawLine.trimEnd()}` + ); + } + const submodulePath = match[2]; + if ( + path.posix.isAbsolute(submodulePath) || + submodulePath + .split('/') + .some( + (segment) => + segment === '' || segment === '.' || segment === '..' + ) + ) { + throw new Error( + `Git submodule status contains an unsafe path: ${submodulePath}` + ); + } + const record = `${match[1]} ${submodulePath}`; + if (seenRecords.has(record) || seenPaths.has(submodulePath)) { + throw new Error( + `Git submodule status contains a duplicate record: ${record}` + ); + } + seenRecords.add(record); + seenPaths.add(submodulePath); + records.push(record); + } + return records; +} + +function parseVersion(value) { + if (typeof value !== 'string') { + return null; + } + const match = value.match(/\b(\d+\.\d+(?:\.\d+)*)\b/); + return match?.[1] ?? null; +} + +function compareVersions(left, right) { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return Math.sign(difference); + } + } + return 0; +} + +function assertMinimumToolVersions(toolVersions) { + for (const tool of REQUIRED_TOOLS) { + const declaredVersion = toolVersions?.[tool]; + if (typeof declaredVersion !== 'string' || !declaredVersion.trim()) { + throw new Error(`Missing required tool version for ${tool}.`); + } + const actualVersion = parseVersion(declaredVersion); + if (!actualVersion) { + throw new Error( + `Unable to parse required tool version for ${tool}: ${declaredVersion}.` + ); + } + const minimumVersion = MINIMUM_TOOL_VERSIONS[tool]; + if (compareVersions(actualVersion, minimumVersion) < 0) { + throw new Error( + `${tool} ${actualVersion} is unsupported; ${tool} requires ${minimumVersion} or newer for Linux runtime builds.` + ); + } + } +} + +function assertUniqueMesonOptionAssignments(buildRecipes) { + if (!buildRecipes || typeof buildRecipes !== 'object') { + throw new TypeError('Linux runtime build recipes must be an object.'); + } + for (const [packageId, recipe] of Object.entries(buildRecipes)) { + if (recipe?.buildSystem !== 'meson') { + continue; + } + if (!Array.isArray(recipe.args)) { + throw new Error( + `${packageId} Meson recipe must declare an argument array.` + ); + } + + const assignmentsByOption = new Map(); + for (const flag of recipe.args) { + const match = + typeof flag === 'string' ? flag.match(/^(-D[^=]+)=/) : null; + if (!match) { + continue; + } + const option = match[1]; + const assignmentCount = (assignmentsByOption.get(option) ?? 0) + 1; + assignmentsByOption.set(option, assignmentCount); + if (assignmentCount > 1) { + throw new Error( + `${packageId} Meson recipe must assign ${option} exactly once.` + ); + } + } + } +} + +function lstatIfExists(fileSystem, filePath) { + try { + return fileSystem.lstatSync(filePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function assertOwnedOutputDestination(outputPrefix, fileSystem = fs) { + const outputStat = lstatIfExists(fileSystem, outputPrefix); + if (!outputStat) { + return; + } + if (!outputStat.isDirectory() || outputStat.isSymbolicLink()) { + throw new Error( + `Existing output ${outputPrefix} must be a non-symbolic-link directory carrying the IPTVnator ownership marker.` + ); + } + + const markerPath = path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER); + const markerStat = lstatIfExists(fileSystem, markerPath); + if ( + !markerStat || + !markerStat.isFile() || + markerStat.isSymbolicLink() || + fileSystem.readFileSync(markerPath, 'utf8') !== + OUTPUT_OWNERSHIP_MARKER_CONTENT + ) { + throw new Error( + `Existing output ${outputPrefix} is missing the valid IPTVnator ownership marker.` + ); + } +} + +function ownedStagingPrefixPath(outputPrefix, token) { + return path.join( + path.dirname(outputPrefix), + `.${path.basename(outputPrefix)}.iptvnator-stage-${token}` + ); +} + +function createOwnedStagingPrefix( + outputPrefix, + { fileSystem = fs, token = crypto.randomBytes(8).toString('hex') } = {} +) { + assertOwnedOutputDestination(outputPrefix, fileSystem); + const outputParent = path.dirname(outputPrefix); + const stagingPrefix = ownedStagingPrefixPath(outputPrefix, token); + if (lstatIfExists(fileSystem, stagingPrefix)) { + throw new Error( + `Refusing to reuse existing Linux runtime staging path ${stagingPrefix}.` + ); + } + + fileSystem.mkdirSync(outputParent, { recursive: true }); + fileSystem.mkdirSync(stagingPrefix); + try { + fileSystem.writeFileSync( + path.join(stagingPrefix, OUTPUT_OWNERSHIP_MARKER), + OUTPUT_OWNERSHIP_MARKER_CONTENT, + { mode: 0o644 } + ); + } catch (error) { + fileSystem.rmSync(stagingPrefix, { recursive: true, force: true }); + throw error; + } + return stagingPrefix; +} + +function publishOwnedOutput({ + outputPrefix, + stagingPrefix, + fileSystem = fs, + token = crypto.randomBytes(8).toString('hex'), +}) { + assertOwnedOutputDestination(outputPrefix, fileSystem); + assertOwnedOutputDestination(stagingPrefix, fileSystem); + if (!lstatIfExists(fileSystem, stagingPrefix)) { + throw new Error( + `Linux runtime staging prefix does not exist: ${stagingPrefix}.` + ); + } + + const backupPrefix = path.join( + path.dirname(outputPrefix), + `.${path.basename(outputPrefix)}.iptvnator-backup-${token}` + ); + if (lstatIfExists(fileSystem, backupPrefix)) { + throw new Error( + `Refusing to reuse existing Linux runtime backup path ${backupPrefix}.` + ); + } + + let movedPreviousOutput = false; + let published = false; + try { + if (lstatIfExists(fileSystem, outputPrefix)) { + fileSystem.renameSync(outputPrefix, backupPrefix); + movedPreviousOutput = true; + } + fileSystem.renameSync(stagingPrefix, outputPrefix); + published = true; + if (movedPreviousOutput) { + fileSystem.rmSync(backupPrefix, { + recursive: true, + force: true, + }); + } + } catch (error) { + if ( + movedPreviousOutput && + !lstatIfExists(fileSystem, outputPrefix) && + lstatIfExists(fileSystem, backupPrefix) + ) { + fileSystem.renameSync(backupPrefix, outputPrefix); + } + throw error; + } finally { + if (lstatIfExists(fileSystem, stagingPrefix)) { + fileSystem.rmSync(stagingPrefix, { + recursive: true, + force: true, + }); + } + if (published && lstatIfExists(fileSystem, backupPrefix)) { + fileSystem.rmSync(backupPrefix, { + recursive: true, + force: true, + }); + } + } +} + +function joinEnvironmentParts(parts, separator = ' ') { + return parts.filter((value) => value && value.trim()).join(separator); +} + +function resolveSystemPkgConfigDirs(environment = {}) { + const explicitDirectories = + environment.IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS; + if (!explicitDirectories) { + return [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS]; + } + + const directories = explicitDirectories + .split(path.delimiter) + .map((directory) => directory.trim()) + .filter(Boolean); + if ( + directories.length === 0 || + directories.some((directory) => !path.isAbsolute(directory)) + ) { + throw new Error( + 'IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS must contain only absolute paths.' + ); + } + return [ + ...new Set(directories.map((directory) => path.normalize(directory))), + ]; +} + +function createBuildEnvironment({ + prefix, + baseEnv = process.env, + systemPkgConfigDirs = [], +}) { + const prefixPkgConfigDirs = [ + path.join(prefix, 'lib', 'pkgconfig'), + path.join(prefix, 'share', 'pkgconfig'), + ]; + const pkgConfigLibDirs = [ + ...new Set([ + ...prefixPkgConfigDirs, + ...systemPkgConfigDirs.filter(Boolean), + ]), + ]; + const prefixLibDir = path.join(prefix, 'lib'); + const ignoredVariables = new Set([ + 'CFLAGS', + 'CPPFLAGS', + 'CXXFLAGS', + 'LDFLAGS', + 'LD_LIBRARY_PATH', + 'LIBRARY_PATH', + 'CPATH', + 'C_INCLUDE_PATH', + 'CPLUS_INCLUDE_PATH', + 'CMAKE_PREFIX_PATH', + 'CMAKE_LIBRARY_PATH', + 'CMAKE_INCLUDE_PATH', + 'FONTCONFIG_PATH', + 'OPENSSL_MODULES', + ]); + const inheritedEnvironment = Object.fromEntries( + Object.entries(baseEnv).filter( + ([name]) => + !ignoredVariables.has(name) && !name.startsWith('PKG_CONFIG') + ) + ); + + return { + ...inheritedEnvironment, + PATH: joinEnvironmentParts( + [path.join(prefix, 'bin'), baseEnv.PATH], + path.delimiter + ), + PKG_CONFIG_PATH: prefixPkgConfigDirs.join(path.delimiter), + PKG_CONFIG_LIBDIR: pkgConfigLibDirs.join(path.delimiter), + CMAKE_PREFIX_PATH: prefix, + CPPFLAGS: `-I${path.join(prefix, 'include')}`, + CFLAGS: joinEnvironmentParts([ + '-fPIC', + `-I${path.join(prefix, 'include')}`, + ]), + CXXFLAGS: joinEnvironmentParts([ + '-fPIC', + `-I${path.join(prefix, 'include')}`, + ]), + LDFLAGS: joinEnvironmentParts([ + `-L${prefixLibDir}`, + `-Wl,-rpath-link,${prefixLibDir}`, + ]), + LD_LIBRARY_PATH: prefixLibDir, + FONTCONFIG_PATH: path.join(prefix, 'etc', 'fonts'), + OPENSSL_MODULES: path.join(prefixLibDir, 'ossl-modules'), + }; +} + +function createPinnedHwdataPkgConfigEnvironment({ buildEnvironment, prefix }) { + if (!buildEnvironment || typeof buildEnvironment !== 'object') { + throw new TypeError( + 'Pinned hwdata requires the Linux runtime build environment.' + ); + } + const prefixPkgConfigDirs = [ + path.join(prefix, 'lib', 'pkgconfig'), + path.join(prefix, 'share', 'pkgconfig'), + ]; + const pinnedPkgConfigPath = prefixPkgConfigDirs.join(path.delimiter); + return { + ...buildEnvironment, + PKG_CONFIG_PATH: pinnedPkgConfigPath, + PKG_CONFIG_LIBDIR: pinnedPkgConfigPath, + }; +} + +function assertPinnedHwdataResolution({ + pcFileDir, + pkgDataDir, + prefix, + version, +}) { + const expectedPcFileDir = path.join(prefix, 'share', 'pkgconfig'); + const expectedPkgDataDir = path.join(prefix, 'share', 'hwdata'); + if (path.resolve(pcFileDir) !== path.resolve(expectedPcFileDir)) { + throw new Error( + `Pinned hwdata pkg-config metadata resolved outside the staged prefix: ${pcFileDir}.` + ); + } + if (path.resolve(pkgDataDir) !== path.resolve(expectedPkgDataDir)) { + throw new Error( + `Pinned hwdata data resolved outside the staged prefix: ${pkgDataDir}.` + ); + } + const hwdataPackage = SOURCE_PACKAGES.find(({ id }) => id === 'hwdata'); + if (version !== hwdataPackage.version) { + throw new Error( + `Pinned hwdata version mismatch: expected ${hwdataPackage.version}, received ${version}.` + ); + } +} + +function preparePinnedHwdataBuildInput({ + buildEnvironment, + fileSystem = fs, + prefix, + runCapture, + sourcePath, +}) { + if (typeof runCapture !== 'function') { + throw new TypeError( + 'Pinned hwdata preparation requires a command capture function.' + ); + } + const hwdataPackage = SOURCE_PACKAGES.find(({ id }) => id === 'hwdata'); + const sourceRoot = fileSystem.realpathSync(sourcePath); + const sourceInputPath = path.join( + sourcePath, + hwdataPackage.buildInput.relativePath + ); + const sourceInputStat = fileSystem.lstatSync(sourceInputPath); + if (!sourceInputStat.isFile() || sourceInputStat.isSymbolicLink()) { + throw new Error( + `Pinned hwdata build input must be a regular file: ${sourceInputPath}.` + ); + } + const realSourceInputPath = fileSystem.realpathSync(sourceInputPath); + assertPathInside( + sourceRoot, + realSourceInputPath, + 'Pinned hwdata build input' + ); + const pnpIds = fileSystem.readFileSync(realSourceInputPath); + if (pnpIds.length === 0) { + throw new Error('Pinned hwdata pnp.ids build input must not be empty.'); + } + + const pkgDataDir = path.join(prefix, 'share', 'hwdata'); + const pcFileDir = path.join(prefix, 'share', 'pkgconfig'); + fileSystem.mkdirSync(pkgDataDir, { recursive: true }); + fileSystem.mkdirSync(pcFileDir, { recursive: true }); + fileSystem.writeFileSync(path.join(pkgDataDir, 'pnp.ids'), pnpIds, { + mode: 0o644, + }); + fileSystem.writeFileSync( + path.join(pcFileDir, 'hwdata.pc'), + [ + `prefix=${prefix}`, + 'datadir=${prefix}/share', + `pkgdatadir=${pkgDataDir}`, + '', + 'Name: hwdata', + 'Description: Pinned PNP hardware identification data', + `Version: ${hwdataPackage.version}`, + '', + ].join('\n'), + { mode: 0o644 } + ); + + const pinnedEnvironment = createPinnedHwdataPkgConfigEnvironment({ + buildEnvironment, + prefix, + }); + const captureOptions = { env: pinnedEnvironment }; + const resolvedPcFileDir = runCapture( + 'pkg-config', + ['--variable=pcfiledir', 'hwdata'], + captureOptions + ); + const resolvedPkgDataDir = runCapture( + 'pkg-config', + ['--variable=pkgdatadir', 'hwdata'], + captureOptions + ); + const resolvedVersion = runCapture( + 'pkg-config', + ['--modversion', 'hwdata'], + captureOptions + ); + assertPinnedHwdataResolution({ + pcFileDir: resolvedPcFileDir, + pkgDataDir: resolvedPkgDataDir, + prefix, + version: resolvedVersion, + }); + return pinnedEnvironment; +} + +function resolveLinuxPackageBuildEnvironment(packageId, context) { + if (packageId !== 'libdisplay-info') { + return context.buildEnvironment; + } + if (!context.hwdataBuildEnvironment) { + throw new Error( + 'libdisplay-info requires the staged pinned hwdata build environment.' + ); + } + return context.hwdataBuildEnvironment; +} + +function sha256Buffer(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function runtimeLibraryNames(libDir) { + return fs + .readdirSync(libDir, { withFileTypes: true }) + .filter( + (entry) => + (entry.isFile() || entry.isSymbolicLink()) && + SHARED_LIBRARY_PATTERN.test(entry.name) + ) + .map((entry) => entry.name) + .sort(); +} + +function assertPathInside(parentPath, candidatePath, label) { + const relativePath = path.relative(parentPath, candidatePath); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error(`${label} resolves outside ${parentPath}.`); + } +} + +function materializeLibrarySymlinks(libDir, selectedNames = null) { + const realLibDir = fs.realpathSync(libDir); + for (const name of runtimeLibraryNames(libDir)) { + if (selectedNames && !selectedNames.has(name)) { + continue; + } + const libraryPath = path.join(libDir, name); + const stat = fs.lstatSync(libraryPath); + if (!stat.isSymbolicLink()) { + continue; + } + + const realLibraryPath = fs.realpathSync(libraryPath); + assertPathInside( + realLibDir, + realLibraryPath, + `Runtime library alias ${name}` + ); + const targetStat = fs.statSync(realLibraryPath); + if (!targetStat.isFile()) { + throw new Error( + `Runtime library alias ${name} does not resolve to a regular file.` + ); + } + const contents = fs.readFileSync(realLibraryPath); + fs.unlinkSync(libraryPath); + fs.writeFileSync(libraryPath, contents, { + mode: targetStat.mode & 0o777, + }); + } +} + +function selectReachableRuntimeLibraryNames(entries) { + if (!Array.isArray(entries)) { + throw new TypeError('Runtime dynamic entries must be an array.'); + } + + const entriesByName = new Map(); + for (const entry of entries) { + if ( + !entry || + typeof entry.name !== 'string' || + !SHARED_LIBRARY_PATTERN.test(entry.name) + ) { + throw new Error( + 'Runtime dynamic entry has an invalid library name.' + ); + } + if (entriesByName.has(entry.name)) { + throw new Error( + `Runtime dynamic entries contain duplicate library ${entry.name}.` + ); + } + entriesByName.set(entry.name, entry); + } + + const linkerAlias = entriesByName.get('libmpv.so'); + if (!linkerAlias) { + throw new Error( + 'Linux runtime must contain the libmpv.so linker alias.' + ); + } + if ( + typeof linkerAlias.soname !== 'string' || + !SHARED_LIBRARY_PATTERN.test(linkerAlias.soname) || + !entriesByName.has(linkerAlias.soname) + ) { + throw new Error( + 'libmpv.so must declare a bundled SONAME before runtime pruning.' + ); + } + + const reachableNames = new Set(['libmpv.so']); + const pendingNames = [linkerAlias.soname]; + while (pendingNames.length > 0) { + const libraryName = pendingNames.shift(); + if (reachableNames.has(libraryName)) { + continue; + } + reachableNames.add(libraryName); + const entry = entriesByName.get(libraryName); + if (!entry) { + throw new Error( + `Reachable runtime library ${libraryName} is missing its dynamic entry.` + ); + } + for (const neededName of entry.needed ?? []) { + if ( + entriesByName.has(neededName) && + !reachableNames.has(neededName) + ) { + pendingNames.push(neededName); + } + } + } + + return [...reachableNames].sort(); +} + +function retainRuntimeLibraries(libDir, retainedNames) { + if (!Array.isArray(retainedNames) || retainedNames.length === 0) { + throw new Error('Runtime retention list must be a non-empty array.'); + } + const retainedNameSet = new Set(retainedNames); + if (retainedNameSet.size !== retainedNames.length) { + throw new Error('Runtime retention list contains duplicate libraries.'); + } + + const availableNames = runtimeLibraryNames(libDir); + for (const retainedName of retainedNameSet) { + if (!availableNames.includes(retainedName)) { + throw new Error( + `Retained runtime library does not exist: ${retainedName}.` + ); + } + } + + materializeLibrarySymlinks(libDir, retainedNameSet); + for (const libraryName of availableNames) { + if (!retainedNameSet.has(libraryName)) { + fs.rmSync(path.join(libDir, libraryName)); + } + } + + for (const retainedName of retainedNameSet) { + const retainedPath = path.join(libDir, retainedName); + const stat = fs.lstatSync(retainedPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Retained runtime library ${retainedName} must be a materialized regular file.` + ); + } + } +} + +function createRuntimeFileRecords(libDir) { + return runtimeLibraryNames(libDir).map((name) => { + const libraryPath = path.join(libDir, name); + const stat = fs.lstatSync(libraryPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Runtime library ${name} must be a materialized regular file.` + ); + } + const contents = fs.readFileSync(libraryPath); + return { + name, + size: contents.length, + sha256: sha256Buffer(contents), + }; + }); +} + +function parseReadelfDynamic(output) { + const dynamic = { + needed: [], + rpath: [], + runpath: [], + soname: null, + }; + const dynamicEntryPattern = + /\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g; + for (const match of output.matchAll(dynamicEntryPattern)) { + const [, tag, value] = match; + if (tag === 'SONAME') { + dynamic.soname = value; + continue; + } + if (tag === 'NEEDED') { + dynamic.needed.push(value); + continue; + } + const field = tag.toLowerCase(); + dynamic[field].push( + ...value.split(':').filter((pathEntry) => pathEntry.length > 0) + ); + } + + for (const field of ['needed', 'rpath', 'runpath']) { + dynamic[field] = [...new Set(dynamic[field])].sort(); + } + return dynamic; +} + +function parseReadelfVersionInfo(output, name) { + if (typeof output !== 'string' || typeof name !== 'string' || !name) { + throw new TypeError( + 'readelf version output and runtime library name are required.' + ); + } + + let requiredGlibc = null; + let requiredGlibcxx = null; + const versionPattern = /\b(GLIBCXX|GLIBC)_(\d+(?:\.\d+)+)\b/g; + for (const [, namespace, version] of output.matchAll(versionPattern)) { + if ( + namespace === 'GLIBC' && + (!requiredGlibc || compareVersions(version, requiredGlibc) > 0) + ) { + requiredGlibc = version; + } + if ( + namespace === 'GLIBCXX' && + (!requiredGlibcxx || compareVersions(version, requiredGlibcxx) > 0) + ) { + requiredGlibcxx = version; + } + } + + return { name, requiredGlibc, requiredGlibcxx }; +} + +function assertPortableAbiRecords(records) { + if (!Array.isArray(records)) { + throw new TypeError('Runtime ABI records must be an array.'); + } + for (const record of records) { + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ]) { + const version = record?.[field]; + if (version && compareVersions(version, maximum) > 0) { + throw new Error( + `Portable ABI baseline ${PORTABLE_ABI_BASELINE.distribution} rejects newer symbol ${version} required by ${record.name}; maximum ${field} is ${maximum}.` + ); + } + } + } +} + +function assertPortableBuildHostGlibc(glibcVersion) { + if ( + typeof glibcVersion !== 'string' || + !/^\d+(?:\.\d+)+$/.test(glibcVersion) + ) { + throw new Error( + 'Unable to determine the Linux build host glibc version.' + ); + } + if (compareVersions(glibcVersion, PORTABLE_ABI_BASELINE.glibcMaximum) > 0) { + throw new Error( + `Build host glibc ${glibcVersion} exceeds the portable ABI baseline ${PORTABLE_ABI_BASELINE.distribution} maximum ${PORTABLE_ABI_BASELINE.glibcMaximum}.` + ); + } +} + +function validateRuntimeDependencyClosure({ + entries, + runtimeFileNames, + buildPrefix, +}) { + if (!Array.isArray(entries) || !Array.isArray(runtimeFileNames)) { + throw new TypeError( + 'Runtime closure entries and runtime file names must be arrays.' + ); + } + + const bundledNames = new Set(runtimeFileNames); + const entryNames = new Set(); + const externalDependencies = new Set(); + const normalizedEntries = [...entries] + .map((entry) => ({ + name: entry.name, + soname: entry.soname ?? null, + needed: [...new Set(entry.needed ?? [])].sort(), + rpath: [...new Set(entry.rpath ?? [])].sort(), + runpath: [...new Set(entry.runpath ?? [])].sort(), + })) + .sort((left, right) => left.name.localeCompare(right.name)); + + for (const entry of normalizedEntries) { + if (!bundledNames.has(entry.name)) { + throw new Error( + `Dynamic closure contains undeclared runtime file ${entry.name}.` + ); + } + if (entryNames.has(entry.name)) { + throw new Error( + `Dynamic closure contains duplicate runtime file ${entry.name}.` + ); + } + entryNames.add(entry.name); + + if ( + entry.soname !== null && + (typeof entry.soname !== 'string' || + !SHARED_LIBRARY_PATTERN.test(entry.soname) || + path.basename(entry.soname) !== entry.soname) + ) { + throw new Error( + `${entry.name} SONAME must be null or a safe shared-library basename.` + ); + } + if ( + entry.name === 'libmpv.so' && + (typeof entry.soname !== 'string' || + !/^libmpv\.so\.\d+(?:\.\d+)*$/.test(entry.soname) || + !bundledNames.has(entry.soname)) + ) { + throw new Error( + 'libmpv.so must declare a versioned SONAME present in the runtime closure.' + ); + } + + if (entry.rpath.length > 0) { + throw new Error( + `${entry.name} has forbidden RPATH ${entry.rpath.join(':')}.` + ); + } + if (entry.runpath.length !== 1 || entry.runpath[0] !== '$ORIGIN') { + const renderedRunpath = + entry.runpath.length > 0 ? entry.runpath.join(':') : ''; + throw new Error( + `${entry.name} RUNPATH must be exactly $ORIGIN; got ${renderedRunpath}.` + ); + } + if ( + buildPrefix && + [...entry.rpath, ...entry.runpath].some((value) => + value.includes(buildPrefix) + ) + ) { + throw new Error( + `${entry.name} RPATH/RUNPATH contains build prefix ${buildPrefix}.` + ); + } + + for (const dependencyName of entry.needed) { + if (bundledNames.has(dependencyName)) { + continue; + } + if (!allowedExternalLibraryNames.has(dependencyName)) { + throw new Error( + `Runtime dependency is not bundled or allowlisted: ${entry.name} -> ${dependencyName}.` + ); + } + externalDependencies.add(dependencyName); + } + } + + for (const runtimeFileName of bundledNames) { + if (!entryNames.has(runtimeFileName)) { + throw new Error( + `Runtime library ${runtimeFileName} is missing from the dynamic closure.` + ); + } + } + + return { + entries: normalizedEntries, + externalDependencies: [...externalDependencies].sort(), + }; +} + +function parseCliInvocation({ platform, arch, argv, cwd }) { + if (platform !== 'linux' || arch !== 'x64') { + throw new Error( + `Embedded MPV runtime source builds are supported on Linux x64 only; received ${platform}/${arch}.` + ); + } + + const args = argv[0] === '--' ? argv.slice(1) : argv; + if (args.length !== 1 || !args[0]) { + throw new Error( + [ + 'Usage: node tools/embedded-mpv/build-linux-runtime.mjs ', + '', + 'Builds the pinned LGPL-compatible Linux x64 libmpv runtime from source.', + ].join('\n') + ); + } + + return { prefix: path.resolve(cwd, args[0]) }; +} + +function sourceManifestMetadata(sourceRecord) { + const metadata = { + version: sourceRecord.version, + sourceUrl: sourceRecord.sourceUrl, + ...(sourceRecord.sourceTag + ? { sourceTag: sourceRecord.sourceTag } + : {}), + ...(sourceRecord.sourceSha256 + ? { sourceSha256: sourceRecord.sourceSha256 } + : {}), + ...(sourceRecord.sourceGitCommit + ? { sourceGitCommit: sourceRecord.sourceGitCommit } + : {}), + ...(sourceRecord.sourceSubmodules + ? { sourceSubmodules: [...sourceRecord.sourceSubmodules] } + : {}), + ...(sourceRecord.buildInput + ? { buildInput: { ...sourceRecord.buildInput } } + : {}), + license: sourceRecord.license, + }; + + if ( + sourceRecord.sourceKind === 'archive' && + !SHA256_PATTERN.test(sourceRecord.sourceSha256 ?? '') + ) { + throw new Error( + `Archive source ${sourceRecord.id} is missing its downloaded SHA-256 digest.` + ); + } + if ( + sourceRecord.sourceKind === 'git' && + !/^[a-f0-9]{40,64}$/.test(sourceRecord.sourceGitCommit ?? '') + ) { + throw new Error( + `Git source ${sourceRecord.id} is missing its exact commit digest.` + ); + } + return metadata; +} + +function createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles, + abiRecords, + dependencyClosure, + buildHost, + generatedAt = new Date().toISOString(), + ffmpegConfigureFlags = FFMPEG_CONFIGURE_FLAGS, + mpvMesonFlags = MPV_MESON_FLAGS, +}) { + const packages = {}; + for (const sourcePackage of SOURCE_PACKAGES) { + const sourceRecord = sourceRecords[sourcePackage.id]; + if (!sourceRecord) { + throw new Error(`Missing source metadata for ${sourcePackage.id}.`); + } + if (sourcePackage.sourceKind === 'archive') { + assertArchiveMatchesPin(sourcePackage, sourceRecord.sourceSha256); + } else { + assertGitCommitMatchesPin( + sourcePackage, + sourceRecord.sourceGitCommit + ); + assertGitSubmodulesMatchPin( + sourcePackage, + sourceRecord.sourceSubmodules + ); + } + packages[sourcePackage.id] = sourceManifestMetadata(sourceRecord); + } + assertPortableAbiRecords(abiRecords); + + const runtimeTotalBytes = runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ); + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + generatedAt, + packages, + ffmpeg: { + ...packages.ffmpeg, + licensePolicy: + 'LGPL, built with OpenSSL and without GPL, nonfree, or version-3-only components.', + configureFlags: [...ffmpegConfigureFlags], + }, + mpv: { + ...packages.mpv, + licensePolicy: + 'LGPL-compatible libmpv built with -Dgpl=false and without the CLI player.', + mesonFlags: [...mpvMesonFlags], + }, + runtimeFiles: runtimeFiles.map((runtimeFile) => ({ ...runtimeFile })), + runtimeTotalBytes, + runtimeAbi: { + baseline: { ...PORTABLE_ABI_BASELINE }, + files: abiRecords.map((record) => ({ ...record })), + }, + runtimeExternalConfiguration: { + fontconfig: { ...RUNTIME_EXTERNAL_CONFIGURATION.fontconfig }, + openssl: { ...RUNTIME_EXTERNAL_CONFIGURATION.openssl }, + }, + runtimeDependencyClosure: { + entries: dependencyClosure.entries.map((entry) => ({ + name: entry.name, + soname: entry.soname ?? null, + needed: [...entry.needed], + rpath: [...entry.rpath], + runpath: [...entry.runpath], + })), + externalDependencies: [...dependencyClosure.externalDependencies], + }, + externalSystemLibraries: EXTERNAL_SYSTEM_LIBRARIES.map( + (externalLibrary) => ({ ...externalLibrary }) + ), + buildHost, + sourceDistribution: + 'Attach a source archive to the corresponding Linux binary release containing the exact downloaded source archives, including the pinned dual-licensed hwdata archive whose pnp.ids is compiled into the MIT-licensed libdisplay-info source archive, a checkout or git bundle of the recorded libplacebo commit and submodules, tools/embedded-mpv/build-linux-runtime.mjs, tools/embedded-mpv/build-linux-runtime.cjs, this runtime manifest, and any local patches.', + }; +} + +module.exports = { + BUILD_RECIPES, + 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, + MINIMUM_TOOL_VERSIONS, + MPV_MESON_FLAGS, + OUTPUT_OWNERSHIP_MARKER, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, + assertArchiveMatchesPin, + assertGitCommitMatchesPin, + assertGitSubmodulesMatchPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + canonicalizeGitSubmoduleStatus, + compareVersions, + createBuildEnvironment, + createLinuxRuntimeManifest, + createOwnedStagingPrefix, + createRuntimeFileRecords, + materializeLibrarySymlinks, + ownedStagingPrefixPath, + parseCliInvocation, + parseReadelfDynamic, + parseReadelfVersionInfo, + parseVersion, + preparePinnedHwdataBuildInput, + resolveSystemPkgConfigDirs, + resolveLinuxPackageBuildEnvironment, + runtimeLibraryNames, + sha256Buffer, + publishOwnedOutput, + retainRuntimeLibraries, + selectReachableRuntimeLibraryNames, + validateRuntimeDependencyClosure, +}; diff --git a/tools/embedded-mpv/build-linux-runtime.mjs b/tools/embedded-mpv/build-linux-runtime.mjs new file mode 100644 index 000000000..e9efefd8f --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.mjs @@ -0,0 +1,865 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + BUILD_RECIPES, + BUILD_ORDER, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + MPV_MESON_FLAGS, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + SOURCE_PACKAGES, + assertArchiveMatchesPin, + assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + canonicalizeGitSubmoduleStatus, + createBuildEnvironment, + createLinuxRuntimeManifest, + createOwnedStagingPrefix, + createRuntimeFileRecords, + ownedStagingPrefixPath, + parseCliInvocation, + parseReadelfDynamic, + parseReadelfVersionInfo, + preparePinnedHwdataBuildInput, + retainRuntimeLibraries, + resolveLinuxPackageBuildEnvironment, + resolveSystemPkgConfigDirs, + runtimeLibraryNames, + selectReachableRuntimeLibraryNames, + sha256Buffer, + publishOwnedOutput, + validateRuntimeDependencyClosure, +} = require('./build-linux-runtime.cjs'); +const { + validateLinuxRuntimeManifest, +} = require('./linux-runtime-manifest.cjs'); + +const scriptPath = fileURLToPath(import.meta.url); +const workspaceRoot = path.resolve(path.dirname(scriptPath), '..', '..'); +const sourcePackageById = new Map( + SOURCE_PACKAGES.map((sourcePackage) => [sourcePackage.id, sourcePackage]) +); + +function log(message) { + process.stdout.write(`[embedded-mpv-linux-runtime] ${message}\n`); +} + +function commandLine(command, args) { + return [command, ...args] + .map((value) => + /^[A-Za-z0-9_./:=+,-]+$/.test(value) ? value : JSON.stringify(value) + ) + .join(' '); +} + +function spawn(command, args, options, capture) { + log(commandLine(command, args)); + const result = spawnSync(command, args, { + cwd: options.cwd, + env: options.env, + encoding: capture ? 'utf8' : undefined, + stdio: capture ? 'pipe' : 'inherit', + }); + + if (result.error) { + throw new Error( + `Unable to run ${commandLine(command, args)}: ${result.error.message}` + ); + } + if (result.status !== 0) { + const details = capture + ? [result.stdout, result.stderr].filter(Boolean).join('\n').trim() + : ''; + throw new Error( + `${commandLine(command, args)} failed with status ${ + result.status ?? 1 + }.${details ? `\n${details}` : ''}` + ); + } + return result; +} + +function createCommandRunner({ buildEnvironment }) { + return { + run(command, args, options = {}) { + spawn( + command, + args, + { + cwd: options.cwd ?? workspaceRoot, + env: options.env ?? buildEnvironment, + }, + false + ); + }, + runCapture(command, args, options = {}) { + const result = spawn( + command, + args, + { + cwd: options.cwd ?? workspaceRoot, + env: options.env ?? buildEnvironment, + }, + true + ); + return [result.stdout, result.stderr] + .filter(Boolean) + .join('\n') + .trim(); + }, + }; +} + +function commandExists(command) { + const result = spawnSync( + 'sh', + ['-c', 'command -v "$1" >/dev/null 2>&1', 'sh', command], + { stdio: 'ignore' } + ); + return result.status === 0; +} + +function ensureTools() { + const missingTools = REQUIRED_TOOLS.filter( + (command) => !commandExists(command) + ); + if (missingTools.length > 0) { + throw new Error( + `Missing required Linux runtime build tools: ${missingTools.join( + ', ' + )}.` + ); + } +} + +function resolveParallelism(environment) { + const explicitJobs = environment.IPTVNATOR_EMBEDDED_MPV_JOBS; + const makeJobs = environment.MAKEFLAGS?.match( + /(?:^|\s)-j\s*(\d+)(?:\s|$)/ + )?.[1]; + const value = + explicitJobs ?? + makeJobs ?? + String(os.availableParallelism?.() ?? os.cpus().length); + if (!/^[1-9]\d*$/.test(value)) { + throw new Error( + `IPTVNATOR_EMBEDDED_MPV_JOBS must be a positive integer; received ${value}.` + ); + } + return value; +} + +function containsPath(parentPath, candidatePath) { + const relativePath = path.relative(parentPath, candidatePath); + return ( + relativePath === '' || + (!relativePath.startsWith(`..${path.sep}`) && + relativePath !== '..' && + !path.isAbsolute(relativePath)) + ); +} + +function assertSafeOutputPrefix(prefix, buildRoot) { + const filesystemRoot = path.parse(prefix).root; + if ( + prefix === filesystemRoot || + containsPath(prefix, workspaceRoot) || + containsPath(prefix, buildRoot) + ) { + throw new Error( + `Refusing unsafe output prefix ${prefix}; choose a dedicated directory that does not contain the repository or build cache.` + ); + } +} + +function archiveExtension(sourceUrl) { + for (const extension of ['.tar.xz', '.tar.gz', '.tar.bz2', '.tgz']) { + if (new URL(sourceUrl).pathname.endsWith(extension)) { + return extension; + } + } + throw new Error(`Unsupported source archive URL: ${sourceUrl}`); +} + +function archivePathFor(sourcePackage, archiveRoot) { + return path.join( + archiveRoot, + `${sourcePackage.id}-${sourcePackage.version}${archiveExtension( + sourcePackage.sourceUrl + )}` + ); +} + +function sourcePathFor(packageId, sourceRoot) { + return path.join(sourceRoot, packageId); +} + +function sha256File(filePath) { + return sha256Buffer(fs.readFileSync(filePath)); +} + +function downloadArchive(sourcePackage, context) { + const archivePath = archivePathFor(sourcePackage, context.archiveRoot); + if (!fs.existsSync(archivePath)) { + const temporaryArchivePath = `${archivePath}.partial`; + fs.rmSync(temporaryArchivePath, { force: true }); + context.run('curl', [ + '--fail', + '--location', + '--retry', + '3', + '--retry-all-errors', + '--connect-timeout', + '30', + '--proto', + '=https', + '--tlsv1.2', + '--output', + temporaryArchivePath, + sourcePackage.sourceUrl, + ]); + fs.renameSync(temporaryArchivePath, archivePath); + } + + const sourceSha256 = sha256File(archivePath); + assertArchiveMatchesPin(sourcePackage, sourceSha256); + const packageSourcePath = sourcePathFor( + sourcePackage.id, + context.sourceRoot + ); + fs.rmSync(packageSourcePath, { recursive: true, force: true }); + fs.mkdirSync(packageSourcePath, { recursive: true }); + context.run('tar', [ + '--extract', + '--file', + archivePath, + '--directory', + packageSourcePath, + '--strip-components', + '1', + '--no-same-owner', + ]); + + return { + ...sourcePackage, + sourceSha256, + }; +} + +function cloneGitSource(sourcePackage, context) { + const packageSourcePath = sourcePathFor( + sourcePackage.id, + context.sourceRoot + ); + fs.rmSync(packageSourcePath, { recursive: true, force: true }); + context.run('git', [ + 'clone', + '--depth', + '1', + '--branch', + sourcePackage.sourceTag, + sourcePackage.sourceUrl, + packageSourcePath, + ]); + const sourceGitCommit = context.runCapture('git', ['rev-parse', 'HEAD'], { + cwd: packageSourcePath, + }); + assertGitCommitMatchesPin(sourcePackage, sourceGitCommit); + context.run( + 'git', + ['submodule', 'update', '--init', '--recursive', '--depth', '1'], + { cwd: packageSourcePath } + ); + + const submoduleOutput = context.runCapture( + 'git', + ['submodule', 'status', '--recursive'], + { cwd: packageSourcePath } + ); + + return { + ...sourcePackage, + sourceGitCommit, + sourceSubmodules: canonicalizeGitSubmoduleStatus(submoduleOutput), + }; +} + +function acquireSources(context) { + fs.mkdirSync(context.archiveRoot, { recursive: true }); + fs.mkdirSync(context.sourceRoot, { recursive: true }); + const sourceRecords = {}; + + for (const packageId of BUILD_ORDER) { + const sourcePackage = sourcePackageById.get(packageId); + log( + `Acquiring ${sourcePackage.id} ${sourcePackage.version} from ${sourcePackage.sourceUrl}` + ); + sourceRecords[packageId] = + sourcePackage.sourceKind === 'git' + ? cloneGitSource(sourcePackage, context) + : downloadArchive(sourcePackage, context); + } + return sourceRecords; +} + +function configureInstall(packageId, recipe, context) { + const sourcePath = sourcePathFor(packageId, context.sourceRoot); + context.run('./configure', [`--prefix=${context.prefix}`, ...recipe.args], { + cwd: sourcePath, + }); + context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); + context.run('make', ['install'], { cwd: sourcePath }); +} + +function mesonSetupArgs(prefix, recipeArgs) { + return [ + `--prefix=${prefix}`, + '--libdir=lib', + '--buildtype=release', + '--default-library=shared', + '--wrap-mode=nodownload', + '--auto-features=disabled', + '-Db_ndebug=true', + ...recipeArgs, + ]; +} + +function mesonInstall(packageId, recipe, context) { + const sourcePath = sourcePathFor(packageId, context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + const buildEnvironment = resolveLinuxPackageBuildEnvironment( + packageId, + context + ); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run( + 'meson', + ['setup', buildPath, ...mesonSetupArgs(context.prefix, recipe.args)], + { cwd: sourcePath, env: buildEnvironment } + ); + context.run( + 'meson', + ['compile', '--jobs', context.parallelism, '-C', buildPath], + { cwd: sourcePath, env: buildEnvironment } + ); + context.run('meson', ['install', '-C', buildPath], { + cwd: sourcePath, + env: buildEnvironment, + }); +} + +function prepareHwdata(context) { + context.hwdataBuildEnvironment = preparePinnedHwdataBuildInput({ + buildEnvironment: context.buildEnvironment, + prefix: context.prefix, + runCapture: (command, args, options) => + context.runCapture(command, args, options), + sourcePath: sourcePathFor('hwdata', context.sourceRoot), + }); +} + +function pathInsideDestdir(destdir, absolutePath) { + return path.join( + destdir, + absolutePath.slice(path.parse(absolutePath).root.length) + ); +} + +export function copyDirectoryContents(sourceDirectory, destinationDirectory) { + if (!fs.existsSync(sourceDirectory)) { + return; + } + fs.mkdirSync(destinationDirectory, { recursive: true }); + for (const entry of fs.readdirSync(sourceDirectory)) { + fs.cpSync( + path.join(sourceDirectory, entry), + path.join(destinationDirectory, entry), + { + recursive: true, + force: true, + verbatimSymlinks: true, + } + ); + } +} + +function installWithDestdir(packageId, context, install, externalPaths = []) { + const destdir = path.join(context.buildRoot, 'install-roots', packageId); + fs.rmSync(destdir, { recursive: true, force: true }); + fs.mkdirSync(destdir, { recursive: true }); + try { + install(destdir); + copyDirectoryContents( + pathInsideDestdir(destdir, context.prefix), + context.prefix + ); + for (const { destination, source } of externalPaths) { + copyDirectoryContents( + pathInsideDestdir(destdir, source), + path.join(context.prefix, destination) + ); + } + } finally { + fs.rmSync(destdir, { recursive: true, force: true }); + } +} + +function fontconfigInstall(recipe, context) { + const sourcePath = sourcePathFor('fontconfig', context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run( + 'meson', + ['setup', buildPath, ...mesonSetupArgs(context.prefix, recipe.args)], + { cwd: sourcePath } + ); + context.run( + 'meson', + ['compile', '--jobs', context.parallelism, '-C', buildPath], + { cwd: sourcePath } + ); + installWithDestdir( + 'fontconfig', + context, + (destdir) => + context.run('meson', ['install', '-C', buildPath], { + cwd: sourcePath, + env: { ...context.buildEnvironment, DESTDIR: destdir }, + }), + [ + { source: '/etc/fonts', destination: path.join('etc', 'fonts') }, + { + source: '/usr/share/fontconfig', + destination: path.join('share', 'fontconfig'), + }, + { + source: '/usr/share/xml/fontconfig', + destination: path.join('share', 'xml', 'fontconfig'), + }, + { + source: '/var/cache/fontconfig', + destination: path.join('var', 'cache', 'fontconfig'), + }, + ] + ); +} + +function cmakeInstall(packageId, recipe, context) { + const sourcePath = sourcePathFor(packageId, context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run('cmake', [ + '-S', + sourcePath, + '-B', + buildPath, + '-G', + 'Ninja', + `-DCMAKE_INSTALL_PREFIX=${context.prefix}`, + '-DCMAKE_INSTALL_LIBDIR=lib', + '-DCMAKE_BUILD_TYPE=Release', + '-DBUILD_SHARED_LIBS=ON', + ...recipe.args, + ]); + context.run('cmake', [ + '--build', + buildPath, + '--parallel', + context.parallelism, + ]); + context.run('cmake', ['--install', buildPath]); +} + +function opensslInstall(recipe, context) { + const sourcePath = sourcePathFor('openssl', context.sourceRoot); + context.run( + 'perl', + [ + './Configure', + 'linux-x86_64', + `--prefix=${context.prefix}`, + '--libdir=lib', + ...recipe.args, + ], + { cwd: sourcePath } + ); + context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); + installWithDestdir('openssl', context, (destdir) => + context.run('make', ['install_sw', `DESTDIR=${destdir}`], { + cwd: sourcePath, + }) + ); +} + +function ffmpegInstall(recipe, context) { + const sourcePath = sourcePathFor('ffmpeg', context.sourceRoot); + const configureFlags = [`--prefix=${context.prefix}`, ...recipe.args]; + context.run('./configure', configureFlags, { cwd: sourcePath }); + context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); + context.run('make', ['install'], { cwd: sourcePath }); + context.ffmpegConfigureFlags = configureFlags; +} + +function buildRuntime(context) { + for (const packageId of BUILD_ORDER) { + const recipe = BUILD_RECIPES[packageId]; + log(`Building ${packageId} as shared libraries`); + if (packageId === 'fontconfig') { + fontconfigInstall(recipe, context); + continue; + } + switch (recipe.buildSystem) { + case 'data': + prepareHwdata(context); + break; + case 'configure': + configureInstall(packageId, recipe, context); + break; + case 'meson': + mesonInstall(packageId, recipe, context); + break; + case 'cmake': + cmakeInstall(packageId, recipe, context); + break; + case 'openssl': + opensslInstall(recipe, context); + break; + case 'ffmpeg': + ffmpegInstall(recipe, context); + break; + default: + throw new Error( + `Unsupported build system ${recipe.buildSystem} for ${packageId}.` + ); + } + } +} + +function removeFilesMatching(root, pattern) { + if (!fs.existsSync(root)) { + return; + } + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) { + removeFilesMatching(entryPath, pattern); + } else if (entry.isFile() && pattern.test(entry.name)) { + fs.rmSync(entryPath); + } + } +} + +function removeNonRuntimeBuildOutputs(prefix) { + removeFilesMatching(path.join(prefix, 'lib'), /\.(?:a|la)$/); + for (const relativePath of [ + 'bin', + path.join('share', 'doc'), + path.join('share', 'gtk-doc'), + path.join('share', 'man'), + ]) { + fs.rmSync(path.join(prefix, relativePath), { + recursive: true, + force: true, + }); + } +} + +function assertElfLibrary(libraryPath) { + const descriptor = fs.openSync(libraryPath, 'r'); + try { + const header = Buffer.alloc(4); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== 4 || + !header.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + throw new Error( + `Runtime shared library is not an ELF file: ${libraryPath}.` + ); + } + } finally { + fs.closeSync(descriptor); + } +} + +function postProcessRuntime(context) { + const libDir = path.join(context.prefix, 'lib'); + if (!fs.existsSync(libDir)) { + throw new Error(`Runtime library directory was not built: ${libDir}.`); + } + + const unprunedDynamicEntries = runtimeLibraryNames(libDir).map( + (libraryName) => { + const libraryPath = path.join(libDir, libraryName); + assertElfLibrary(libraryPath); + return { + name: libraryName, + ...parseReadelfDynamic( + context.runCapture('readelf', ['-d', libraryPath]) + ), + }; + } + ); + const retainedNames = selectReachableRuntimeLibraryNames( + unprunedDynamicEntries + ); + retainRuntimeLibraries(libDir, retainedNames); + + const abiRecords = []; + const dynamicEntries = []; + for (const libraryName of runtimeLibraryNames(libDir)) { + const libraryPath = path.join(libDir, libraryName); + assertElfLibrary(libraryPath); + context.run('patchelf', ['--set-rpath', '$ORIGIN', libraryPath]); + const dynamic = parseReadelfDynamic( + context.runCapture('readelf', ['-d', libraryPath]) + ); + dynamicEntries.push({ name: libraryName, ...dynamic }); + const versionInfo = context.runCapture('readelf', [ + '--version-info', + libraryPath, + ]); + abiRecords.push(parseReadelfVersionInfo(versionInfo, libraryName)); + } + assertPortableAbiRecords(abiRecords); + + const runtimeFiles = createRuntimeFileRecords(libDir); + if (runtimeFiles.length === 0) { + throw new Error( + 'The Linux runtime build produced no shared libraries.' + ); + } + const dependencyClosure = validateRuntimeDependencyClosure({ + entries: dynamicEntries, + runtimeFileNames: runtimeFiles.map(({ name }) => name), + buildPrefix: context.prefix, + }); + + return { + abiBaseline: PORTABLE_ABI_BASELINE, + abiRecords, + dependencyClosure, + runtimeFiles, + }; +} + +function firstLine(value) { + return ( + value + .split(/\r?\n/) + .find((line) => line.trim()) + ?.trim() ?? '' + ); +} + +function collectBuildHost(context) { + const tools = context.toolVersions; + if (!tools) { + throw new Error('Linux runtime tool versions were not preflighted.'); + } + return { + platform: process.platform, + arch: process.arch, + release: os.release(), + glibcVersion: context.glibcVersion, + systemPkgConfigDirs: [...context.systemPkgConfigDirs], + systemPkgConfigPackages: { + ...context.systemPkgConfigPackages, + }, + tools: { ...tools }, + }; +} + +function detectBuildHostGlibcVersion() { + const glibcVersion = + process.report?.getReport?.()?.header?.glibcVersionRuntime; + assertPortableBuildHostGlibc(glibcVersion); + return glibcVersion; +} + +function collectToolVersions(context) { + const versionArgs = { + cc: ['--version'], + cmake: ['--version'], + curl: ['--version'], + git: ['--version'], + gperf: ['--version'], + make: ['--version'], + meson: ['--version'], + nasm: ['-v'], + ninja: ['--version'], + patchelf: ['--version'], + perl: ['-e', 'printf "%vd\\n", $^V'], + 'pkg-config': ['--version'], + python3: ['--version'], + readelf: ['--version'], + tar: ['--version'], + }; + const toolVersions = {}; + for (const tool of REQUIRED_TOOLS) { + toolVersions[tool] = firstLine( + context.runCapture(tool, versionArgs[tool] ?? ['--version']) + ); + } + return toolVersions; +} + +function verifySystemPkgConfigPackages(context) { + const systemPkgConfigPackages = {}; + for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + context.run('pkg-config', ['--exists', packageName]); + systemPkgConfigPackages[packageName] = context.runCapture( + 'pkg-config', + ['--modversion', packageName] + ); + } + return systemPkgConfigPackages; +} + +function writeManifest(context, sourceRecords, runtimeMetadata) { + const mpvMesonFlags = mesonSetupArgs(context.prefix, MPV_MESON_FLAGS); + const manifest = createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles: runtimeMetadata.runtimeFiles, + abiRecords: runtimeMetadata.abiRecords, + dependencyClosure: runtimeMetadata.dependencyClosure, + buildHost: collectBuildHost(context), + ffmpegConfigureFlags: context.ffmpegConfigureFlags, + mpvMesonFlags, + }); + const manifestErrors = validateLinuxRuntimeManifest(manifest); + if (manifestErrors.length > 0) { + throw new Error( + [ + 'Generated Linux runtime manifest is invalid:', + ...manifestErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + + const manifestPath = path.join(context.prefix, 'runtime-manifest.json'); + const temporaryManifestPath = `${manifestPath}.tmp`; + fs.writeFileSync( + temporaryManifestPath, + `${JSON.stringify(manifest, null, 2)}\n`, + { mode: 0o644 } + ); + fs.renameSync(temporaryManifestPath, manifestPath); + return manifest; +} + +function createBuildContext(prefix, environment) { + const buildRoot = path.resolve( + environment.IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT ?? + path.join( + os.tmpdir(), + 'iptvnator-embedded-mpv-runtime', + 'linux-x64' + ) + ); + assertSafeOutputPrefix(prefix, buildRoot); + const systemPkgConfigDirs = resolveSystemPkgConfigDirs(environment); + const buildEnvironment = createBuildEnvironment({ + prefix, + baseEnv: environment, + systemPkgConfigDirs, + }); + const runner = createCommandRunner({ buildEnvironment }); + return { + ...runner, + prefix, + buildRoot, + archiveRoot: path.join(buildRoot, 'archives'), + sourceRoot: path.join(buildRoot, 'sources'), + parallelism: resolveParallelism(environment), + systemPkgConfigDirs, + buildEnvironment, + ffmpegConfigureFlags: null, + }; +} + +export function main({ + argv = process.argv.slice(2), + platform = process.platform, + arch = process.arch, + cwd = process.cwd(), + environment = process.env, +} = {}) { + const { prefix: outputPrefix } = parseCliInvocation({ + platform, + arch, + argv, + cwd, + }); + assertUniqueMesonOptionAssignments(BUILD_RECIPES); + ensureTools(); + assertOwnedOutputDestination(outputPrefix); + const stagingToken = `${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}`; + const stagingPrefix = ownedStagingPrefixPath(outputPrefix, stagingToken); + const context = createBuildContext(stagingPrefix, environment); + assertSafeOutputPrefix(outputPrefix, context.buildRoot); + const toolVersions = collectToolVersions(context); + assertMinimumToolVersions(toolVersions); + context.toolVersions = toolVersions; + context.glibcVersion = detectBuildHostGlibcVersion(); + context.systemPkgConfigPackages = verifySystemPkgConfigPackages(context); + fs.mkdirSync(context.buildRoot, { recursive: true }); + let stagingCreated = false; + try { + const createdStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: stagingToken, + }); + if (createdStagingPrefix !== stagingPrefix) { + throw new Error( + 'Linux runtime staging prefix changed unexpectedly.' + ); + } + stagingCreated = true; + const sourceRecords = acquireSources(context); + buildRuntime(context); + removeNonRuntimeBuildOutputs(context.prefix); + const runtimeMetadata = postProcessRuntime(context); + const manifest = writeManifest(context, sourceRecords, runtimeMetadata); + publishOwnedOutput({ + outputPrefix, + stagingPrefix, + }); + stagingCreated = false; + log( + `Built ${manifest.runtimeFiles.length} LGPL-compatible runtime libraries (${manifest.runtimeTotalBytes} bytes) at ${outputPrefix}` + ); + } finally { + if (stagingCreated) { + fs.rmSync(stagingPrefix, { recursive: true, force: true }); + } + } +} + +const invokedScriptPath = process.argv[1] + ? path.resolve(process.argv[1]) + : undefined; +if (invokedScriptPath === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/embedded-mpv/build-linux-runtime.test.mjs b/tools/embedded-mpv/build-linux-runtime.test.mjs new file mode 100644 index 000000000..61a2cbd0a --- /dev/null +++ b/tools/embedded-mpv/build-linux-runtime.test.mjs @@ -0,0 +1,1638 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const builderScript = path.join(currentDir, 'build-linux-runtime.mjs'); +const builderHelpers = path.join(currentDir, 'build-linux-runtime.cjs'); +const workspaceRoot = path.resolve(currentDir, '..', '..'); +const require = createRequire(import.meta.url); +const { + BUILD_RECIPES, + BUILD_ORDER, + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + FFMPEG_CONFIGURE_FLAGS, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + MPV_MESON_FLAGS, + OUTPUT_OWNERSHIP_MARKER, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, + assertArchiveMatchesPin, + assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + canonicalizeGitSubmoduleStatus, + createBuildEnvironment, + createLinuxRuntimeManifest, + createOwnedStagingPrefix, + createRuntimeFileRecords, + materializeLibrarySymlinks, + parseCliInvocation, + parseReadelfDynamic, + parseReadelfVersionInfo, + preparePinnedHwdataBuildInput, + publishOwnedOutput, + retainRuntimeLibraries, + resolveLinuxPackageBuildEnvironment, + resolveSystemPkgConfigDirs, + selectReachableRuntimeLibraryNames, + validateRuntimeDependencyClosure, +} = require('./build-linux-runtime.cjs'); +const { + validateLinuxRuntimeManifest, +} = require('./linux-runtime-manifest.cjs'); + +function createPinnedSourceRecords() { + return Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + ...sourcePackage, + ...(sourcePackage.sourceKind === 'git' + ? { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [ + ...sourcePackage.expectedSubmodules, + ], + } + : { + sourceSha256: sourcePackage.expectedSha256, + }), + }, + ]) + ); +} + +test('provides the Linux source runtime builder entrypoint and helpers', () => { + assert.equal(fs.existsSync(builderScript), true); + assert.equal(fs.existsSync(builderHelpers), true); +}); + +test('canonicalizes exact submodule identities independently of clone depth', () => { + const commit = '450bd2232225d6c7728a4108055ac2e37cef6475'; + const path = '3rdparty/Vulkan-Headers'; + assert.deepEqual( + canonicalizeGitSubmoduleStatus(` ${commit} ${path} (v1.4.337)\n`), + [`${commit} ${path}`] + ); + assert.deepEqual( + canonicalizeGitSubmoduleStatus(`${commit} ${path} (450bd22)`), + [`${commit} ${path}`] + ); +}); + +test('rejects non-clean, unsafe, and duplicate submodule status records', () => { + const commit = 'a'.repeat(40); + for (const output of [ + `-${commit} 3rdparty/missing`, + `+${commit} 3rdparty/moved`, + `U${commit} 3rdparty/conflicted`, + ` ${commit} ../outside`, + ` ${commit} 3rdparty/example\n ${commit} 3rdparty/example`, + ]) { + assert.throws( + () => canonicalizeGitSubmoduleStatus(output), + /not clean|unsafe|duplicate/i + ); + } +}); + +test('pins the complete source stack and preserves dependency build order', () => { + assert.deepEqual( + SOURCE_PACKAGES.map(({ id, version }) => ({ id, version })), + [ + { id: 'freetype', version: '2.13.3' }, + { id: 'fribidi', version: '1.0.16' }, + { id: 'harfbuzz', version: '8.5.0' }, + { id: 'expat', version: '2.8.2' }, + { id: 'fontconfig', version: '2.16.0' }, + { id: 'libass', version: '0.17.3' }, + { id: 'openssl', version: '3.5.7' }, + { id: 'ffmpeg', version: '8.1' }, + { id: 'libplacebo', version: '7.360.1' }, + { id: 'hwdata', version: '0.409' }, + { id: 'libdisplay-info', version: '0.1.1' }, + { id: 'mpv', version: '0.41.0' }, + ] + ); + assert.deepEqual( + BUILD_ORDER, + SOURCE_PACKAGES.map(({ id }) => id) + ); + + for (const sourcePackage of SOURCE_PACKAGES) { + assert.match(sourcePackage.sourceUrl, /^https:\/\//); + assert.ok(sourcePackage.license); + if (sourcePackage.id === 'libplacebo') { + assert.equal(sourcePackage.sourceTag, 'v7.360.1'); + assert.equal(sourcePackage.sourceKind, 'git'); + } else { + assert.equal(sourcePackage.sourceKind, 'archive'); + } + } + + const byId = new Map( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + sourcePackage, + ]) + ); + assert.equal( + byId.get('expat').sourceUrl, + 'https://github.com/libexpat/libexpat/releases/download/R_2_8_2/expat-2.8.2.tar.xz' + ); + assert.equal( + byId.get('fontconfig').sourceUrl, + 'https://www.freedesktop.org/software/fontconfig/release/fontconfig-2.16.0.tar.xz' + ); + assert.equal( + byId.get('openssl').sourceUrl, + 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz' + ); + assert.deepEqual(byId.get('hwdata'), { + id: 'hwdata', + version: '0.409', + sourceKind: 'archive', + sourceUrl: + 'https://github.com/vcrhonek/hwdata/archive/refs/tags/v0.409.tar.gz', + expectedSha256: + '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + license: 'GPL-2.0-or-later OR XFree86-1.0', + buildInput: { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }, + }); + assert.deepEqual(byId.get('libdisplay-info'), { + id: 'libdisplay-info', + version: '0.1.1', + sourceKind: 'archive', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + expectedSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }); + assert.ok( + BUILD_ORDER.indexOf('hwdata') < BUILD_ORDER.indexOf('libdisplay-info') + ); + assert.ok( + BUILD_ORDER.indexOf('libdisplay-info') < BUILD_ORDER.indexOf('mpv') + ); +}); + +test('hardcodes the verified official archive digests and libplacebo commit', () => { + const expectedArchivePins = { + expat: '3ad89b8588e6644bd4e49981480d48b21289eebbcd4f0a1a4afb1c29f99b6ab4', + ffmpeg: 'b072aed6871998cce9b36e7774033105ca29e33632be5b6347f3206898e0756a', + fontconfig: + '6a33dc555cc9ba8b10caf7695878ef134eeb36d0af366041f639b1da9b6ed220', + freetype: + '0550350666d427c74daeb85d5ac7bb353acba5f76956395995311a9c6f063289', + fribidi: + '1b1cde5b235d40479e91be2f0e88a309e3214c8ab470ec8a2744d82a5a9ea05c', + harfbuzz: + '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', + hwdata: '23006accc0f931dd5187d0307a57d0744e2b8feb85e73c37bc0f5229fb31eadd', + libass: 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + 'libdisplay-info': + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + mpv: 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', + openssl: + 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', + }; + assert.deepEqual( + Object.fromEntries( + SOURCE_PACKAGES.filter(({ sourceKind }) => sourceKind === 'archive') + .map(({ id, expectedSha256 }) => [id, expectedSha256]) + .sort(([left], [right]) => left.localeCompare(right)) + ), + expectedArchivePins + ); + assert.equal( + SOURCE_PACKAGES.find(({ id }) => id === 'libplacebo').expectedGitCommit, + 'cee9b076f2c63104ccfd497fa79c39a867293ec4' + ); +}); + +test('rejects archive and git sources that differ from immutable pins', () => { + const freetype = SOURCE_PACKAGES.find(({ id }) => id === 'freetype'); + assert.doesNotThrow(() => + assertArchiveMatchesPin(freetype, freetype.expectedSha256) + ); + assert.throws( + () => assertArchiveMatchesPin(freetype, '0'.repeat(64)), + /freetype.*SHA-256 mismatch.*expected 055035.*received 000000/i + ); + + const libplacebo = SOURCE_PACKAGES.find(({ id }) => id === 'libplacebo'); + assert.doesNotThrow(() => + assertGitCommitMatchesPin(libplacebo, libplacebo.expectedGitCommit) + ); + assert.throws( + () => assertGitCommitMatchesPin(libplacebo, '0'.repeat(40)), + /libplacebo.*commit mismatch.*expected cee9b.*received 000000/i + ); +}); + +test('verifies source pins before archive extraction or git submodules', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.ok( + builderSource.indexOf( + 'assertArchiveMatchesPin(sourcePackage, sourceSha256)' + ) < builderSource.indexOf("context.run('tar'") + ); + assert.ok( + builderSource.indexOf( + 'assertGitCommitMatchesPin(sourcePackage, sourceGitCommit)' + ) < + builderSource.indexOf( + "['submodule', 'update', '--init', '--recursive', '--depth', '1']" + ) + ); +}); + +test('rejects source metadata that does not match the immutable pins', () => { + const sourceRecords = createPinnedSourceRecords(); + sourceRecords.freetype.sourceSha256 = '0'.repeat(64); + assert.throws( + () => + createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles: [ + { + name: 'libmpv.so.2', + size: 1, + sha256: 'a'.repeat(64), + }, + ], + dependencyClosure: { + entries: [ + { + name: 'libmpv.so.2', + needed: ['libc.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + }, + ], + externalDependencies: ['libc.so.6'], + }, + buildHost: { + platform: 'linux', + arch: 'x64', + tools: {}, + }, + }), + /freetype.*SHA-256 mismatch/i + ); +}); + +test('defines shared-only source recipes with font discovery before playback', () => { + assert.deepEqual(Object.keys(BUILD_RECIPES), BUILD_ORDER); + assert.ok(BUILD_RECIPES.freetype.args.includes('--enable-shared')); + assert.ok(BUILD_RECIPES.freetype.args.includes('--disable-static')); + assert.ok(BUILD_RECIPES.fribidi.args.includes('--disable-docs')); + assert.ok(BUILD_RECIPES.fribidi.args.includes('--disable-bin')); + for (const flag of [ + '-Dtests=disabled', + '-Ddocs=disabled', + '-Dutilities=disabled', + ]) { + assert.ok(BUILD_RECIPES.harfbuzz.args.includes(flag), flag); + } + for (const flag of [ + '-DEXPAT_SHARED_LIBS=ON', + '-DEXPAT_BUILD_TOOLS=OFF', + '-DEXPAT_BUILD_EXAMPLES=OFF', + '-DEXPAT_BUILD_TESTS=OFF', + '-DEXPAT_BUILD_DOCS=OFF', + ]) { + assert.ok(BUILD_RECIPES.expat.args.includes(flag), flag); + } + for (const flag of [ + '-Ddoc=disabled', + '-Dtests=disabled', + '-Dtools=disabled', + '-Dcache-build=disabled', + '-Dxml-backend=expat', + '-Dbaseconfig-dir=/etc/fonts', + '-Dconfig-dir=/etc/fonts/conf.d', + '-Dtemplate-dir=/usr/share/fontconfig/conf.avail', + '-Dcache-dir=/var/cache/fontconfig', + '-Dxml-dir=/usr/share/xml/fontconfig', + ]) { + assert.ok(BUILD_RECIPES.fontconfig.args.includes(flag), flag); + } + assert.ok(BUILD_RECIPES.libass.args.includes('--enable-fontconfig')); + assert.equal( + BUILD_RECIPES.libass.args.includes('--disable-fontconfig'), + false + ); + for (const flag of [ + 'shared', + 'no-apps', + 'no-docs', + 'no-tests', + '--openssldir=/etc/ssl', + ]) { + assert.ok(BUILD_RECIPES.openssl.args.includes(flag), flag); + } + assert.ok( + BUILD_ORDER.indexOf('fontconfig') < BUILD_ORDER.indexOf('libass') + ); + assert.ok(BUILD_ORDER.indexOf('openssl') < BUILD_ORDER.indexOf('ffmpeg')); + assert.equal(BUILD_RECIPES.hwdata.buildSystem, 'data'); + assert.equal(BUILD_RECIPES.hwdata.sharedOnly, false); + assert.equal(BUILD_RECIPES['libdisplay-info'].buildSystem, 'meson'); + for (const packageId of BUILD_ORDER.filter( + (packageId) => packageId !== 'hwdata' + )) { + assert.equal(BUILD_RECIPES[packageId].sharedOnly, true, packageId); + } +}); + +test('stages pinned hwdata and excludes host pkg-config fallback', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-pinned-hwdata-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sourcePath = path.join(root, 'sources', 'hwdata'); + const prefix = path.join(root, 'runtime'); + fs.mkdirSync(sourcePath, { recursive: true }); + const pnpIds = 'ABC Example Display Vendor\n'; + fs.writeFileSync(path.join(sourcePath, 'pnp.ids'), pnpIds); + + const invocations = []; + const buildEnvironment = { + PATH: '/usr/bin', + PKG_CONFIG_PATH: '/host/pkgconfig', + PKG_CONFIG_LIBDIR: + '/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/share/pkgconfig', + }; + const pinnedEnvironment = preparePinnedHwdataBuildInput({ + buildEnvironment, + prefix, + runCapture(command, args, options) { + invocations.push({ args, command, env: options.env }); + assert.equal(options.env.PKG_CONFIG_PATH.includes('/host'), false); + assert.equal(options.env.PKG_CONFIG_LIBDIR.includes('/usr'), false); + if (args[0] === '--variable=pcfiledir') { + return path.join(prefix, 'share', 'pkgconfig'); + } + if (args[0] === '--variable=pkgdatadir') { + return path.join(prefix, 'share', 'hwdata'); + } + if (args[0] === '--modversion') { + return '0.409'; + } + throw new Error(`Unexpected pkg-config query: ${args.join(' ')}`); + }, + sourcePath, + }); + + assert.equal( + fs.readFileSync( + path.join(prefix, 'share', 'hwdata', 'pnp.ids'), + 'utf8' + ), + pnpIds + ); + const pkgConfig = fs.readFileSync( + path.join(prefix, 'share', 'pkgconfig', 'hwdata.pc'), + 'utf8' + ); + assert.match(pkgConfig, /^Version: 0\.409$/m); + assert.match( + pkgConfig, + new RegExp( + `^pkgdatadir=${path + .join(prefix, 'share', 'hwdata') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, + 'm' + ) + ); + assert.doesNotMatch(pkgConfig, /\/usr\/share\/hwdata/); + assert.equal(pinnedEnvironment.PKG_CONFIG_PATH.includes('/host'), false); + assert.equal(pinnedEnvironment.PKG_CONFIG_LIBDIR.includes('/usr'), false); + assert.equal( + resolveLinuxPackageBuildEnvironment('libdisplay-info', { + buildEnvironment, + hwdataBuildEnvironment: pinnedEnvironment, + }), + pinnedEnvironment + ); + assert.equal( + resolveLinuxPackageBuildEnvironment('mpv', { + buildEnvironment, + hwdataBuildEnvironment: pinnedEnvironment, + }), + buildEnvironment + ); + assert.throws( + () => + resolveLinuxPackageBuildEnvironment('libdisplay-info', { + buildEnvironment, + }), + /libdisplay-info.*pinned hwdata/i + ); + assert.deepEqual( + invocations.map(({ args, command }) => [command, ...args]), + [ + ['pkg-config', '--variable=pcfiledir', 'hwdata'], + ['pkg-config', '--variable=pkgdatadir', 'hwdata'], + ['pkg-config', '--modversion', 'hwdata'], + ] + ); + + assert.throws( + () => + preparePinnedHwdataBuildInput({ + buildEnvironment, + prefix: path.join(root, 'rejected-runtime'), + runCapture(_command, args) { + return args[0] === '--modversion' + ? '0.409' + : '/usr/share/hwdata'; + }, + sourcePath, + }), + /pinned hwdata.*host|host.*hwdata|resolved outside/i + ); +}); + +test('rejects duplicate option assignments in every Meson recipe', () => { + assert.doesNotThrow(() => + assertUniqueMesonOptionAssignments(BUILD_RECIPES) + ); + + for (const [packageId, duplicateFlag] of [ + ['fontconfig', '-Dxml-backend=libxml2'], + ['libplacebo', '-Dvulkan=enabled'], + ]) { + const duplicateRecipes = { + ...BUILD_RECIPES, + [packageId]: { + ...BUILD_RECIPES[packageId], + args: [...BUILD_RECIPES[packageId].args, duplicateFlag], + }, + }; + assert.throws( + () => assertUniqueMesonOptionAssignments(duplicateRecipes), + new RegExp( + `${packageId}.*${duplicateFlag.split('=')[0]}.*once`, + 'i' + ) + ); + } +}); + +test('constructs a prefix-only build environment and ignores hostile host flags', () => { + const environment = createBuildEnvironment({ + prefix: '/opt/runtime', + baseEnv: { + PATH: '/usr/bin', + KEEP_ME: 'benign', + CPPFLAGS: '-I/host/include', + CFLAGS: '-O0 -march=native', + CXXFLAGS: '-stdlib=hostile', + LDFLAGS: '-L/host/lib -Wl,--as-needed', + LD_LIBRARY_PATH: '/host/runtime', + LIBRARY_PATH: '/host/implicit', + CPATH: '/host/cpath', + C_INCLUDE_PATH: '/host/c-include', + CPLUS_INCLUDE_PATH: '/host/cxx-include', + CMAKE_PREFIX_PATH: '/host/cmake', + PKG_CONFIG_PATH: '/host/pkgconfig', + PKG_CONFIG_LIBDIR: '/host/pkgconfig-libdir', + PKG_CONFIG_SYSROOT_DIR: '/host/sysroot', + FONTCONFIG_PATH: '/host/fonts', + OPENSSL_MODULES: '/host/openssl-modules', + }, + systemPkgConfigDirs: [ + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/share/pkgconfig', + ], + }); + + assert.equal(environment.PATH, '/opt/runtime/bin:/usr/bin'); + assert.equal( + environment.PKG_CONFIG_PATH, + '/opt/runtime/lib/pkgconfig:/opt/runtime/share/pkgconfig' + ); + assert.equal( + environment.PKG_CONFIG_LIBDIR, + [ + '/opt/runtime/lib/pkgconfig', + '/opt/runtime/share/pkgconfig', + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/share/pkgconfig', + ].join(':') + ); + assert.equal(environment.CPPFLAGS, '-I/opt/runtime/include'); + assert.equal(environment.CFLAGS, '-fPIC -I/opt/runtime/include'); + assert.equal(environment.CXXFLAGS, '-fPIC -I/opt/runtime/include'); + assert.equal( + environment.LDFLAGS, + '-L/opt/runtime/lib -Wl,-rpath-link,/opt/runtime/lib' + ); + assert.equal(environment.LD_LIBRARY_PATH, '/opt/runtime/lib'); + assert.equal(environment.CMAKE_PREFIX_PATH, '/opt/runtime'); + assert.equal(environment.KEEP_ME, 'benign'); + for (const variable of [ + 'LIBRARY_PATH', + 'CPATH', + 'C_INCLUDE_PATH', + 'CPLUS_INCLUDE_PATH', + 'PKG_CONFIG_SYSROOT_DIR', + ]) { + assert.equal(environment[variable], undefined, variable); + } + assert.doesNotMatch(JSON.stringify(environment), /\/host\//); +}); + +test('rejects an existing unmarked output without changing its contents', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-output-ownership-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const outputPrefix = path.join(root, 'usr', 'local'); + fs.mkdirSync(outputPrefix, { recursive: true }); + fs.writeFileSync(path.join(outputPrefix, 'keep-me'), 'untouched'); + + assert.throws( + () => assertOwnedOutputDestination(outputPrefix), + /existing output.*ownership marker/i + ); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'keep-me'), 'utf8'), + 'untouched' + ); +}); + +test('atomically publishes only owned outputs and rolls back failures', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-output-publish-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const outputPrefix = path.join(root, 'runtime'); + fs.mkdirSync(outputPrefix); + fs.writeFileSync( + path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER), + 'iptvnator-embedded-mpv-linux-runtime-v1\n' + ); + fs.writeFileSync(path.join(outputPrefix, 'state'), 'previous'); + + const failedStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: 'failed', + }); + fs.writeFileSync(path.join(failedStagingPrefix, 'state'), 'replacement'); + let renameCount = 0; + const failingFileSystem = { + ...fs, + renameSync(source, destination) { + renameCount += 1; + if (renameCount === 2) { + throw new Error('injected publication failure'); + } + return fs.renameSync(source, destination); + }, + }; + assert.throws( + () => + publishOwnedOutput({ + fileSystem: failingFileSystem, + outputPrefix, + stagingPrefix: failedStagingPrefix, + token: 'rollback', + }), + /injected publication failure/ + ); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'state'), 'utf8'), + 'previous' + ); + assert.equal(fs.existsSync(failedStagingPrefix), false); + assert.deepEqual( + fs.readdirSync(root).filter((name) => name.includes('backup')), + [] + ); + + const successfulStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: 'successful', + }); + fs.writeFileSync( + path.join(successfulStagingPrefix, 'state'), + 'replacement' + ); + publishOwnedOutput({ + outputPrefix, + stagingPrefix: successfulStagingPrefix, + token: 'success', + }); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'state'), 'utf8'), + 'replacement' + ); + assert.equal( + fs.readFileSync( + path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER), + 'utf8' + ), + 'iptvnator-embedded-mpv-linux-runtime-v1\n' + ); +}); + +test('builds in an owned sibling before atomically publishing output', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.doesNotMatch( + builderSource, + /fs\.rmSync\(context\.prefix, \{ recursive: true, force: true \}\)/ + ); + const toolPreflight = builderSource.indexOf( + 'assertMinimumToolVersions(toolVersions)' + ); + const stagingMutation = builderSource.indexOf( + 'createOwnedStagingPrefix(outputPrefix' + ); + const publication = builderSource.indexOf('publishOwnedOutput({'); + assert.notEqual(stagingMutation, -1); + assert.notEqual(publication, -1); + assert.ok(toolPreflight < stagingMutation); + assert.ok(stagingMutation < publication); +}); + +test('uses fixed Linux x64 pkg-config directories without host discovery', () => { + assert.deepEqual(DEFAULT_SYSTEM_PKG_CONFIG_DIRS, [ + '/usr/lib/x86_64-linux-gnu/pkgconfig', + '/usr/lib64/pkgconfig', + '/usr/lib/pkgconfig', + '/usr/share/pkgconfig', + ]); + assert.deepEqual( + resolveSystemPkgConfigDirs({}), + DEFAULT_SYSTEM_PKG_CONFIG_DIRS + ); + assert.deepEqual( + resolveSystemPkgConfigDirs({ + IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS: + '/opt/interfaces/pkgconfig:/usr/share/pkgconfig:/opt/interfaces/pkgconfig', + }), + ['/opt/interfaces/pkgconfig', '/usr/share/pkgconfig'] + ); + assert.throws( + () => + resolveSystemPkgConfigDirs({ + IPTVNATOR_EMBEDDED_MPV_SYSTEM_PKG_CONFIG_DIRS: + 'relative/pkgconfig', + }), + /must contain only absolute paths/ + ); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.doesNotMatch(builderSource, /--variable['"],\s*['"]pc_path/); +}); + +test('declares only the intended Linux system interface packages', () => { + assert.deepEqual(EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, [ + 'alsa', + 'egl', + 'gbm', + 'gl', + 'libdrm', + 'libpulse', + 'libva', + 'libva-drm', + ]); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /pkg-config['"],\s*\[['"]--exists['"],\s*packageName\]/ + ); + assert.match( + builderSource, + /pkg-config['"],\s*\[['"]--modversion['"],\s*packageName\]/ + ); +}); + +test('requires ELF patching and inspection tools in addition to the build toolchain', () => { + for (const tool of [ + 'cc', + 'cmake', + 'curl', + 'git', + 'gperf', + 'make', + 'meson', + 'nasm', + 'ninja', + 'patchelf', + 'pkg-config', + 'readelf', + 'tar', + ]) { + assert.ok(REQUIRED_TOOLS.includes(tool), tool); + } +}); + +test('preflights every required tool against a supported minimum version', () => { + assert.deepEqual( + Object.keys(MINIMUM_TOOL_VERSIONS).sort(), + [...REQUIRED_TOOLS].sort() + ); + assert.equal(MINIMUM_TOOL_VERSIONS.meson, '1.6.0'); + assert.equal(MINIMUM_TOOL_VERSIONS.nasm, '2.15.05'); + assert.equal(MINIMUM_TOOL_VERSIONS.gperf, '3.1.0'); + + const supportedVersions = Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ); + assert.doesNotThrow(() => assertMinimumToolVersions(supportedVersions)); + + const missingTool = { ...supportedVersions }; + delete missingTool.nasm; + assert.throws( + () => assertMinimumToolVersions(missingTool), + /missing required tool version.*nasm/i + ); + + for (const [tool, oldVersion] of [ + ['gperf', 'GNU gperf 3.0.4'], + ['meson', 'meson 1.5.9'], + ['nasm', 'NASM version 2.15.04'], + ]) { + assert.throws( + () => + assertMinimumToolVersions({ + ...supportedVersions, + [tool]: oldVersion, + }), + new RegExp(`${tool}.*requires.*${MINIMUM_TOOL_VERSIONS[tool]}`, 'i') + ); + } +}); + +test('completes tool and system-package preflight before filesystem mutation', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + const buildRootMutation = builderSource.indexOf( + 'fs.mkdirSync(context.buildRoot' + ); + const toolPreflight = builderSource.indexOf( + 'assertMinimumToolVersions(toolVersions)' + ); + const packagePreflight = builderSource.indexOf( + 'verifySystemPkgConfigPackages(context)' + ); + const mesonPolicyPreflight = builderSource.indexOf( + 'assertUniqueMesonOptionAssignments(BUILD_RECIPES)' + ); + assert.notEqual(mesonPolicyPreflight, -1); + assert.notEqual(toolPreflight, -1); + assert.notEqual(packagePreflight, -1); + assert.ok(mesonPolicyPreflight < buildRootMutation); + assert.ok(toolPreflight < buildRootMutation); + assert.ok(packagePreflight < buildRootMutation); +}); + +test('uses DESTDIR with standard fontconfig and OpenSSL runtime paths', () => { + assert.deepEqual(RUNTIME_EXTERNAL_CONFIGURATION, { + fontconfig: { + configDirectory: '/etc/fonts', + templateDirectory: '/usr/share/fontconfig', + cacheDirectory: '/var/cache/fontconfig', + ownership: 'system', + }, + openssl: { + configFile: '/etc/ssl/openssl.cnf', + certificateFile: '/etc/ssl/cert.pem', + certificateDirectory: '/etc/ssl/certs', + ownership: 'system', + }, + }); + assert.doesNotMatch( + JSON.stringify(RUNTIME_EXTERNAL_CONFIGURATION), + /build|prefix|tmp/i + ); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match(builderSource, /DESTDIR/); + assert.match(builderSource, /installWithDestdir/); + assert.doesNotMatch( + builderSource, + /--openssldir=\$\{path\.join\(context\.prefix/ + ); +}); + +test('preserves relative library symlinks copied out of DESTDIR', async (t) => { + const { copyDirectoryContents } = await import( + pathToFileURL(builderScript).href + ); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-destdir-links-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const sourceDirectory = path.join(root, 'destdir', 'lib'); + const destinationDirectory = path.join(root, 'prefix', 'lib'); + fs.mkdirSync(sourceDirectory, { recursive: true }); + fs.writeFileSync(path.join(sourceDirectory, 'libcrypto.so.3'), 'crypto'); + fs.symlinkSync( + 'libcrypto.so.3', + path.join(sourceDirectory, 'libcrypto.so') + ); + + copyDirectoryContents(sourceDirectory, destinationDirectory); + fs.rmSync(path.join(root, 'destdir'), { + recursive: true, + force: true, + }); + + assert.equal( + fs.readlinkSync(path.join(destinationDirectory, 'libcrypto.so')), + 'libcrypto.so.3' + ); + assert.equal( + fs.readFileSync( + path.join(destinationDirectory, 'libcrypto.so'), + 'utf8' + ), + 'crypto' + ); +}); + +test('uses an explicit LGPL FFmpeg HTTPS and HLS protocol baseline', () => { + const required = [ + '--enable-shared', + '--disable-static', + '--disable-programs', + '--disable-doc', + '--disable-debug', + '--disable-autodetect', + '--disable-gpl', + '--disable-nonfree', + '--enable-pic', + '--enable-pthreads', + '--enable-openssl', + '--enable-network', + '--enable-protocol=file', + '--enable-protocol=http', + '--enable-protocol=https', + '--enable-protocol=tcp', + '--enable-protocol=tls', + '--enable-protocol=udp', + '--enable-protocol=crypto', + '--enable-protocol=data', + '--enable-demuxer=hls', + '--enable-vaapi', + ]; + + for (const flag of required) { + assert.ok(FFMPEG_CONFIGURE_FLAGS.includes(flag), `missing ${flag}`); + } + for (const forbidden of [ + '--enable-gpl', + '--enable-nonfree', + '--enable-version3', + ]) { + assert.equal(FFMPEG_CONFIGURE_FLAGS.includes(forbidden), false); + } +}); + +test('uses valid mpv v0.41 Meson option names and pins the Linux backends', () => { + const upstreamMpv041Options = new Set([ + 'aaudio', + 'alsa', + 'android-media-ndk', + 'audiotrack', + 'audiounit', + 'avfoundation', + 'build-date', + 'caca', + 'cdda', + 'coreaudio', + 'cocoa', + 'cplayer', + 'cplugins', + 'cuda-hwaccel', + 'cuda-interop', + 'd3d-hwaccel', + 'd3d11', + 'd3d9-hwaccel', + 'direct3d', + 'disable-packet-pool', + 'dmabuf-wayland', + 'drm', + 'dvbin', + 'dvdnav', + 'egl', + 'egl-android', + 'egl-angle', + 'egl-angle-lib', + 'egl-angle-win32', + 'egl-drm', + 'egl-wayland', + 'egl-x11', + 'fuzzers', + 'gbm', + 'gl', + 'gl-cocoa', + 'gl-dxinterop', + 'gl-dxinterop-d3d9', + 'gl-win32', + 'gl-x11', + 'gpl', + 'html-build', + 'iconv', + 'ios-gl', + 'jack', + 'javascript', + 'jpeg', + 'lcms2', + 'libarchive', + 'libavdevice', + 'libbluray', + 'libmpv', + 'lua', + 'macos-10-15-4-features', + 'macos-11-3-features', + 'macos-11-features', + 'macos-12-features', + 'macos-cocoa-cb', + 'macos-media-player', + 'macos-touchbar', + 'manpage-build', + 'openal', + 'opensles', + 'oss-audio', + 'pdf-build', + 'pipewire', + 'plain-gl', + 'pthread-debug', + 'pulse', + 'rubberband', + 'sdl2-audio', + 'sdl2-gamepad', + 'sdl2-video', + 'shaderc', + 'sixel', + 'sndio', + 'spirv-cross', + 'swift-build', + 'tests', + 'uchardet', + 'uwp', + 'vaapi', + 'vaapi-drm', + 'vaapi-wayland', + 'vaapi-win32', + 'vaapi-x11', + 'vapoursynth', + 'vdpau', + 'vdpau-gl-x11', + 'vector', + 'videotoolbox-gl', + 'videotoolbox-pl', + 'vulkan', + 'wasapi', + 'wayland', + 'win32-smtc', + 'win32-threads', + 'x11', + 'x11-clipboard', + 'xv', + 'zimg', + 'zlib', + ]); + + for (const flag of MPV_MESON_FLAGS) { + const optionName = flag.slice(2).split('=')[0]; + assert.ok( + upstreamMpv041Options.has(optionName), + `unknown mpv v0.41 option ${optionName}` + ); + assert.doesNotMatch(flag, /=auto$/); + } + + for (const required of [ + '-Dgpl=false', + '-Dlibmpv=true', + '-Dcplayer=false', + '-Dtests=false', + '-Dlua=disabled', + '-Djavascript=disabled', + '-Dcplugins=disabled', + '-Dmanpage-build=disabled', + '-Dhtml-build=disabled', + '-Dpdf-build=disabled', + '-Dlibarchive=disabled', + '-Dlibbluray=disabled', + '-Ddvdnav=disabled', + '-Dcdda=disabled', + '-Ddvbin=disabled', + '-Dvulkan=disabled', + '-Dshaderc=disabled', + '-Dspirv-cross=disabled', + '-Ddrm=enabled', + '-Dgl=enabled', + '-Dplain-gl=enabled', + '-Degl=enabled', + '-Dgbm=enabled', + '-Dpulse=enabled', + '-Dalsa=enabled', + '-Dvaapi=enabled', + '-Dvaapi-drm=enabled', + ]) { + assert.ok(MPV_MESON_FLAGS.includes(required), `missing ${required}`); + } + assert.equal(MPV_MESON_FLAGS.includes('-Ddrm=disabled'), false); + for (const optionName of ['drm', 'gbm', 'vaapi-drm']) { + const assignments = MPV_MESON_FLAGS.filter((flag) => + flag.startsWith(`-D${optionName}=`) + ); + assert.deepEqual(assignments, [`-D${optionName}=enabled`]); + } +}); + +test('keeps the external dependency allowlists explicit and deterministic', () => { + assert.deepEqual(GLIBC_TOOLCHAIN_ALLOWLIST, [ + 'ld-linux-x86-64.so.2', + 'libc.so.6', + 'libdl.so.2', + 'libgcc_s.so.1', + 'libm.so.6', + 'libpthread.so.0', + 'librt.so.1', + 'libstdc++.so.6', + ]); + assert.deepEqual( + EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + [ + 'libEGL.so.1', + 'libGL.so.1', + 'libGLX.so.0', + 'libOpenGL.so.0', + 'libasound.so.2', + 'libdrm.so.2', + 'libgbm.so.1', + 'libpulse.so.0', + 'libva-drm.so.2', + 'libva.so.2', + ] + ); + for (const externalLibrary of EXTERNAL_SYSTEM_LIBRARIES) { + assert.ok(externalLibrary.interface); + assert.ok(externalLibrary.reason); + } +}); + +test('parses readelf dynamic sections and validates an ORIGIN-only closure', () => { + const mpvDynamic = parseReadelfDynamic(` + 0x000000000000000e (SONAME) Library soname: [libmpv.so.2] + 0x0000000000000001 (NEEDED) Shared library: [libavcodec.so.62] + 0x0000000000000001 (NEEDED) Shared library: [libEGL.so.1] + 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] + 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] +`); + assert.deepEqual(mpvDynamic, { + needed: ['libEGL.so.1', 'libavcodec.so.62', 'libc.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libmpv.so.2', + }); + + const closure = validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libavcodec.so.62', + ...parseReadelfDynamic(` + 0x0000000000000001 (NEEDED) Shared library: [libm.so.6] + 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN] +`), + }, + { name: 'libmpv.so.2', ...mpvDynamic }, + ], + runtimeFileNames: ['libavcodec.so.62', 'libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }); + assert.deepEqual(closure.externalDependencies, [ + 'libEGL.so.1', + 'libc.so.6', + 'libm.so.6', + ]); + assert.deepEqual(closure.entries[1].needed, [ + 'libEGL.so.1', + 'libavcodec.so.62', + 'libc.so.6', + ]); + assert.equal(closure.entries[1].soname, 'libmpv.so.2'); + + const aliasClosure = validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so', + ...mpvDynamic, + }, + { + name: 'libmpv.so.2', + ...mpvDynamic, + }, + { + name: 'libavcodec.so.62', + needed: ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libavcodec.so.62', + }, + ], + runtimeFileNames: ['libavcodec.so.62', 'libmpv.so', 'libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }); + assert.equal( + aliasClosure.entries.find(({ name }) => name === 'libmpv.so').soname, + 'libmpv.so.2' + ); + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: aliasClosure.entries.map((entry) => + entry.name === 'libmpv.so' + ? { ...entry, soname: 'libmpv.so.99' } + : entry + ), + runtimeFileNames: [ + 'libavcodec.so.62', + 'libmpv.so', + 'libmpv.so.2', + ], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /libmpv\.so must declare a versioned SONAME present in the runtime closure/ + ); + + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so.2', + needed: ['libsurprise.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, + ], + runtimeFileNames: ['libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /not bundled or allowlisted.*libsurprise\.so\.1/ + ); + for (const forbiddenRunpath of [ + '/tmp/iptvnator-prefix/lib', + '/usr/local/lib', + '$ORIGIN:/tmp/host-lib', + ]) { + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so.2', + needed: ['libc.so.6'], + rpath: [], + runpath: [forbiddenRunpath], + }, + ], + runtimeFileNames: ['libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /RUNPATH/ + ); + } +}); + +test('retains only the reachable SONAME closure plus the libmpv linker alias', (t) => { + const entries = [ + { + name: 'libmpv.so', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libmpv.so.2', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libmpv.so.2.0.0', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libavcodec.so', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libavcodec.so.62', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libavcodec.so.62.1.0', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libunused.so.1', + soname: 'libunused.so.1', + needed: ['libc.so.6'], + }, + ]; + const retainedNames = selectReachableRuntimeLibraryNames(entries); + assert.deepEqual(retainedNames, [ + 'libavcodec.so.62', + 'libmpv.so', + 'libmpv.so.2', + ]); + + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-runtime-prune-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2.0.0'), 'mpv'); + fs.symlinkSync('libmpv.so.2.0.0', path.join(libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(libDir, 'libmpv.so')); + fs.writeFileSync(path.join(libDir, 'libavcodec.so.62.1.0'), 'codec'); + fs.symlinkSync( + 'libavcodec.so.62.1.0', + path.join(libDir, 'libavcodec.so.62') + ); + fs.symlinkSync('libavcodec.so.62', path.join(libDir, 'libavcodec.so')); + fs.writeFileSync(path.join(libDir, 'libunused.so.1'), 'unused'); + + retainRuntimeLibraries(libDir, retainedNames); + assert.deepEqual(fs.readdirSync(libDir).sort(), retainedNames); + for (const retainedName of retainedNames) { + assert.equal( + fs.lstatSync(path.join(libDir, retainedName)).isFile(), + true, + retainedName + ); + } +}); + +test('enforces the Ubuntu 22.04 GLIBC and GLIBCXX symbol ceilings', () => { + assert.deepEqual(PORTABLE_ABI_BASELINE, { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', + }); + const record = parseReadelfVersionInfo( + ` + 0x0010: Name: GLIBC_2.2.5 Flags: none Version: 7 + 0x0020: Name: GLIBC_2.34 Flags: none Version: 5 + 0x0030: Name: GLIBCXX_3.4.21 Flags: none Version: 4 + 0x0040: Name: GLIBCXX_3.4.29 Flags: none Version: 3 +`, + 'libmpv.so.2' + ); + assert.deepEqual(record, { + name: 'libmpv.so.2', + requiredGlibc: '2.34', + requiredGlibcxx: '3.4.29', + }); + assert.doesNotThrow(() => assertPortableAbiRecords([record])); + + for (const [field, version] of [ + ['requiredGlibc', '2.36'], + ['requiredGlibcxx', '3.4.31'], + ]) { + assert.throws( + () => + assertPortableAbiRecords([ + { + ...record, + [field]: version, + }, + ]), + /portable ABI baseline.*newer symbol/i + ); + } +}); + +test('rejects build hosts newer than the portable glibc baseline', () => { + assert.doesNotThrow(() => assertPortableBuildHostGlibc('2.35')); + assert.throws( + () => assertPortableBuildHostGlibc('2.36'), + /build host glibc 2\.36.*portable ABI baseline.*2\.35/i + ); + assert.throws( + () => assertPortableBuildHostGlibc(undefined), + /unable to determine the Linux build host glibc version/i + ); +}); + +test('inspects every retained runtime file for portable symbol versions', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /runCapture\('readelf', \[\s*'--version-info',\s*libraryPath,\s*\]\)/ + ); + assert.match(builderSource, /retainRuntimeLibraries\(libDir,/); + assert.match(builderSource, /assertPortableAbiRecords\(abiRecords\)/); +}); + +test('materializes symlink aliases and hashes every runtime library', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-builder-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + const contents = Buffer.from('fake-elf-runtime'); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2.0.0'), contents); + fs.symlinkSync('libmpv.so.2.0.0', path.join(libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(libDir, 'libmpv.so')); + + materializeLibrarySymlinks(libDir); + + for (const name of ['libmpv.so', 'libmpv.so.2', 'libmpv.so.2.0.0']) { + const filePath = path.join(libDir, name); + assert.equal(fs.lstatSync(filePath).isFile(), true); + assert.deepEqual(fs.readFileSync(filePath), contents); + } + assert.deepEqual(createRuntimeFileRecords(libDir), [ + { + name: 'libmpv.so', + size: contents.length, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }, + { + name: 'libmpv.so.2', + size: contents.length, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }, + { + name: 'libmpv.so.2.0.0', + size: contents.length, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + }, + ]); +}); + +test('generates a hash-complete manifest accepted by the Linux validator', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-manifest-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + fs.writeFileSync(path.join(libDir, 'libavcodec.so.62'), 'avcodec'); + fs.writeFileSync(path.join(libDir, 'libmpv.so'), 'mpv'); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2'), 'mpv'); + const runtimeFiles = createRuntimeFileRecords(libDir); + const sourceRecords = createPinnedSourceRecords(); + const abiRecords = runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })); + const manifest = createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles, + abiRecords, + dependencyClosure: { + entries: [ + { + name: 'libavcodec.so.62', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + }, + { + name: 'libmpv.so', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libEGL.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, + { + name: 'libmpv.so.2', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libEGL.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, + ], + externalDependencies: ['libEGL.so.1', 'libm.so.6'], + }, + buildHost: { + platform: 'linux', + arch: 'x64', + release: 'fixture-kernel', + glibcVersion: '2.35', + systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], + systemPkgConfigPackages: Object.fromEntries( + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((packageName) => [ + packageName, + `${packageName} fixture version`, + ]) + ), + tools: Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ), + }, + generatedAt: '2026-07-17T00:00:00.000Z', + }); + + assert.deepEqual(validateLinuxRuntimeManifest(manifest), []); + assert.equal( + manifest.runtimeTotalBytes, + runtimeFiles.reduce((total, runtimeFile) => total + runtimeFile.size, 0) + ); + assert.deepEqual(manifest.runtimeDependencyClosure.externalDependencies, [ + 'libEGL.so.1', + 'libm.so.6', + ]); + assert.equal( + manifest.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname, + 'libmpv.so.2' + ); + assert.deepEqual( + manifest.externalSystemLibraries, + EXTERNAL_SYSTEM_LIBRARIES + ); + assert.deepEqual(manifest.runtimeAbi, { + baseline: PORTABLE_ABI_BASELINE, + files: abiRecords, + }); + assert.deepEqual( + manifest.runtimeExternalConfiguration, + RUNTIME_EXTERNAL_CONFIGURATION + ); + assert.equal(manifest.buildHost.glibcVersion, '2.35'); + assert.deepEqual( + Object.keys(manifest.buildHost.systemPkgConfigPackages), + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES + ); + for (const sourcePackage of SOURCE_PACKAGES) { + if (sourcePackage.sourceKind === 'archive') { + assert.equal( + manifest.packages[sourcePackage.id].sourceSha256, + sourcePackage.expectedSha256 + ); + } + } + assert.equal( + manifest.packages.libplacebo.sourceGitCommit, + SOURCE_PACKAGES.find(({ id }) => id === 'libplacebo').expectedGitCommit + ); + assert.doesNotMatch(manifest.sourceDistribution, /TBD|TODO/i); + assert.match(manifest.sourceDistribution, /source archives/i); + assert.match(manifest.sourceDistribution, /libdisplay-info/i); + assert.match(manifest.sourceDistribution, /hwdata/i); + assert.match(manifest.sourceDistribution, /pnp\.ids/i); + assert.deepEqual(manifest.packages.hwdata.buildInput, { + consumer: 'libdisplay-info', + relativePath: 'pnp.ids', + purpose: 'PNP vendor lookup table compiled into libdisplay-info.', + }); +}); + +test('guards the CLI to Linux x64 and requires exactly one output prefix', () => { + assert.deepEqual( + parseCliInvocation({ + platform: 'linux', + arch: 'x64', + argv: ['/tmp/runtime'], + cwd: '/workspace', + }), + { prefix: '/tmp/runtime' } + ); + assert.deepEqual( + parseCliInvocation({ + platform: 'linux', + arch: 'x64', + argv: ['--', 'relative/runtime'], + cwd: '/workspace', + }), + { prefix: '/workspace/relative/runtime' } + ); + assert.throws( + () => + parseCliInvocation({ + platform: 'darwin', + arch: 'x64', + argv: ['/tmp/runtime'], + cwd: '/workspace', + }), + /supported on Linux x64 only.*darwin\/x64/ + ); + assert.throws( + () => + parseCliInvocation({ + platform: 'linux', + arch: 'arm64', + argv: ['/tmp/runtime'], + cwd: '/workspace', + }), + /supported on Linux x64 only.*linux\/arm64/ + ); + for (const argv of [[], ['/one', '/two']]) { + assert.throws( + () => + parseCliInvocation({ + platform: 'linux', + arch: 'x64', + argv, + cwd: '/workspace', + }), + /Usage: node tools\/embedded-mpv\/build-linux-runtime\.mjs / + ); + } +}); + +test('does not execute the Linux build when the entrypoint is imported', async () => { + await assert.doesNotReject(() => import(pathToFileURL(builderScript).href)); +}); + +test('patches every materialized ELF runtime to ORIGIN before validating closure', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /run\('patchelf', \['--set-rpath', '\$ORIGIN', libraryPath\]\)/ + ); + assert.match( + builderSource, + /runCapture\('readelf', \['-d', libraryPath\]\)/ + ); + assert.match(builderSource, /retainRuntimeLibraries\(libDir,/); + assert.match(builderSource, /validateRuntimeDependencyClosure\(\{/); + assert.match(builderSource, /createRuntimeFileRecords\(libDir\)/); + assert.match(builderSource, /runtime-manifest\.json/); + assert.match(builderSource, /validateLinuxRuntimeManifest\(manifest\)/); +}); + +test('registers the builder script and focused test with package and Nx', () => { + const packageMetadata = JSON.parse( + fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8') + ); + assert.equal( + packageMetadata.scripts['embedded-mpv:build-runtime:linux'], + 'node tools/embedded-mpv/build-linux-runtime.mjs' + ); + + const packagingProject = JSON.parse( + fs.readFileSync( + path.join(workspaceRoot, 'tools', 'packaging', 'project.json'), + 'utf8' + ) + ); + const inputs = packagingProject.targets.test.inputs; + for (const registeredInput of [ + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs', + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs', + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs', + ]) { + assert.ok(inputs.includes(registeredInput), registeredInput); + } + assert.match( + packagingProject.targets.test.options.command, + /tools\/embedded-mpv\/build-linux-runtime\.test\.mjs/ + ); + + const lintInputs = packagingProject.targets.lint.inputs; + for (const registeredInput of [ + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs', + '{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs', + ]) { + assert.ok(lintInputs.includes(registeredInput), registeredInput); + } + assert.match( + packagingProject.targets.lint.command, + /tools\/embedded-mpv\/build-linux-runtime\.\{mjs,test\.mjs\}/ + ); +}); diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.cjs b/tools/embedded-mpv/generate-linux-runtime-notices.cjs new file mode 100644 index 000000000..ec2a44dc1 --- /dev/null +++ b/tools/embedded-mpv/generate-linux-runtime-notices.cjs @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs'); + +const LICENSE_INPUT_MANIFEST = 'linux-runtime-license-inputs.json'; +const NOTICE_MANIFEST = 'embedded-mpv-notices.json'; +const THIRD_PARTY_NOTICES = 'THIRD_PARTY_NOTICES.txt'; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0); +const NOTICE_SOURCE_PACKAGES = Object.freeze( + [...SOURCE_PACKAGES].sort(({ id: left }, { id: right }) => + compareText(left, right) + ) +); + +const LICENSE_PATHS_BY_PACKAGE = Object.freeze({ + freetype: Object.freeze(['LICENSE.TXT', 'docs/FTL.TXT']), + fribidi: Object.freeze(['COPYING']), + harfbuzz: Object.freeze(['COPYING']), + expat: Object.freeze(['COPYING']), + fontconfig: Object.freeze(['COPYING']), + libass: Object.freeze(['COPYING']), + openssl: Object.freeze(['LICENSE.txt']), + ffmpeg: Object.freeze(['LICENSE.md', 'COPYING.LGPLv2.1']), + libplacebo: Object.freeze([ + 'LICENSE', + '3rdparty/Vulkan-Headers/LICENSE.md', + '3rdparty/fast_float/LICENSE-APACHE', + '3rdparty/fast_float/LICENSE-BOOST', + '3rdparty/fast_float/LICENSE-MIT', + '3rdparty/glad/LICENSE', + '3rdparty/jinja/LICENSE.txt', + '3rdparty/markupsafe/LICENSE.txt', + 'demos/3rdparty/nuklear/LICENSE', + ]), + hwdata: Object.freeze(['LICENSE', 'COPYING']), + 'libdisplay-info': Object.freeze(['LICENSE']), + mpv: Object.freeze(['Copyright', 'LICENSE.LGPL']), +}); + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function hasExactFields(value, fields) { + return ( + isObject(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()) + ); +} + +function isPathInside(root, candidate) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + +function assertDirectoryWithoutSymlinks(directoryPath, label) { + let stat; + try { + stat = fs.lstatSync(directoryPath); + } catch { + throw new Error(`Missing ${label}: ${directoryPath}`); + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a real directory: ${directoryPath}`); + } + return fs.realpathSync(directoryPath); +} + +function assertRegularFileInside(root, relativePath, label) { + if ( + typeof relativePath !== 'string' || + relativePath.length === 0 || + path.isAbsolute(relativePath) || + relativePath.split(/[\\/]/).some((part) => part === '..' || part === '') + ) { + throw new Error( + `${label} has an unsafe relative path: ${relativePath}` + ); + } + const realRoot = assertDirectoryWithoutSymlinks(root, `${label} root`); + const candidate = path.resolve(root, ...relativePath.split('/')); + if (!isPathInside(path.resolve(root), candidate)) { + throw new Error(`${label} resolves outside ${root}: ${relativePath}`); + } + + let cursor = path.resolve(root); + const parts = path.relative(cursor, candidate).split(path.sep); + for (const [index, part] of parts.entries()) { + cursor = path.join(cursor, part); + let stat; + try { + stat = fs.lstatSync(cursor); + } catch { + throw new Error(`Missing ${label}: ${cursor}`); + } + if (stat.isSymbolicLink()) { + throw new Error(`${label} must not use a symbolic link: ${cursor}`); + } + if (index < parts.length - 1 && !stat.isDirectory()) { + throw new Error(`${label} parent must be a directory: ${cursor}`); + } + if (index === parts.length - 1 && !stat.isFile()) { + throw new Error(`${label} must be a regular file: ${cursor}`); + } + } + + const realCandidate = fs.realpathSync(candidate); + if (!isPathInside(realRoot, realCandidate)) { + throw new Error( + `${label} resolves outside its trusted root: ${relativePath}` + ); + } + return realCandidate; +} + +function sourcePackageMetadata(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { sourceGitCommit: sourcePackage.expectedGitCommit }), + license: sourcePackage.license, + }; +} + +function validateRuntimeManifestPackages(runtimeManifest) { + if ( + !isObject(runtimeManifest) || + runtimeManifest.platform !== 'linux' || + runtimeManifest.arch !== 'x64' || + !isObject(runtimeManifest.packages) || + !isDeepStrictEqual( + Object.keys(runtimeManifest.packages).sort(), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ) + ) { + throw new Error( + 'Linux runtime notice generation requires the exact pinned linux-x64 package manifest.' + ); + } + for (const sourcePackage of SOURCE_PACKAGES) { + const actual = runtimeManifest.packages[sourcePackage.id]; + const expected = sourcePackageMetadata(sourcePackage); + for (const [field, expectedValue] of Object.entries(expected)) { + if (!isDeepStrictEqual(actual?.[field], expectedValue)) { + throw new Error( + `Linux runtime package ${sourcePackage.id}.${field} does not match its immutable pin.` + ); + } + } + if ( + sourcePackage.sourceKind === 'git' && + (!Array.isArray(actual.sourceSubmodules) || + actual.sourceSubmodules.length === 0) + ) { + throw new Error( + `Linux runtime package ${sourcePackage.id} must record pinned submodules.` + ); + } + const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]; + if (!Array.isArray(licensePaths) || licensePaths.length === 0) { + throw new Error( + `Linux runtime package ${sourcePackage.id} has no pinned upstream license files.` + ); + } + } +} + +function fileRecord(filePath, relativePath) { + const contents = fs.readFileSync(filePath); + return { + path: relativePath.split(path.sep).join('/'), + size: contents.length, + sha256: sha256(contents), + }; +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o644, + }); +} + +function replaceDirectory(outputRoot, writer) { + const resolvedOutputRoot = path.resolve(outputRoot); + const temporaryRoot = `${resolvedOutputRoot}.tmp-${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}`; + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + fs.mkdirSync(temporaryRoot, { recursive: true }); + try { + const result = writer(temporaryRoot); + fs.rmSync(resolvedOutputRoot, { recursive: true, force: true }); + fs.renameSync(temporaryRoot, resolvedOutputRoot); + return result; + } catch (error) { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + throw error; + } +} + +function expectedLicensePath(packageId, sourceRelativePath) { + return path.posix.join('licenses', packageId, sourceRelativePath); +} + +function collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot, + runtimeManifest, +}) { + validateRuntimeManifestPackages(runtimeManifest); + assertDirectoryWithoutSymlinks(sourceRoot, 'Linux runtime source root'); + + return replaceDirectory(outputRoot, (temporaryRoot) => { + const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage) => { + const files = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map( + (sourceRelativePath) => { + const sourcePath = assertRegularFileInside( + path.join(sourceRoot, sourcePackage.id), + sourceRelativePath, + `${sourcePackage.id} upstream license` + ); + const relativeOutputPath = expectedLicensePath( + sourcePackage.id, + sourceRelativePath + ); + const destinationPath = path.join( + temporaryRoot, + ...relativeOutputPath.split('/') + ); + fs.mkdirSync(path.dirname(destinationPath), { + recursive: true, + }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o644); + return { + sourcePath: sourceRelativePath, + ...fileRecord(destinationPath, relativeOutputPath), + }; + } + ); + return { + id: sourcePackage.id, + ...sourcePackageMetadata(sourcePackage), + files, + }; + }); + const manifest = { + schemaVersion: 1, + origin: 'pinned-linux-runtime-license-inputs', + platform: 'linux', + arch: 'x64', + packages, + }; + writeJson(path.join(temporaryRoot, LICENSE_INPUT_MANIFEST), manifest); + return manifest; + }); +} + +function listFilesRecursively(root) { + const files = []; + function visit(directoryPath) { + for (const entry of fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => compareText(left.name, right.name))) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isSymbolicLink()) { + throw new Error( + `Linux runtime legal files must not use symbolic links: ${entryPath}` + ); + } + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.isFile()) { + files.push( + path.relative(root, entryPath).split(path.sep).join('/') + ); + } else { + throw new Error( + `Linux runtime legal input must be a regular file: ${entryPath}` + ); + } + } + } + visit(root); + return files; +} + +function readJsonFileInside(root, relativePath, label) { + const filePath = assertRegularFileInside(root, relativePath, label); + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new Error( + `Invalid JSON in ${label}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function validateLicenseInputManifest(inputRoot, runtimeManifest) { + validateRuntimeManifestPackages(runtimeManifest); + const inputManifest = readJsonFileInside( + inputRoot, + LICENSE_INPUT_MANIFEST, + 'Linux runtime license input manifest' + ); + if ( + !hasExactFields(inputManifest, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'packages', + ]) || + inputManifest.schemaVersion !== 1 || + inputManifest.origin !== 'pinned-linux-runtime-license-inputs' || + inputManifest.platform !== 'linux' || + inputManifest.arch !== 'x64' || + !Array.isArray(inputManifest.packages) || + inputManifest.packages.length !== NOTICE_SOURCE_PACKAGES.length + ) { + throw new Error('Invalid Linux runtime license input manifest.'); + } + + const expectedPaths = new Set([LICENSE_INPUT_MANIFEST]); + for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) { + const packageRecord = inputManifest.packages[index]; + const expectedMetadata = sourcePackageMetadata(sourcePackage); + if ( + !hasExactFields(packageRecord, [ + 'id', + ...Object.keys(expectedMetadata), + 'files', + ]) || + packageRecord.id !== sourcePackage.id || + !Object.entries(expectedMetadata).every(([field, value]) => + isDeepStrictEqual(packageRecord[field], value) + ) || + !Array.isArray(packageRecord.files) || + packageRecord.files.length !== + LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].length + ) { + throw new Error( + `Invalid cached license inputs for ${sourcePackage.id}.` + ); + } + for (const [fileIndex, sourceRelativePath] of LICENSE_PATHS_BY_PACKAGE[ + sourcePackage.id + ].entries()) { + const record = packageRecord.files[fileIndex]; + const expectedPath = expectedLicensePath( + sourcePackage.id, + sourceRelativePath + ); + if ( + !hasExactFields(record, [ + 'sourcePath', + 'path', + 'size', + 'sha256', + ]) || + record.sourcePath !== sourceRelativePath || + record.path !== expectedPath || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error( + `Invalid cached license file record for ${sourcePackage.id}.` + ); + } + const inputPath = assertRegularFileInside( + inputRoot, + record.path, + `${sourcePackage.id} license input` + ); + const actual = fileRecord(inputPath, record.path); + if (actual.size !== record.size) { + throw new Error( + `Size mismatch for cached license input ${record.path}.` + ); + } + if (actual.sha256 !== record.sha256) { + throw new Error( + `SHA-256 mismatch for cached license input ${record.path}.` + ); + } + expectedPaths.add(record.path); + } + } + for (const actualPath of listFilesRecursively(inputRoot)) { + if (!expectedPaths.has(actualPath)) { + throw new Error( + `Found undeclared license input ${actualPath} in ${inputRoot}.` + ); + } + } + return inputManifest; +} + +function noticePackageRecord(sourcePackage, inputPackage) { + return { + id: sourcePackage.id, + ...sourcePackageMetadata(sourcePackage), + files: inputPackage.files.map( + ({ path: filePath, size, sha256: hash }) => ({ + path: filePath, + size, + sha256: hash, + }) + ), + }; +} + +function createThirdPartyNotices(packages) { + const lines = [ + 'IPTVnator Linux Embedded MPV Third-Party Notices', + '================================================', + '', + 'This file identifies the pinned upstream source packages used by the bundled Linux Embedded MPV runtime.', + 'The complete verbatim upstream license files are included at the paths and SHA-256 digests listed below.', + 'The exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources.tar.xz.', + '', + ]; + for (const packageRecord of packages) { + lines.push( + `${packageRecord.id} ${packageRecord.version}`, + `License: ${packageRecord.license}`, + `Source: ${packageRecord.sourceUrl}`, + 'Included upstream license files:' + ); + for (const file of packageRecord.files) { + lines.push(`- ${file.path} (SHA-256 ${file.sha256})`); + } + lines.push(''); + } + return `${lines.join('\n')}\n`; +} + +function generateLinuxRuntimeNotices({ + licenseInputRoot, + outputRoot, + runtimeManifest, +}) { + const inputManifest = validateLicenseInputManifest( + licenseInputRoot, + runtimeManifest + ); + return replaceDirectory(outputRoot, (temporaryRoot) => { + const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage, index) => { + const inputPackage = inputManifest.packages[index]; + for (const file of inputPackage.files) { + const sourcePath = assertRegularFileInside( + licenseInputRoot, + file.path, + `${sourcePackage.id} cached license` + ); + const destinationPath = path.join( + temporaryRoot, + ...file.path.split('/') + ); + fs.mkdirSync(path.dirname(destinationPath), { + recursive: true, + }); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o644); + } + return noticePackageRecord(sourcePackage, inputPackage); + }); + const noticeContents = Buffer.from(createThirdPartyNotices(packages)); + const noticePath = path.join(temporaryRoot, THIRD_PARTY_NOTICES); + fs.writeFileSync(noticePath, noticeContents, { mode: 0o644 }); + const noticeFile = fileRecord(noticePath, THIRD_PARTY_NOTICES); + const licenseTotalBytes = packages + .flatMap(({ files }) => files) + .reduce((total, file) => total + file.size, 0); + const manifest = { + schemaVersion: 1, + origin: 'pinned-linux-runtime-upstream-licenses', + platform: 'linux', + arch: 'x64', + noticeFile, + packages, + totalBytes: noticeFile.size + licenseTotalBytes, + }; + writeJson(path.join(temporaryRoot, NOTICE_MANIFEST), manifest); + const errors = validateLinuxRuntimeNotices( + temporaryRoot, + runtimeManifest + ); + if (errors.length > 0) { + throw new Error( + [ + 'Generated Linux runtime notices are invalid.', + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + return manifest; + }); +} + +function assertValidLinuxRuntimeNotices( + root, + runtimeManifest, + { allowUnrelatedFiles = false } = {} +) { + validateRuntimeManifestPackages(runtimeManifest); + assertDirectoryWithoutSymlinks(root, 'Linux runtime notices root'); + const manifest = readJsonFileInside( + root, + NOTICE_MANIFEST, + 'Linux runtime notices manifest' + ); + if ( + !hasExactFields(manifest, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'noticeFile', + 'packages', + 'totalBytes', + ]) || + manifest.schemaVersion !== 1 || + manifest.origin !== 'pinned-linux-runtime-upstream-licenses' || + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + !Array.isArray(manifest.packages) || + manifest.packages.length !== NOTICE_SOURCE_PACKAGES.length + ) { + throw new Error('Invalid Linux runtime notices manifest.'); + } + + const expectedFiles = new Set([NOTICE_MANIFEST, THIRD_PARTY_NOTICES]); + let licenseTotalBytes = 0; + for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) { + const packageRecord = manifest.packages[index]; + const expectedMetadata = sourcePackageMetadata(sourcePackage); + const expectedPaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map( + (sourceRelativePath) => + expectedLicensePath(sourcePackage.id, sourceRelativePath) + ); + if ( + !hasExactFields(packageRecord, [ + 'id', + ...Object.keys(expectedMetadata), + 'files', + ]) || + packageRecord.id !== sourcePackage.id || + !Object.entries(expectedMetadata).every(([field, value]) => + isDeepStrictEqual(packageRecord[field], value) + ) || + !Array.isArray(packageRecord.files) || + !isDeepStrictEqual( + packageRecord.files.map(({ path: filePath }) => filePath), + expectedPaths + ) + ) { + throw new Error( + `Invalid packaged notice record for ${sourcePackage.id}.` + ); + } + for (const record of packageRecord.files) { + if ( + !hasExactFields(record, ['path', 'size', 'sha256']) || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error( + `Invalid packaged notice file for ${sourcePackage.id}.` + ); + } + const filePath = assertRegularFileInside( + root, + record.path, + `${sourcePackage.id} packaged license` + ); + const actual = fileRecord(filePath, record.path); + if (actual.size !== record.size) { + throw new Error( + `Size mismatch for packaged license ${record.path}.` + ); + } + if (actual.sha256 !== record.sha256) { + throw new Error( + `SHA-256 mismatch for packaged license ${record.path}.` + ); + } + licenseTotalBytes += record.size; + expectedFiles.add(record.path); + } + } + + if ( + !hasExactFields(manifest.noticeFile, ['path', 'size', 'sha256']) || + manifest.noticeFile.path !== THIRD_PARTY_NOTICES || + !Number.isSafeInteger(manifest.noticeFile.size) || + manifest.noticeFile.size <= 0 || + !SHA256_PATTERN.test(manifest.noticeFile.sha256) + ) { + throw new Error('Invalid aggregate third-party notice file record.'); + } + const noticePath = assertRegularFileInside( + root, + THIRD_PARTY_NOTICES, + 'aggregate third-party notices' + ); + const actualNotice = fileRecord(noticePath, THIRD_PARTY_NOTICES); + if ( + actualNotice.size !== manifest.noticeFile.size || + actualNotice.sha256 !== manifest.noticeFile.sha256 + ) { + throw new Error( + 'Aggregate THIRD_PARTY_NOTICES.txt size or SHA-256 mismatch.' + ); + } + const expectedNoticeContents = createThirdPartyNotices(manifest.packages); + if (fs.readFileSync(noticePath, 'utf8') !== expectedNoticeContents) { + throw new Error( + 'Aggregate THIRD_PARTY_NOTICES.txt does not match the exact notice index.' + ); + } + if (manifest.totalBytes !== actualNotice.size + licenseTotalBytes) { + throw new Error( + 'Linux runtime notices totalBytes does not match declared legal files.' + ); + } + const actualPaths = allowUnrelatedFiles + ? [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + ...listFilesRecursively(path.join(root, 'licenses')).map( + (relativePath) => path.posix.join('licenses', relativePath) + ), + ] + : listFilesRecursively(root); + for (const actualPath of actualPaths) { + if (!expectedFiles.has(actualPath)) { + throw new Error( + `Found undeclared packaged legal file ${actualPath} in ${root}.` + ); + } + } + return manifest; +} + +function validateLinuxRuntimeNotices(root, runtimeManifest, options) { + try { + assertValidLinuxRuntimeNotices(root, runtimeManifest, options); + return []; + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } +} + +function parseArguments(argv) { + const args = argv[0] === '--' ? argv.slice(1) : [...argv]; + const mode = args.shift(); + if (!['collect', 'generate'].includes(mode)) { + throw new Error( + 'Usage: generate-linux-runtime-notices.cjs --runtime-manifest --output-root [--source-root |--license-input-root ]' + ); + } + const values = {}; + while (args.length > 0) { + const key = args.shift(); + const value = args.shift(); + if (!key?.startsWith('--') || !value) { + throw new Error(`Invalid Linux runtime notices argument: ${key}`); + } + const name = key.slice(2); + if (Object.hasOwn(values, name)) { + throw new Error(`Duplicate Linux runtime notices argument: ${key}`); + } + values[name] = value; + } + const required = [ + 'runtime-manifest', + 'output-root', + mode === 'collect' ? 'source-root' : 'license-input-root', + ]; + for (const name of required) { + if (!values[name]) { + throw new Error(`Missing --${name}.`); + } + } + return { mode, values }; +} + +function main(argv = process.argv.slice(2)) { + const { mode, values } = parseArguments(argv); + const runtimeManifest = JSON.parse( + fs.readFileSync(path.resolve(values['runtime-manifest']), 'utf8') + ); + if (mode === 'collect') { + collectLinuxRuntimeLicenseInputs({ + sourceRoot: path.resolve(values['source-root']), + outputRoot: path.resolve(values['output-root']), + runtimeManifest, + }); + } else { + generateLinuxRuntimeNotices({ + licenseInputRoot: path.resolve(values['license-input-root']), + outputRoot: path.resolve(values['output-root']), + runtimeManifest, + }); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} + +module.exports = { + LICENSE_INPUT_MANIFEST, + LICENSE_PATHS_BY_PACKAGE, + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, + validateLinuxRuntimeNotices, +}; diff --git a/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs new file mode 100644 index 000000000..7e9942342 --- /dev/null +++ b/tools/embedded-mpv/generate-linux-runtime-notices.test.mjs @@ -0,0 +1,338 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs'); +const { + LICENSE_PATHS_BY_PACKAGE, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, + validateLinuxRuntimeNotices, +} = require('./generate-linux-runtime-notices.cjs'); +const generatorScript = path.join( + path.dirname(fileURLToPath(import.meta.url)), + 'generate-linux-runtime-notices.cjs' +); + +function runtimeManifest() { + return { + platform: 'linux', + arch: 'x64', + packages: Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [ + ...sourcePackage.expectedSubmodules, + ], + }), + license: sourcePackage.license, + ...(sourcePackage.buildInput + ? { buildInput: sourcePackage.buildInput } + : {}), + }, + ]) + ), + }; +} + +function createSourceFixture() { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-sources-') + ); + for (const sourcePackage of SOURCE_PACKAGES) { + const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]; + assert.ok( + Array.isArray(licensePaths) && licensePaths.length > 0, + `missing test mapping for ${sourcePackage.id}` + ); + for (const relativePath of licensePaths) { + const filePath = path.join(root, sourcePackage.id, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync( + filePath, + `verbatim upstream ${sourcePackage.id} ${relativePath}\n` + ); + } + } + return root; +} + +function fileTree(root) { + const files = []; + function visit(directoryPath) { + for (const entry of fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name))) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else { + files.push({ + path: path + .relative(root, entryPath) + .split(path.sep) + .join('/'), + contents: fs.readFileSync(entryPath), + }); + } + } + } + visit(root); + return files; +} + +test('collects verbatim pinned licenses and generates deterministic exact notices', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-notices-') + ); + const inputRoot = path.join(fixtureRoot, 'inputs'); + const firstOutput = path.join(fixtureRoot, 'first'); + const secondOutput = path.join(fixtureRoot, 'second'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const manifest = runtimeManifest(); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + const first = generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: firstOutput, + runtimeManifest: manifest, + }); + const second = generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: secondOutput, + runtimeManifest: manifest, + }); + + assert.deepEqual(fileTree(firstOutput), fileTree(secondOutput)); + assert.deepEqual(first, second); + assert.equal(first.schemaVersion, 1); + assert.equal(first.origin, 'pinned-linux-runtime-upstream-licenses'); + assert.deepEqual( + first.packages.map(({ id }) => id), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ); + assert.ok(first.packages.every(({ files }) => files.length >= 1)); + assert.deepEqual(LICENSE_PATHS_BY_PACKAGE.libplacebo, [ + 'LICENSE', + '3rdparty/Vulkan-Headers/LICENSE.md', + '3rdparty/fast_float/LICENSE-APACHE', + '3rdparty/fast_float/LICENSE-BOOST', + '3rdparty/fast_float/LICENSE-MIT', + '3rdparty/glad/LICENSE', + '3rdparty/jinja/LICENSE.txt', + '3rdparty/markupsafe/LICENSE.txt', + 'demos/3rdparty/nuklear/LICENSE', + ]); + assert.deepEqual(validateLinuxRuntimeNotices(firstOutput, manifest), []); + + const noticeContents = fs.readFileSync( + path.join(firstOutput, 'THIRD_PARTY_NOTICES.txt') + ); + assert.equal(first.noticeFile.path, 'THIRD_PARTY_NOTICES.txt'); + assert.equal(first.noticeFile.size, noticeContents.length); + assert.equal( + first.noticeFile.sha256, + crypto.createHash('sha256').update(noticeContents).digest('hex') + ); + assert.match( + noticeContents.toString('utf8'), + /exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources\.tar\.xz/ + ); + + for (const packageRecord of first.packages) { + for (const fileRecord of packageRecord.files) { + const outputContents = fs.readFileSync( + path.join(firstOutput, fileRecord.path) + ); + const sourcePath = fileRecord.path.slice( + `licenses/${packageRecord.id}/`.length + ); + assert.ok( + LICENSE_PATHS_BY_PACKAGE[packageRecord.id].includes(sourcePath) + ); + assert.deepEqual( + outputContents, + fs.readFileSync( + path.join(sourceRoot, packageRecord.id, sourcePath) + ) + ); + } + } +}); + +test('rejects symlinked license sources and path escapes', (t) => { + const sourceRoot = createSourceFixture(); + const outputRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-inputs-') + ); + const outsidePath = path.join(outputRoot, 'outside-license'); + fs.writeFileSync(outsidePath, 'outside\n'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(outputRoot, { recursive: true, force: true }); + }); + + const freetypeLicense = path.join( + sourceRoot, + 'freetype', + LICENSE_PATHS_BY_PACKAGE.freetype[0] + ); + fs.rmSync(freetypeLicense); + fs.symlinkSync(outsidePath, freetypeLicense); + + assert.throws( + () => + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: path.join(outputRoot, 'collected'), + runtimeManifest: runtimeManifest(), + }), + /symbolic link|outside/i + ); +}); + +test('rejects missing, tampered, and undeclared cached license inputs', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-license-cache-') + ); + const inputRoot = path.join(fixtureRoot, 'inputs'); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + const manifest = runtimeManifest(); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + const collectedManifest = JSON.parse( + fs.readFileSync( + path.join(inputRoot, 'linux-runtime-license-inputs.json'), + 'utf8' + ) + ); + const firstLicensePath = path.join( + inputRoot, + collectedManifest.packages[0].files[0].path + ); + fs.appendFileSync(firstLicensePath, 'tampered\n'); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'tampered-output'), + runtimeManifest: manifest, + }), + /(?:Size|SHA-256) mismatch/ + ); + + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: inputRoot, + runtimeManifest: manifest, + }); + fs.writeFileSync(path.join(inputRoot, 'licenses', 'undeclared.txt'), 'x'); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'extra-output'), + runtimeManifest: manifest, + }), + /undeclared license input/ + ); + + fs.rmSync(path.join(inputRoot, 'licenses', 'undeclared.txt')); + fs.rmSync( + path.join(inputRoot, collectedManifest.packages[0].files[0].path) + ); + assert.throws( + () => + generateLinuxRuntimeNotices({ + licenseInputRoot: inputRoot, + outputRoot: path.join(fixtureRoot, 'missing-output'), + runtimeManifest: manifest, + }), + /Missing .*license input/ + ); +}); + +test('CLI collects immutable inputs and regenerates the packaged notice bundle', (t) => { + const sourceRoot = createSourceFixture(); + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-notices-cli-') + ); + const manifestPath = path.join(fixtureRoot, 'runtime-manifest.json'); + const inputRoot = path.join(fixtureRoot, 'inputs'); + const outputRoot = path.join(fixtureRoot, 'notices'); + fs.writeFileSync( + manifestPath, + `${JSON.stringify(runtimeManifest(), null, 2)}\n` + ); + t.after(() => { + fs.rmSync(sourceRoot, { recursive: true, force: true }); + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + }); + + for (const args of [ + [ + 'collect', + '--runtime-manifest', + manifestPath, + '--source-root', + sourceRoot, + '--output-root', + inputRoot, + ], + [ + 'generate', + '--runtime-manifest', + manifestPath, + '--license-input-root', + inputRoot, + '--output-root', + outputRoot, + ], + ]) { + const result = spawnSync(process.execPath, [generatorScript, ...args], { + encoding: 'utf8', + }); + assert.equal( + result.status, + 0, + [result.stdout, result.stderr].filter(Boolean).join('\n') + ); + } + assert.deepEqual( + validateLinuxRuntimeNotices(outputRoot, runtimeManifest()), + [] + ); +}); diff --git a/tools/embedded-mpv/linux-runtime-manifest.cjs b/tools/embedded-mpv/linux-runtime-manifest.cjs new file mode 100644 index 000000000..4230ee129 --- /dev/null +++ b/tools/embedded-mpv/linux-runtime-manifest.cjs @@ -0,0 +1,999 @@ +'use strict'; + +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, + compareVersions, + parseVersion, +} = require('./build-linux-runtime.cjs'); + +const LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION = 1; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const REQUIRED_SOURCE_PACKAGES = SOURCE_PACKAGES.map(({ id }) => id); +const SOURCE_PACKAGE_BY_ID = new Map( + SOURCE_PACKAGES.map((sourcePackage) => [sourcePackage.id, sourcePackage]) +); +const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), +]); +const SUBMODULE_RECORD_PATTERN = /^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)$/; +const LINUX_SYSTEM_BACKEND = 'process-isolated mpv --wid'; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isSafeBasename(value) { + return ( + isNonEmptyString(value) && + path.basename(value) === value && + !value.includes('/') && + !value.includes('\\') && + value !== '.' && + value !== '..' && + SAFE_BASENAME_PATTERN.test(value) + ); +} + +function isValidSubmoduleRecord(value) { + if (typeof value !== 'string') { + return false; + } + const match = value.match(SUBMODULE_RECORD_PATTERN); + if (!match) { + return false; + } + const submodulePath = match[1]; + return ( + !path.posix.isAbsolute(submodulePath) && + !submodulePath + .split('/') + .some((segment) => segment === '.' || segment === '..') + ); +} + +function validateSourceMetadata(errors, value, label) { + if (!isNonEmptyString(value.version)) { + errors.push( + `Linux runtime manifest ${label}.version must be a non-empty string.` + ); + } + if (!isNonEmptyString(value.sourceUrl)) { + errors.push( + `Linux runtime manifest ${label}.sourceUrl must be a non-empty string.` + ); + } + + const hasSourceSha256 = value.sourceSha256 !== undefined; + const hasSourceGitCommit = value.sourceGitCommit !== undefined; + if (!hasSourceSha256 && !hasSourceGitCommit) { + errors.push( + `Linux runtime manifest ${label} must include sourceSha256 or sourceGitCommit.` + ); + return; + } + if ( + hasSourceSha256 && + (typeof value.sourceSha256 !== 'string' || + !SHA256_PATTERN.test(value.sourceSha256)) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSha256 must be a lowercase 64-character hexadecimal digest.` + ); + } + if ( + hasSourceGitCommit && + (typeof value.sourceGitCommit !== 'string' || + !GIT_COMMIT_PATTERN.test(value.sourceGitCommit)) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceGitCommit must be a lowercase hexadecimal commit digest.` + ); + } +} + +function validatePackages(errors, packages) { + if (!isObject(packages)) { + errors.push( + 'Linux runtime manifest packages must contain source package metadata.' + ); + return; + } + + if (Object.keys(packages).length === 0) { + errors.push( + 'Linux runtime manifest packages must contain source package metadata.' + ); + } + + const invalidRequiredPackages = new Set(); + for (const packageName of REQUIRED_SOURCE_PACKAGES) { + if ( + !Object.prototype.hasOwnProperty.call(packages, packageName) || + !isObject(packages[packageName]) + ) { + errors.push( + `Linux runtime manifest packages.${packageName} must be an object.` + ); + invalidRequiredPackages.add(packageName); + } + } + + for (const packageName of Object.keys(packages).sort()) { + if (!SOURCE_PACKAGE_BY_ID.has(packageName)) { + errors.push( + `Linux runtime manifest packages contains unexpected source package "${packageName}".` + ); + } + } + + for (const packageName of Object.keys(packages).sort()) { + const packageMetadata = packages[packageName]; + const label = `packages.${packageName}`; + if (!isObject(packageMetadata)) { + if (!invalidRequiredPackages.has(packageName)) { + errors.push( + `Linux runtime manifest ${label} must be an object.` + ); + } + continue; + } + + validateSourceMetadata(errors, packageMetadata, label); + if (!isNonEmptyString(packageMetadata.license)) { + errors.push( + `Linux runtime manifest ${label}.license must be a non-empty string.` + ); + } + + const pinnedPackage = SOURCE_PACKAGE_BY_ID.get(packageName); + if (!pinnedPackage) { + continue; + } + + for (const field of ['version', 'sourceUrl', 'license']) { + if ( + isNonEmptyString(packageMetadata[field]) && + packageMetadata[field] !== pinnedPackage[field] + ) { + errors.push( + `Linux runtime manifest ${label}.${field} must equal the pinned value.` + ); + } + } + + if (pinnedPackage.sourceTag) { + if (packageMetadata.sourceTag !== pinnedPackage.sourceTag) { + errors.push( + `Linux runtime manifest ${label}.sourceTag must equal the pinned tag.` + ); + } + } else if (packageMetadata.sourceTag !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceTag.` + ); + } + + if (pinnedPackage.buildInput) { + if ( + !isObject(packageMetadata.buildInput) || + !isDeepStrictEqual( + packageMetadata.buildInput, + pinnedPackage.buildInput + ) + ) { + errors.push( + `Linux runtime manifest ${label}.buildInput must equal the pinned build input.` + ); + } + } else if (packageMetadata.buildInput !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include buildInput.` + ); + } + + if (pinnedPackage.sourceKind === 'archive') { + if ( + typeof packageMetadata.sourceSha256 === 'string' && + SHA256_PATTERN.test(packageMetadata.sourceSha256) && + packageMetadata.sourceSha256 !== pinnedPackage.expectedSha256 + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSha256 must equal the pinned digest.` + ); + } + if (packageMetadata.sourceGitCommit !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceGitCommit.` + ); + } + if (packageMetadata.sourceSubmodules !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceSubmodules.` + ); + } + continue; + } + + if ( + typeof packageMetadata.sourceGitCommit === 'string' && + GIT_COMMIT_PATTERN.test(packageMetadata.sourceGitCommit) && + packageMetadata.sourceGitCommit !== pinnedPackage.expectedGitCommit + ) { + errors.push( + `Linux runtime manifest ${label}.sourceGitCommit must equal the pinned commit.` + ); + } + if (packageMetadata.sourceSha256 !== undefined) { + errors.push( + `Linux runtime manifest ${label} must not include sourceSha256.` + ); + } + if ( + !Array.isArray(packageMetadata.sourceSubmodules) || + packageMetadata.sourceSubmodules.length === 0 || + packageMetadata.sourceSubmodules.some( + (record) => !isValidSubmoduleRecord(record) + ) || + new Set(packageMetadata.sourceSubmodules).size !== + packageMetadata.sourceSubmodules.length + ) { + 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.` + ); + } + } +} + +function validateFlags(errors, value, label, options) { + if ( + !Array.isArray(value) || + value.some((flag) => typeof flag !== 'string') + ) { + errors.push( + `Linux runtime manifest ${label} must be an array of strings.` + ); + return; + } + + const addForbiddenErrors = () => { + for (const forbiddenFlag of options.forbidden) { + if (value.includes(forbiddenFlag)) { + errors.push( + `Linux runtime manifest ${label} must not include "${forbiddenFlag}".` + ); + } + } + }; + const addRequiredErrors = () => { + for (const requiredFlag of options.required) { + if (!value.includes(requiredFlag)) { + errors.push( + `Linux runtime manifest ${label} must include "${requiredFlag}".` + ); + } + } + }; + + if (options.requiredFirst) { + addRequiredErrors(); + addForbiddenErrors(); + } else { + addForbiddenErrors(); + addRequiredErrors(); + } +} + +function validateFfmpeg(errors, ffmpeg) { + if (!isObject(ffmpeg)) { + errors.push('Linux runtime manifest ffmpeg must be an object.'); + return; + } + + validateFlags(errors, ffmpeg.configureFlags, 'ffmpeg.configureFlags', { + forbidden: ['--enable-gpl', '--enable-nonfree'], + required: ['--disable-gpl', '--disable-nonfree'], + }); +} + +function validateMpv(errors, mpv) { + if (!isObject(mpv)) { + errors.push('Linux runtime manifest mpv must be an object.'); + return; + } + + validateFlags(errors, mpv.mesonFlags, 'mpv.mesonFlags', { + forbidden: [], + required: ['-Dgpl=false', '-Dlibmpv=true'], + requiredFirst: true, + }); + + if (Array.isArray(mpv.mesonFlags)) { + const assignmentsByOption = new Map(); + for (const flag of mpv.mesonFlags) { + const match = + typeof flag === 'string' ? flag.match(/^(-D[^=]+)=/) : null; + if (!match) { + continue; + } + const option = match[1]; + const assignments = assignmentsByOption.get(option) ?? []; + assignments.push(flag); + assignmentsByOption.set(option, assignments); + } + for (const [option, assignments] of assignmentsByOption) { + if (assignments.length > 1) { + errors.push( + `Linux runtime manifest mpv.mesonFlags must assign "${option}" exactly once.` + ); + } + } + + for (const [option, requiredFlag] of [ + ['-Dgpl', '-Dgpl=false'], + ['-Dlibmpv', '-Dlibmpv=true'], + ]) { + const assignments = assignmentsByOption.get(option) ?? []; + for (const flag of assignments) { + if (flag !== requiredFlag) { + errors.push( + `Linux runtime manifest mpv.mesonFlags must not include "${flag}".` + ); + } + } + } + } +} + +function validateRuntimeFiles(errors, runtimeFiles) { + if (!Array.isArray(runtimeFiles) || runtimeFiles.length === 0) { + errors.push( + 'Linux runtime manifest runtimeFiles must be a non-empty array.' + ); + return; + } + + const names = new Set(); + let hasLibMpvLinkerAlias = false; + let hasVersionedLibMpv = false; + + for (const [index, runtimeFile] of runtimeFiles.entries()) { + const label = `runtimeFiles[${index}]`; + if (!isObject(runtimeFile)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + + const { name, sha256, size } = runtimeFile; + const safeName = isSafeBasename(name); + if (!safeName) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if ( + typeof name === 'string' && + safeName && + !SHARED_LIBRARY_PATTERN.test(name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must end in ".so" or a numeric ".so.N" suffix.` + ); + } + if (!Number.isInteger(size) || size <= 0) { + errors.push( + `Linux runtime manifest ${label}.size must be a positive integer.` + ); + } + if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) { + errors.push( + `Linux runtime manifest ${label}.sha256 must be a lowercase 64-character hexadecimal digest.` + ); + } + + if (typeof name === 'string') { + if (names.has(name)) { + errors.push( + `Linux runtime manifest runtimeFiles contains duplicate name "${name}".` + ); + } else { + names.add(name); + } + if (name === 'libmpv.so') { + hasLibMpvLinkerAlias = true; + } + if (VERSIONED_LIBMPV_PATTERN.test(name)) { + hasVersionedLibMpv = true; + } + } + } + + if (!hasVersionedLibMpv) { + errors.push( + 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.' + ); + } + if (!hasLibMpvLinkerAlias) { + errors.push( + 'Linux runtime manifest runtimeFiles must include the libmpv.so linker alias.' + ); + } +} + +function validateRuntimeTotalBytes(errors, runtimeFiles, runtimeTotalBytes) { + const expectedTotal = Array.isArray(runtimeFiles) + ? runtimeFiles.reduce( + (total, runtimeFile) => + total + + (isObject(runtimeFile) && + Number.isInteger(runtimeFile.size) && + runtimeFile.size > 0 + ? runtimeFile.size + : 0), + 0 + ) + : 0; + if ( + !Number.isInteger(runtimeTotalBytes) || + runtimeTotalBytes !== expectedTotal + ) { + errors.push( + `Linux runtime manifest runtimeTotalBytes must equal the sum of runtimeFiles sizes (${expectedTotal}).` + ); + } +} + +function validateRuntimeAbi(errors, runtimeAbi, runtimeFiles) { + if (!isObject(runtimeAbi)) { + errors.push('Linux runtime manifest runtimeAbi must be an object.'); + return; + } + if ( + !isObject(runtimeAbi.baseline) || + !isDeepStrictEqual(runtimeAbi.baseline, PORTABLE_ABI_BASELINE) + ) { + errors.push( + 'Linux runtime manifest runtimeAbi.baseline must exactly match the portable ABI baseline.' + ); + } + + const runtimeNames = Array.isArray(runtimeFiles) + ? runtimeFiles + .filter((runtimeFile) => isObject(runtimeFile)) + .map(({ name }) => name) + .filter((name) => typeof name === 'string') + : []; + const runtimeNameSet = new Set(runtimeNames); + if (!Array.isArray(runtimeAbi.files)) { + errors.push( + 'Linux runtime manifest runtimeAbi.files must contain one record for every runtime file.' + ); + return; + } + + const abiNames = new Set(); + for (const [index, record] of runtimeAbi.files.entries()) { + const label = `runtimeAbi.files[${index}]`; + if (!isObject(record)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + if ( + !isSafeBasename(record.name) || + !SHARED_LIBRARY_PATTERN.test(record.name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if (abiNames.has(record.name)) { + errors.push( + `Linux runtime manifest runtimeAbi.files contains duplicate name "${String( + record.name + )}".` + ); + } else if (typeof record.name === 'string') { + abiNames.add(record.name); + } + + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ]) { + const version = record[field]; + if ( + version !== null && + (typeof version !== 'string' || + !/^\d+(?:\.\d+)+$/.test(version)) + ) { + errors.push( + `Linux runtime manifest ${label}.${field} must be null or a dotted numeric symbol version.` + ); + continue; + } + if (version && compareVersions(version, maximum) > 0) { + errors.push( + `Linux runtime manifest ${label}.${field} must not exceed portable ABI maximum ${maximum}.` + ); + } + } + } + + if ( + runtimeAbi.files.length !== runtimeNames.length || + runtimeNames.some((name) => !abiNames.has(name)) || + [...abiNames].some((name) => !runtimeNameSet.has(name)) + ) { + errors.push( + 'Linux runtime manifest runtimeAbi.files must contain one record for every runtime file.' + ); + } +} + +function validateRuntimeExternalConfiguration( + errors, + runtimeExternalConfiguration +) { + if ( + !isObject(runtimeExternalConfiguration) || + !isDeepStrictEqual( + runtimeExternalConfiguration, + RUNTIME_EXTERNAL_CONFIGURATION + ) + ) { + errors.push( + 'Linux runtime manifest runtimeExternalConfiguration must exactly match the system-owned runtime paths.' + ); + } +} + +function validateRuntimeDependencyClosure( + errors, + runtimeDependencyClosure, + runtimeFiles +) { + if (!isObject(runtimeDependencyClosure)) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure must be an object.' + ); + return; + } + + const runtimeNames = Array.isArray(runtimeFiles) + ? runtimeFiles + .filter((runtimeFile) => isObject(runtimeFile)) + .map(({ name }) => name) + .filter((name) => typeof name === 'string') + : []; + const runtimeNameSet = new Set(runtimeNames); + const { entries, externalDependencies } = runtimeDependencyClosure; + + if (!Array.isArray(entries)) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure.entries must contain one record for every runtime file.' + ); + return; + } + + const entryNames = new Set(); + const computedExternalDependencies = new Set(); + for (const [index, entry] of entries.entries()) { + const label = `runtimeDependencyClosure.entries[${index}]`; + if (!isObject(entry)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + + if ( + !isSafeBasename(entry.name) || + !SHARED_LIBRARY_PATTERN.test(entry.name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if (entryNames.has(entry.name)) { + errors.push( + `Linux runtime manifest runtimeDependencyClosure.entries contains duplicate name "${String( + entry.name + )}".` + ); + } else if (typeof entry.name === 'string') { + entryNames.add(entry.name); + } + + if ( + entry.soname !== null && + (!isSafeBasename(entry.soname) || + !SHARED_LIBRARY_PATTERN.test(entry.soname)) + ) { + errors.push( + `Linux runtime manifest ${label}.soname must be null or a safe shared-library basename.` + ); + } + + if ( + !Array.isArray(entry.needed) || + entry.needed.some( + (dependencyName) => + !isSafeBasename(dependencyName) || + !SHARED_LIBRARY_PATTERN.test(dependencyName) + ) + ) { + errors.push( + `Linux runtime manifest ${label}.needed must be an array of safe shared-library names.` + ); + } else { + const neededNames = new Set(); + for (const dependencyName of entry.needed) { + if (neededNames.has(dependencyName)) { + errors.push( + `Linux runtime manifest ${label}.needed contains duplicate name "${dependencyName}".` + ); + continue; + } + neededNames.add(dependencyName); + if (runtimeNameSet.has(dependencyName)) { + continue; + } + if (!ALLOWED_EXTERNAL_LIBRARY_NAMES.has(dependencyName)) { + errors.push( + `Linux runtime manifest dependency ${dependencyName} is not in the deterministic system-library allowlist.` + ); + continue; + } + computedExternalDependencies.add(dependencyName); + } + } + + if (!Array.isArray(entry.rpath) || entry.rpath.length !== 0) { + errors.push( + `Linux runtime manifest ${label}.rpath must be an empty array.` + ); + } + if ( + !Array.isArray(entry.runpath) || + entry.runpath.length !== 1 || + entry.runpath[0] !== '$ORIGIN' + ) { + errors.push( + `Linux runtime manifest ${label}.runpath must contain only "$ORIGIN".` + ); + } + } + + if ( + entries.length !== runtimeNames.length || + runtimeNames.some((name) => !entryNames.has(name)) || + [...entryNames].some((name) => !runtimeNameSet.has(name)) + ) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure.entries must contain one record for every runtime file.' + ); + } + + const libMpvLinkerEntry = entries.find( + (entry) => isObject(entry) && entry.name === 'libmpv.so' + ); + if ( + !libMpvLinkerEntry || + typeof libMpvLinkerEntry.soname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(libMpvLinkerEntry.soname) || + !runtimeNameSet.has(libMpvLinkerEntry.soname) + ) { + errors.push( + 'Linux runtime manifest libmpv.so must declare a versioned SONAME present in runtimeFiles.' + ); + } + + const expectedExternalDependencies = [ + ...computedExternalDependencies, + ].sort(); + if ( + !Array.isArray(externalDependencies) || + externalDependencies.some( + (dependencyName) => + !isSafeBasename(dependencyName) || + !SHARED_LIBRARY_PATTERN.test(dependencyName) + ) || + JSON.stringify(externalDependencies) !== + JSON.stringify(expectedExternalDependencies) + ) { + errors.push( + 'Linux runtime manifest runtimeDependencyClosure.externalDependencies must exactly match the sorted allowlisted external dependency set.' + ); + } +} + +function validateExternalSystemLibraries(errors, externalSystemLibraries) { + const expectedKeys = ['interface', 'name', 'reason']; + const matchesDeterministicAllowlist = + Array.isArray(externalSystemLibraries) && + externalSystemLibraries.length === EXTERNAL_SYSTEM_LIBRARIES.length && + externalSystemLibraries.every((externalLibrary, index) => { + if (!isObject(externalLibrary)) { + return false; + } + const actualKeys = Object.keys(externalLibrary).sort(); + if ( + actualKeys.length !== expectedKeys.length || + actualKeys.some( + (key, keyIndex) => key !== expectedKeys[keyIndex] + ) + ) { + return false; + } + const expectedLibrary = EXTERNAL_SYSTEM_LIBRARIES[index]; + return expectedKeys.every( + (key) => externalLibrary[key] === expectedLibrary[key] + ); + }); + if (!matchesDeterministicAllowlist) { + errors.push( + 'Linux runtime manifest externalSystemLibraries must exactly match the deterministic allowlist.' + ); + } +} + +function validateBuildHost(errors, buildHost) { + if (!isObject(buildHost)) { + errors.push('Linux runtime manifest buildHost must be an object.'); + return; + } + if (buildHost.platform !== 'linux') { + errors.push( + 'Linux runtime manifest buildHost.platform must be "linux".' + ); + } + if (buildHost.arch !== 'x64') { + errors.push('Linux runtime manifest buildHost.arch must be "x64".'); + } + if (!isNonEmptyString(buildHost.release)) { + errors.push( + 'Linux runtime manifest buildHost.release must be a non-empty string.' + ); + } + if ( + typeof buildHost.glibcVersion !== 'string' || + !/^\d+(?:\.\d+)+$/.test(buildHost.glibcVersion) + ) { + errors.push( + 'Linux runtime manifest buildHost.glibcVersion must be a dotted numeric version.' + ); + } else if ( + compareVersions( + buildHost.glibcVersion, + PORTABLE_ABI_BASELINE.glibcMaximum + ) > 0 + ) { + errors.push( + `Linux runtime manifest buildHost.glibcVersion ${buildHost.glibcVersion} exceeds portable ABI baseline maximum ${PORTABLE_ABI_BASELINE.glibcMaximum}.` + ); + } + + if ( + !Array.isArray(buildHost.systemPkgConfigDirs) || + buildHost.systemPkgConfigDirs.length === 0 || + buildHost.systemPkgConfigDirs.some( + (directory) => + !isNonEmptyString(directory) || !path.isAbsolute(directory) + ) || + new Set(buildHost.systemPkgConfigDirs).size !== + buildHost.systemPkgConfigDirs.length + ) { + errors.push( + 'Linux runtime manifest buildHost.systemPkgConfigDirs must be a non-empty array of unique absolute paths.' + ); + } + + if (!isObject(buildHost.systemPkgConfigPackages)) { + errors.push( + 'Linux runtime manifest buildHost.systemPkgConfigPackages must be an object.' + ); + } else { + for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + if ( + !isNonEmptyString( + buildHost.systemPkgConfigPackages[packageName] + ) + ) { + errors.push( + `Linux runtime manifest buildHost.systemPkgConfigPackages.${packageName} must be a non-empty version string.` + ); + } + } + for (const packageName of Object.keys( + buildHost.systemPkgConfigPackages + ).sort()) { + if (!EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.includes(packageName)) { + errors.push( + `Linux runtime manifest buildHost.systemPkgConfigPackages contains unexpected package "${packageName}".` + ); + } + } + } + + if (!isObject(buildHost.tools)) { + errors.push( + 'Linux runtime manifest buildHost.tools must be an object.' + ); + return; + } + for (const tool of REQUIRED_TOOLS) { + const declaredVersion = buildHost.tools[tool]; + if (!isNonEmptyString(declaredVersion)) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} must be a non-empty version string.` + ); + continue; + } + const actualVersion = parseVersion(declaredVersion); + if (!actualVersion) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} must contain a parseable dotted numeric version.` + ); + } else if ( + compareVersions(actualVersion, MINIMUM_TOOL_VERSIONS[tool]) < 0 + ) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} ${actualVersion} is unsupported; requires ${MINIMUM_TOOL_VERSIONS[tool]} or newer.` + ); + } + } + for (const tool of Object.keys(buildHost.tools).sort()) { + if (!REQUIRED_TOOLS.includes(tool)) { + errors.push( + `Linux runtime manifest buildHost.tools contains unexpected tool "${tool}".` + ); + } + } +} + +function validateLinuxRuntimeManifest(manifest) { + if (!isObject(manifest)) { + return ['Linux runtime manifest must be an object.']; + } + + const errors = []; + if (manifest.schemaVersion !== LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION) { + errors.push( + `Linux runtime manifest schemaVersion must be ${LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION}.` + ); + } + if (manifest.origin !== 'vendored-lgpl-source-build') { + errors.push( + 'Linux runtime manifest origin must be "vendored-lgpl-source-build".' + ); + } + if (manifest.platform !== 'linux') { + errors.push('Linux runtime manifest platform must be "linux".'); + } + if (manifest.arch !== 'x64') { + errors.push('Linux runtime manifest arch must be "x64".'); + } + + validatePackages(errors, manifest.packages); + validateFfmpeg(errors, manifest.ffmpeg); + validateMpv(errors, manifest.mpv); + + if (!isNonEmptyString(manifest.sourceDistribution)) { + errors.push( + 'Linux runtime manifest sourceDistribution must be a non-empty string.' + ); + } else { + for (const [sourceName, sourcePattern] of [ + ['hwdata', /\bhwdata\b/i], + ['pnp.ids', /\bpnp\.ids\b/i], + ['libdisplay-info', /\blibdisplay-info\b/i], + ]) { + if (!sourcePattern.test(manifest.sourceDistribution)) { + errors.push( + `Linux runtime manifest sourceDistribution must explicitly include ${sourceName}.` + ); + } + } + } + + validateRuntimeFiles(errors, manifest.runtimeFiles); + validateRuntimeTotalBytes( + errors, + manifest.runtimeFiles, + manifest.runtimeTotalBytes + ); + validateRuntimeAbi(errors, manifest.runtimeAbi, manifest.runtimeFiles); + validateRuntimeExternalConfiguration( + errors, + manifest.runtimeExternalConfiguration + ); + validateRuntimeDependencyClosure( + errors, + manifest.runtimeDependencyClosure, + manifest.runtimeFiles + ); + validateExternalSystemLibraries(errors, manifest.externalSystemLibraries); + validateBuildHost(errors, manifest.buildHost); + return errors; +} + +function isLinuxSystemBuildInputManifest(manifest) { + return isObject(manifest) && manifest.linuxBackend === LINUX_SYSTEM_BACKEND; +} + +function validateLinuxSystemBuildInputManifest(manifest) { + if (!isObject(manifest)) { + return ['Linux system build-input manifest must be an object.']; + } + + const errors = []; + if (manifest.linuxBackend !== LINUX_SYSTEM_BACKEND) { + errors.push( + `Linux system build-input manifest linuxBackend must be "${LINUX_SYSTEM_BACKEND}".` + ); + } + + if (!isObject(manifest.buildInputs)) { + errors.push( + 'Linux system build-input manifest buildInputs must be an object.' + ); + } else { + for (const packageName of ['libmpvDevPackage', 'mpvPackage']) { + if (!isNonEmptyString(manifest.buildInputs[packageName])) { + errors.push( + `Linux system build-input manifest buildInputs.${packageName} must be a non-empty string.` + ); + } + } + } + + if (!isNonEmptyString(manifest.sourceDistribution)) { + errors.push( + 'Linux system build-input manifest sourceDistribution must be a non-empty string.' + ); + } + if (Object.prototype.hasOwnProperty.call(manifest, 'origin')) { + errors.push( + 'Linux system build-input manifest must not include origin.' + ); + } + if (Object.prototype.hasOwnProperty.call(manifest, 'runtimeFiles')) { + errors.push( + 'Linux system build-input manifest must not include runtimeFiles.' + ); + } + + return errors; +} + +module.exports = { + LINUX_SYSTEM_BACKEND, + LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, + isLinuxSystemBuildInputManifest, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +}; diff --git a/tools/embedded-mpv/linux-runtime-manifest.test.mjs b/tools/embedded-mpv/linux-runtime-manifest.test.mjs new file mode 100644 index 000000000..81ae64543 --- /dev/null +++ b/tools/embedded-mpv/linux-runtime-manifest.test.mjs @@ -0,0 +1,1185 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +} = require('./linux-runtime-manifest.cjs'); +const { + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXTERNAL_SYSTEM_LIBRARIES, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + PORTABLE_ABI_BASELINE, + REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, + SOURCE_PACKAGES, +} = require('./build-linux-runtime.cjs'); + +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const stageRuntimeScript = path.join( + workspaceRoot, + 'tools', + 'embedded-mpv', + 'stage-runtime.mjs' +); +const stageRuntimeSource = fs.readFileSync(stageRuntimeScript, 'utf8'); +const EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +]); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function runtimeFile(name, contents) { + return { + name, + size: Buffer.byteLength(contents), + sha256: sha256(contents), + }; +} + +function sourcePackageRecord(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.buildInput + ? { buildInput: structuredClone(sourcePackage.buildInput) } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + }), + license: sourcePackage.license, + }; +} + +function createValidManifest( + runtimeFiles = [ + runtimeFile('libmpv.so.2', 'libmpv-runtime'), + runtimeFile('libavcodec.so.61', 'libavcodec-runtime'), + runtimeFile('libmpv.so', 'libmpv-runtime'), + ] +) { + const packages = Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + sourcePackageRecord(sourcePackage), + ]) + ); + const runtimeNames = new Set( + runtimeFiles + .map((runtimeEntry) => runtimeEntry?.name) + .filter((name) => typeof name === 'string') + ); + const closureEntries = runtimeFiles + .filter( + (runtimeEntry) => + runtimeEntry && typeof runtimeEntry.name === 'string' + ) + .map((runtimeEntry) => ({ + name: runtimeEntry.name, + soname: runtimeEntry.name.startsWith('libmpv.so') + ? 'libmpv.so.2' + : runtimeEntry.name, + needed: runtimeEntry.name.startsWith('libmpv.so') + ? [ + ...(runtimeNames.has('libavcodec.so.61') + ? ['libavcodec.so.61'] + : []), + 'libEGL.so.1', + 'libc.so.6', + ] + : ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + })); + const externalDependencies = [ + ...new Set( + closureEntries + .flatMap(({ needed }) => needed) + .filter((neededName) => !runtimeNames.has(neededName)) + ), + ].sort(); + + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + packages, + ffmpeg: { + ...packages.ffmpeg, + configureFlags: [ + '--enable-shared', + '--disable-gpl', + '--disable-nonfree', + ], + }, + mpv: { + ...packages.mpv, + mesonFlags: ['-Dlibmpv=true', '-Dgpl=false'], + }, + sourceDistribution: + 'Publish the exact source archives, including pinned hwdata pnp.ids and the MIT-licensed libdisplay-info source archive.', + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeEntry) => total + (runtimeEntry?.size ?? 0), + 0 + ), + runtimeAbi: { + baseline: { ...PORTABLE_ABI_BASELINE }, + files: runtimeFiles + .filter( + (runtimeEntry) => + runtimeEntry && typeof runtimeEntry.name === 'string' + ) + .map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + }, + runtimeExternalConfiguration: structuredClone( + RUNTIME_EXTERNAL_CONFIGURATION + ), + runtimeDependencyClosure: { + entries: closureEntries, + externalDependencies, + }, + externalSystemLibraries: EXTERNAL_SYSTEM_LIBRARIES.map( + (externalLibrary) => ({ ...externalLibrary }) + ), + buildHost: { + platform: 'linux', + arch: 'x64', + release: 'fixture-kernel', + glibcVersion: '2.35', + systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], + systemPkgConfigPackages: Object.fromEntries( + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((packageName) => [ + packageName, + `${packageName} fixture version`, + ]) + ), + tools: Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ), + }, + }; +} + +function createSystemBuildInputManifest() { + return { + linuxBackend: 'process-isolated mpv --wid', + buildInputs: { + libmpvDevPackage: '2:0.40.0-3ubuntu2', + mpvPackage: '0.40.0-3ubuntu2', + }, + sourceDistribution: + 'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.', + }; +} + +function createFixture(t, options = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-runtime-test-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const prefix = path.join(root, 'prefix'); + const includeDir = path.join(prefix, 'include', 'mpv'); + const libDir = path.join(prefix, 'lib'); + fs.mkdirSync(includeDir, { recursive: true }); + fs.mkdirSync(libDir, { recursive: true }); + fs.writeFileSync( + path.join(includeDir, 'client.h'), + '/* libmpv header */\n' + ); + + const contentsByName = new Map([ + ['libmpv.so.2', 'libmpv-runtime'], + ['libavcodec.so.61', 'libavcodec-runtime'], + ['libmpv.so', 'libmpv-runtime'], + ]); + + for (const [name, contents] of contentsByName) { + fs.writeFileSync(path.join(libDir, name), contents); + } + + const manifest = + options.manifest ?? + createValidManifest( + [...contentsByName].map(([name, contents]) => + runtimeFile(name, contents) + ) + ); + options.mutateManifest?.(manifest); + + if (options.removeRuntimeFile) { + fs.rmSync(path.join(libDir, options.removeRuntimeFile), { + force: true, + }); + } + if (options.removeHeader) { + fs.rmSync(path.join(includeDir, 'client.h'), { force: true }); + } + if (!options.omitManifest) { + fs.writeFileSync( + path.join(prefix, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + } + + return { libDir, manifest, prefix, root }; +} + +function destinationRootFor(fixture) { + return path.join(fixture.root, 'vendor', 'embedded-mpv', 'linux-x64'); +} + +function runStage(fixture) { + return spawnSync( + process.execPath, + [stageRuntimeScript, 'linux', 'x64', fixture.prefix], + { + cwd: fixture.root, + encoding: 'utf8', + } + ); +} + +function assertStageRejected(t, options, expectedError) { + const fixture = createFixture(t, options); + const result = runStage(fixture); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, expectedError); +} + +test('exports the Linux runtime manifest schema version', () => { + assert.equal(LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, 1); +}); + +test('accepts a complete LGPL Linux x64 source-build manifest', () => { + assert.deepEqual(validateLinuxRuntimeManifest(createValidManifest()), []); +}); + +test('requires the exact pinned source package set and provenance', () => { + const expectedPackageNames = SOURCE_PACKAGES.map(({ id }) => id).sort(); + assert.deepEqual( + Object.keys(createValidManifest().packages).sort(), + expectedPackageNames + ); + + for (const packageName of expectedPackageNames) { + const manifest = createValidManifest(); + delete manifest.packages[packageName]; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + new RegExp(`packages\\.${packageName} must be an object`) + ); + } + + const unexpectedPackage = createValidManifest(); + unexpectedPackage.packages.unpinned = { + version: '1.0.0', + sourceUrl: 'https://example.test/unpinned.tar.xz', + sourceSha256: 'f'.repeat(64), + license: 'MIT', + }; + assert.match( + validateLinuxRuntimeManifest(unexpectedPackage).join('\n'), + /packages contains unexpected source package "unpinned"/ + ); +}); + +test('requires pinned archive hashes and exact libplacebo git provenance', () => { + const archiveMismatch = createValidManifest(); + archiveMismatch.packages.freetype.sourceSha256 = '0'.repeat(64); + assert.match( + validateLinuxRuntimeManifest(archiveMismatch).join('\n'), + /packages\.freetype\.sourceSha256 must equal the pinned digest/ + ); + + const commitMismatch = createValidManifest(); + commitMismatch.packages.libplacebo.sourceGitCommit = '0'.repeat(40); + assert.match( + validateLinuxRuntimeManifest(commitMismatch).join('\n'), + /packages\.libplacebo\.sourceGitCommit must equal the pinned commit/ + ); + + const submoduleCommitMismatch = createValidManifest(); + submoduleCommitMismatch.packages.libplacebo.sourceSubmodules[0] = `${'f'.repeat(40)} 3rdparty/Vulkan-Headers`; + 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'; + assert.match( + validateLinuxRuntimeManifest(hostHwdataInput).join('\n'), + /packages\.hwdata\.buildInput must equal the pinned build input/ + ); + + const validSubmoduleRecord = `${'a'.repeat(40)} 3rdparty/example`; + for (const sourceSubmodules of [ + [], + ['not-a-submodule-record'], + [`${'a'.repeat(40)} ../escape`], + [validSubmoduleRecord, validSubmoduleRecord], + ]) { + const invalidSubmodules = createValidManifest(); + invalidSubmodules.packages.libplacebo.sourceSubmodules = + sourceSubmodules; + assert.match( + validateLinuxRuntimeManifest(invalidSubmodules).join('\n'), + /packages\.libplacebo\.sourceSubmodules/ + ); + } +}); + +test('requires runtimeTotalBytes to equal the declared runtime file sizes', () => { + const missingTotal = createValidManifest(); + delete missingTotal.runtimeTotalBytes; + assert.match( + validateLinuxRuntimeManifest(missingTotal).join('\n'), + /runtimeTotalBytes must equal the sum/ + ); + + const wrongTotal = createValidManifest(); + wrongTotal.runtimeTotalBytes += 1; + assert.match( + validateLinuxRuntimeManifest(wrongTotal).join('\n'), + /runtimeTotalBytes must equal the sum/ + ); +}); + +test('requires a complete ORIGIN-only runtime dependency closure', () => { + const missingEntry = createValidManifest(); + missingEntry.runtimeDependencyClosure.entries.pop(); + assert.match( + validateLinuxRuntimeManifest(missingEntry).join('\n'), + /runtimeDependencyClosure\.entries must contain one record for every runtime file/ + ); + + const duplicateEntry = createValidManifest(); + duplicateEntry.runtimeDependencyClosure.entries[1].name = + duplicateEntry.runtimeDependencyClosure.entries[0].name; + assert.match( + validateLinuxRuntimeManifest(duplicateEntry).join('\n'), + /runtimeDependencyClosure\.entries contains duplicate name/ + ); + + const invalidNeeded = createValidManifest(); + invalidNeeded.runtimeDependencyClosure.entries[0].needed = 'libc.so.6'; + assert.match( + validateLinuxRuntimeManifest(invalidNeeded).join('\n'), + /needed must be an array of safe shared-library names/ + ); + + const absoluteRpath = createValidManifest(); + absoluteRpath.runtimeDependencyClosure.entries[0].rpath = ['/tmp/lib']; + assert.match( + validateLinuxRuntimeManifest(absoluteRpath).join('\n'), + /rpath must be an empty array/ + ); + + const wrongRunpath = createValidManifest(); + wrongRunpath.runtimeDependencyClosure.entries[0].runpath = ['/tmp/lib']; + assert.match( + validateLinuxRuntimeManifest(wrongRunpath).join('\n'), + /runpath must contain only "\$ORIGIN"/ + ); + + const unknownDependency = createValidManifest(); + unknownDependency.runtimeDependencyClosure.entries[0].needed.push( + 'libsurprise.so.1' + ); + unknownDependency.runtimeDependencyClosure.externalDependencies.push( + 'libsurprise.so.1' + ); + assert.match( + validateLinuxRuntimeManifest(unknownDependency).join('\n'), + /libsurprise\.so\.1.*not in the deterministic system-library allowlist/ + ); +}); + +test('requires safe SONAME metadata and a bundled versioned libmpv target', () => { + const missingLinkerAlias = createValidManifest(); + const aliasFile = missingLinkerAlias.runtimeFiles.find( + ({ name }) => name === 'libmpv.so' + ); + missingLinkerAlias.runtimeFiles = missingLinkerAlias.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so' + ); + missingLinkerAlias.runtimeTotalBytes -= aliasFile.size; + missingLinkerAlias.runtimeAbi.files = + missingLinkerAlias.runtimeAbi.files.filter( + ({ name }) => name !== 'libmpv.so' + ); + missingLinkerAlias.runtimeDependencyClosure.entries = + missingLinkerAlias.runtimeDependencyClosure.entries.filter( + ({ name }) => name !== 'libmpv.so' + ); + assert.match( + validateLinuxRuntimeManifest(missingLinkerAlias).join('\n'), + /runtimeFiles must include the libmpv\.so linker alias/ + ); + + const missingLibmpvSoname = createValidManifest(); + missingLibmpvSoname.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname = null; + assert.match( + validateLinuxRuntimeManifest(missingLibmpvSoname).join('\n'), + /libmpv\.so must declare a versioned SONAME present in runtimeFiles/ + ); + + const unsafeSoname = createValidManifest(); + unsafeSoname.runtimeDependencyClosure.entries[0].soname = '../libmpv.so.2'; + assert.match( + validateLinuxRuntimeManifest(unsafeSoname).join('\n'), + /runtimeDependencyClosure\.entries\[0\]\.soname must be null or a safe shared-library basename/ + ); + + const absentSonameTarget = createValidManifest(); + absentSonameTarget.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname = 'libmpv.so.99'; + assert.match( + validateLinuxRuntimeManifest(absentSonameTarget).join('\n'), + /libmpv\.so must declare a versioned SONAME present in runtimeFiles/ + ); +}); + +test('requires the deterministic external-system-library records', () => { + const manifest = createValidManifest(); + manifest.externalSystemLibraries.reverse(); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /externalSystemLibraries must exactly match the deterministic allowlist/ + ); + + const allowedNames = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + ]); + for (const dependencyName of createValidManifest().runtimeDependencyClosure + .externalDependencies) { + assert.equal(allowedNames.has(dependencyName), true, dependencyName); + } +}); + +test('requires a Linux x64 build host and every required tool version', () => { + const wrongPlatform = createValidManifest(); + wrongPlatform.buildHost.platform = 'darwin'; + assert.match( + validateLinuxRuntimeManifest(wrongPlatform).join('\n'), + /buildHost\.platform must be "linux"/ + ); + + const wrongArch = createValidManifest(); + wrongArch.buildHost.arch = 'arm64'; + assert.match( + validateLinuxRuntimeManifest(wrongArch).join('\n'), + /buildHost\.arch must be "x64"/ + ); + + for (const tool of REQUIRED_TOOLS) { + const missingTool = createValidManifest(); + delete missingTool.buildHost.tools[tool]; + assert.match( + validateLinuxRuntimeManifest(missingTool).join('\n'), + new RegExp(`buildHost\\.tools\\.${tool.replace('-', '\\-')}`) + ); + } + + const unexpectedTool = createValidManifest(); + unexpectedTool.buildHost.tools.unexpected = '1.0'; + assert.match( + validateLinuxRuntimeManifest(unexpectedTool).join('\n'), + /buildHost\.tools contains unexpected tool "unexpected"/ + ); + + const relativePkgConfigDir = createValidManifest(); + relativePkgConfigDir.buildHost.systemPkgConfigDirs = ['relative/pkgconfig']; + assert.match( + validateLinuxRuntimeManifest(relativePkgConfigDir).join('\n'), + /buildHost\.systemPkgConfigDirs must be a non-empty array of unique absolute paths/ + ); + + for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + const missingPackage = createValidManifest(); + delete missingPackage.buildHost.systemPkgConfigPackages[packageName]; + assert.match( + validateLinuxRuntimeManifest(missingPackage).join('\n'), + new RegExp( + `buildHost\\.systemPkgConfigPackages\\.${packageName.replace( + '-', + '\\-' + )}` + ) + ); + } + + const unexpectedPackage = createValidManifest(); + unexpectedPackage.buildHost.systemPkgConfigPackages.unexpected = '1.0'; + assert.match( + validateLinuxRuntimeManifest(unexpectedPackage).join('\n'), + /buildHost\.systemPkgConfigPackages contains unexpected package "unexpected"/ + ); + + const unsupportedGlibc = createValidManifest(); + unsupportedGlibc.buildHost.glibcVersion = '2.36'; + assert.match( + validateLinuxRuntimeManifest(unsupportedGlibc).join('\n'), + /buildHost\.glibcVersion.*portable ABI baseline.*2\.35/i + ); + + const unsupportedMeson = createValidManifest(); + unsupportedMeson.buildHost.tools.meson = 'meson 1.5.9'; + assert.match( + validateLinuxRuntimeManifest(unsupportedMeson).join('\n'), + /buildHost\.tools\.meson.*requires 1\.6\.0 or newer/i + ); +}); + +test('requires exact portable ABI and external configuration records', () => { + const missingAbiFile = createValidManifest(); + missingAbiFile.runtimeAbi.files.pop(); + assert.match( + validateLinuxRuntimeManifest(missingAbiFile).join('\n'), + /runtimeAbi\.files must contain one record for every runtime file/ + ); + + const newerGlibcSymbol = createValidManifest(); + newerGlibcSymbol.runtimeAbi.files[0].requiredGlibc = '2.36'; + assert.match( + validateLinuxRuntimeManifest(newerGlibcSymbol).join('\n'), + /runtimeAbi\.files\[0\]\.requiredGlibc.*maximum 2\.35/ + ); + + const newerGlibcxxSymbol = createValidManifest(); + newerGlibcxxSymbol.runtimeAbi.files[0].requiredGlibcxx = '3.4.31'; + assert.match( + validateLinuxRuntimeManifest(newerGlibcxxSymbol).join('\n'), + /runtimeAbi\.files\[0\]\.requiredGlibcxx.*maximum 3\.4\.30/ + ); + + const wrongBaseline = createValidManifest(); + wrongBaseline.runtimeAbi.baseline.distribution = 'current host'; + assert.match( + validateLinuxRuntimeManifest(wrongBaseline).join('\n'), + /runtimeAbi\.baseline must exactly match the portable ABI baseline/ + ); + + const buildPathConfiguration = createValidManifest(); + buildPathConfiguration.runtimeExternalConfiguration.fontconfig.configDirectory = + '/tmp/build-prefix/etc/fonts'; + assert.match( + validateLinuxRuntimeManifest(buildPathConfiguration).join('\n'), + /runtimeExternalConfiguration must exactly match the system-owned runtime paths/ + ); +}); + +test('requires the source-distribution statement to name pinned display data', () => { + const manifest = createValidManifest(); + manifest.sourceDistribution = + 'Publish all of the other source archives with the binary release.'; + const errors = validateLinuxRuntimeManifest(manifest).join('\n'); + assert.match(errors, /sourceDistribution must explicitly include hwdata/); + assert.match(errors, /sourceDistribution must explicitly include pnp\.ids/); + assert.match( + errors, + /sourceDistribution must explicitly include libdisplay-info/ + ); +}); + +test('accepts and stages the current Linux CI system build-input manifest', (t) => { + const systemManifest = createSystemBuildInputManifest(); + assert.deepEqual(validateLinuxSystemBuildInputManifest(systemManifest), []); + + const fixture = createFixture(t, { manifest: systemManifest }); + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + + const destinationRoot = destinationRootFor(fixture); + assert.equal( + fs.readFileSync( + path.join(destinationRoot, 'include', 'mpv', 'client.h'), + 'utf8' + ), + '/* libmpv header */\n' + ); + assert.equal(fs.existsSync(path.join(destinationRoot, 'lib')), false); + + const stagedManifest = JSON.parse( + fs.readFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + 'utf8' + ) + ); + assert.equal(stagedManifest.origin, 'vendored-lgpl'); + assert.equal(stagedManifest.platform, 'linux'); + assert.equal(stagedManifest.arch, 'x64'); + assert.deepEqual(stagedManifest.runtimeFiles, []); + assert.deepEqual(stagedManifest.buildInputs, systemManifest.buildInputs); + assert.equal(stagedManifest.linuxBackend, systemManifest.linuxBackend); + assert.equal('sourceBuildOrigin' in stagedManifest, false); +}); + +test('rejects malformed system build-input manifests without falling through', (t) => { + const malformedSystemManifest = createSystemBuildInputManifest(); + malformedSystemManifest.buildInputs.mpvPackage = ''; + malformedSystemManifest.runtimeFiles = []; + + assert.deepEqual( + validateLinuxSystemBuildInputManifest(malformedSystemManifest), + [ + 'Linux system build-input manifest buildInputs.mpvPackage must be a non-empty string.', + 'Linux system build-input manifest must not include runtimeFiles.', + ] + ); + + const fixture = createFixture(t, { manifest: malformedSystemManifest }); + const result = runStage(fixture); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /buildInputs\.mpvPackage/); + assert.match(result.stderr, /must not include runtimeFiles/); +}); + +test('returns deterministic validation errors for untrusted input', () => { + assert.deepEqual(validateLinuxRuntimeManifest(null), [ + 'Linux runtime manifest must be an object.', + ]); + assert.deepEqual(validateLinuxRuntimeManifest(null), [ + 'Linux runtime manifest must be an object.', + ]); + + const manifest = createValidManifest(); + manifest.runtimeFiles[0].sha256 = Symbol('not-a-digest'); + assert.doesNotThrow(() => validateLinuxRuntimeManifest(manifest)); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /runtimeFiles\[0\]\.sha256/ + ); + + const hostileAllowlist = createValidManifest(); + hostileAllowlist.externalSystemLibraries = 1n; + assert.doesNotThrow(() => validateLinuxRuntimeManifest(hostileAllowlist)); + assert.match( + validateLinuxRuntimeManifest(hostileAllowlist).join('\n'), + /externalSystemLibraries/ + ); +}); + +test('requires schema, provenance, target, package, and source metadata', () => { + const manifest = createValidManifest(); + manifest.schemaVersion = 2; + manifest.origin = 'vendored-lgpl'; + manifest.platform = 'darwin'; + manifest.arch = 'arm64'; + manifest.packages.ffmpeg.sourceUrl = ''; + manifest.packages.mpv.version = ''; + manifest.sourceDistribution = ' '; + + assert.deepEqual(validateLinuxRuntimeManifest(manifest), [ + 'Linux runtime manifest schemaVersion must be 1.', + 'Linux runtime manifest origin must be "vendored-lgpl-source-build".', + 'Linux runtime manifest platform must be "linux".', + 'Linux runtime manifest arch must be "x64".', + 'Linux runtime manifest packages.ffmpeg.sourceUrl must be a non-empty string.', + 'Linux runtime manifest packages.mpv.version must be a non-empty string.', + 'Linux runtime manifest sourceDistribution must be a non-empty string.', + ]); + + manifest.packages = {}; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /packages must contain source package metadata/ + ); +}); + +test('requires exact FFmpeg and mpv source package records', () => { + const missingFfmpeg = createValidManifest(); + missingFfmpeg.packages.libavcodec = missingFfmpeg.packages.ffmpeg; + delete missingFfmpeg.packages.ffmpeg; + assert.match( + validateLinuxRuntimeManifest(missingFfmpeg).join('\n'), + /packages\.ffmpeg must be an object/ + ); + + const missingMpv = createValidManifest(); + missingMpv.packages.libmpv = missingMpv.packages.mpv; + delete missingMpv.packages.mpv; + assert.match( + validateLinuxRuntimeManifest(missingMpv).join('\n'), + /packages\.mpv must be an object/ + ); + + const substitutedPackages = createValidManifest(); + substitutedPackages.packages = { + multimediaRuntime: { + version: '1.0.0', + sourceUrl: 'https://example.test/multimedia-runtime.tar.xz', + sourceSha256: 'c'.repeat(64), + license: 'LGPL-2.1-or-later', + }, + }; + const substitutedErrors = + validateLinuxRuntimeManifest(substitutedPackages).join('\n'); + for (const packageName of SOURCE_PACKAGES.map(({ id }) => id)) { + assert.match( + substitutedErrors, + new RegExp(`packages\\.${packageName} must be an object`) + ); + } + assert.match( + substitutedErrors, + /packages contains unexpected source package "multimediaRuntime"/ + ); +}); + +test('rejects GPL and nonfree FFmpeg configurations', () => { + const cases = [ + { + flags: ['--disable-nonfree'], + expected: /must include "--disable-gpl"/, + }, + { + flags: ['--disable-gpl'], + expected: /must include "--disable-nonfree"/, + }, + { + flags: ['--disable-gpl', '--disable-nonfree', '--enable-gpl'], + expected: /must not include "--enable-gpl"/, + }, + { + flags: ['--disable-gpl', '--disable-nonfree', '--enable-nonfree'], + expected: /must not include "--enable-nonfree"/, + }, + ]; + + for (const { flags, expected } of cases) { + const manifest = createValidManifest(); + manifest.ffmpeg.configureFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } + + const manifest = createValidManifest(); + manifest.ffmpeg = { configureFlags: ['--enable-gpl'] }; + assert.match(validateLinuxRuntimeManifest(manifest)[0], /--enable-gpl/); +}); + +test('requires libmpv and GPL-disabled mpv Meson flags', () => { + const cases = [ + { + flags: ['-Dgpl=false'], + expected: /must include "-Dlibmpv=true"/, + }, + { + flags: ['-Dlibmpv=true'], + expected: /must include "-Dgpl=false"/, + }, + { + flags: ['-Dlibmpv=true', '-Dgpl=false', '-Dgpl=true'], + expected: /must not include "-Dgpl=true"/, + }, + ]; + + for (const { flags, expected } of cases) { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } + + const manifest = createValidManifest(); + manifest.mpv = { mesonFlags: ['-Dgpl=true'] }; + assert.match(validateLinuxRuntimeManifest(manifest)[0], /-Dgpl=false/); +}); + +test('rejects duplicate or contradictory required mpv Meson assignments', () => { + const cases = [ + { + flags: ['-Dlibmpv=true', '-Dlibmpv=false', '-Dgpl=false'], + expected: /must assign "-Dlibmpv" exactly once/, + }, + { + flags: ['-Dlibmpv=true', '-Dgpl=false', '-Dgpl=true'], + expected: /must assign "-Dgpl" exactly once/, + }, + { + flags: ['-Dlibmpv=true', '-Dlibmpv=true', '-Dgpl=false'], + expected: /must assign "-Dlibmpv" exactly once/, + }, + ]; + + for (const { expected, flags } of cases) { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } +}); + +test('rejects duplicate assignments for every mpv Meson option', () => { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags.push('-Ddrm=enabled', '-Ddrm=enabled'); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /mpv\.mesonFlags must assign "-Ddrm" exactly once/ + ); +}); + +test('validates safe, unique shared-library metadata', () => { + const manifest = createValidManifest([ + { + name: '../libmpv.so.2', + size: 0, + sha256: 'ABC', + }, + runtimeFile('libcodec.a', 'archive'), + runtimeFile('libcodec.a', 'duplicate'), + ]); + + const errors = validateLinuxRuntimeManifest(manifest); + assert.deepEqual(errors.slice(0, 7), [ + 'Linux runtime manifest runtimeFiles[0].name must be a safe shared-library basename.', + 'Linux runtime manifest runtimeFiles[0].size must be a positive integer.', + 'Linux runtime manifest runtimeFiles[0].sha256 must be a lowercase 64-character hexadecimal digest.', + 'Linux runtime manifest runtimeFiles[1].name must end in ".so" or a numeric ".so.N" suffix.', + 'Linux runtime manifest runtimeFiles[2].name must end in ".so" or a numeric ".so.N" suffix.', + 'Linux runtime manifest runtimeFiles contains duplicate name "libcodec.a".', + 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.', + ]); + assert.match( + errors.join('\n'), + /runtimeDependencyClosure\.entries\[0\]\.name must be a safe shared-library basename/ + ); + assert.match( + errors.join('\n'), + /runtimeDependencyClosure\.entries contains duplicate name "libcodec\.a"/ + ); +}); + +test('rejects control characters in shared-library basenames', () => { + const manifest = createValidManifest(); + manifest.runtimeFiles.push( + runtimeFile('libinjected.so.1\n', 'unsafe-name') + ); + + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /runtimeFiles\[3\]\.name must be a safe shared-library basename/ + ); +}); + +test('stages only declared Linux libraries and materializes source symlinks', (t) => { + const fixture = createFixture(t); + fs.renameSync( + path.join(fixture.libDir, 'libmpv.so.2'), + path.join(fixture.libDir, 'libmpv.so.2.1.0') + ); + fs.rmSync(path.join(fixture.libDir, 'libmpv.so')); + fs.symlinkSync('libmpv.so.2.1.0', path.join(fixture.libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(fixture.libDir, 'libmpv.so')); + fs.writeFileSync(path.join(fixture.libDir, 'libundeclared.so.1'), 'extra'); + fs.writeFileSync( + path.join(fixture.prefix, 'runtime-manifest.json'), + `${JSON.stringify(fixture.manifest, null, 2)}\n` + ); + + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + + const destinationRoot = destinationRootFor(fixture); + const destinationLibDir = path.join(destinationRoot, 'lib'); + assert.deepEqual(fs.readdirSync(destinationLibDir).sort(), [ + 'libavcodec.so.61', + 'libmpv.so', + 'libmpv.so.2', + ]); + for (const runtimeEntry of fixture.manifest.runtimeFiles) { + const destinationPath = path.join(destinationLibDir, runtimeEntry.name); + const stat = fs.lstatSync(destinationPath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.size, runtimeEntry.size); + assert.equal( + sha256(fs.readFileSync(destinationPath)), + runtimeEntry.sha256 + ); + } + assert.equal( + fs.readFileSync( + path.join(destinationRoot, 'include', 'mpv', 'client.h'), + 'utf8' + ), + '/* libmpv header */\n' + ); + + const stagedManifest = JSON.parse( + fs.readFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + 'utf8' + ) + ); + assert.equal(stagedManifest.origin, 'vendored-lgpl'); + assert.equal( + stagedManifest.sourceBuildOrigin, + 'vendored-lgpl-source-build' + ); + assert.equal(stagedManifest.platform, 'linux'); + assert.equal(stagedManifest.arch, 'x64'); + assert.deepEqual( + stagedManifest.runtimeFiles, + fixture.manifest.runtimeFiles + ); + for (const field of [ + 'packages', + 'runtimeTotalBytes', + 'runtimeAbi', + 'runtimeExternalConfiguration', + 'runtimeDependencyClosure', + 'externalSystemLibraries', + 'buildHost', + ]) { + assert.deepEqual(stagedManifest[field], fixture.manifest[field], field); + } +}); + +test('copies runtime libraries only from the verified byte snapshot', () => { + assert.match( + stageRuntimeSource, + /function readVerifiedLinuxRuntimeFiles\(manifest\)/ + ); + assert.match( + stageRuntimeSource, + /contents = fs\.readFileSync\(sourcePath\)/ + ); + assert.match( + stageRuntimeSource, + /fs\.writeFileSync\(destinationPath, verifiedRuntimeFile\.contents\)/ + ); + assert.doesNotMatch( + stageRuntimeSource, + /function copyLinuxRuntimeFiles\(manifest\)/ + ); +}); + +test('atomically replaces the complete Linux destination tree', (t) => { + const fixture = createFixture(t); + const destinationRoot = destinationRootFor(fixture); + fs.mkdirSync(destinationRoot, { recursive: true }); + fs.writeFileSync(path.join(destinationRoot, 'stale-runtime.txt'), 'stale'); + + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + assert.equal( + fs.existsSync(path.join(destinationRoot, 'stale-runtime.txt')), + false + ); + assert.deepEqual( + fs + .readdirSync(path.dirname(destinationRoot)) + .filter((name) => name.startsWith('.linux-x64.')), + [] + ); +}); + +test('rejects a libmpv header symlink that escapes the source prefix', (t) => { + const fixture = createFixture(t); + const outsideHeader = path.join(fixture.root, 'outside-client.h'); + const clientHeader = path.join( + fixture.prefix, + 'include', + 'mpv', + 'client.h' + ); + fs.writeFileSync(outsideHeader, '/* untrusted external header */\n'); + fs.rmSync(clientHeader); + fs.symlinkSync(outsideHeader, clientHeader); + + const result = runStage(fixture); + assert.notEqual(result.status, 0, result.stdout); + assert.match( + result.stderr, + /Linux header resolves outside prefix\/include/ + ); +}); + +test('rejects destination root and ancestor symlink redirection', (t) => { + for (const symlinkLocation of ['destination', 'ancestor']) { + const fixture = createFixture(t); + const destinationRoot = destinationRootFor(fixture); + const outsideRoot = path.join( + fixture.root, + `outside-${symlinkLocation}` + ); + fs.mkdirSync(outsideRoot, { recursive: true }); + fs.writeFileSync(path.join(outsideRoot, 'untouched.txt'), 'untouched'); + + if (symlinkLocation === 'destination') { + fs.mkdirSync(path.dirname(destinationRoot), { recursive: true }); + fs.symlinkSync(outsideRoot, destinationRoot); + } else { + const embeddedMpvRoot = path.dirname(destinationRoot); + fs.mkdirSync(path.dirname(embeddedMpvRoot), { recursive: true }); + fs.symlinkSync(outsideRoot, embeddedMpvRoot); + } + + const result = runStage(fixture); + assert.notEqual(result.status, 0, result.stdout); + assert.match( + result.stderr, + /Linux runtime destination path contains a symbolic link/ + ); + assert.equal( + fs.readFileSync(path.join(outsideRoot, 'untouched.txt'), 'utf8'), + 'untouched' + ); + } +}); + +test('rejects missing Linux headers or manifests', (t) => { + assertStageRejected(t, { removeHeader: true }, /Missing libmpv header/); + assertStageRejected( + t, + { omitManifest: true }, + /Missing Linux runtime manifest/ + ); +}); + +test('rejects unsafe or duplicate declared library names', (t) => { + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].name = '../libmpv.so.2'; + }, + }, + /safe shared-library basename/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles.push({ ...manifest.runtimeFiles[0] }); + }, + }, + /duplicate name "libmpv.so.2"/ + ); +}); + +test('rejects missing files and mismatched size or hash metadata', (t) => { + assertStageRejected( + t, + { removeRuntimeFile: 'libavcodec.so.61' }, + /Missing declared Linux runtime file.*libavcodec\.so\.61/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].size += 1; + manifest.runtimeTotalBytes += 1; + }, + }, + /Size mismatch for Linux runtime file.*libmpv\.so\.2/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].sha256 = '0'.repeat(64); + }, + }, + /SHA-256 mismatch for Linux runtime file.*libmpv\.so\.2/ + ); +}); + +test('rejects forbidden build flags and a missing versioned libmpv entry', (t) => { + const invalidConfigurations = [ + { + mutateManifest(manifest) { + manifest.ffmpeg.configureFlags.push('--enable-gpl'); + }, + expected: /must not include "--enable-gpl"/, + }, + { + mutateManifest(manifest) { + manifest.ffmpeg.configureFlags.push('--enable-nonfree'); + }, + expected: /must not include "--enable-nonfree"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = ['-Dlibmpv=true', '-Dgpl=true']; + }, + expected: /must include "-Dgpl=false"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = ['-Dgpl=false']; + }, + expected: /must include "-Dlibmpv=true"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = [ + '-Dlibmpv=true', + '-Dlibmpv=false', + '-Dgpl=false', + ]; + }, + expected: /must assign "-Dlibmpv" exactly once/, + }, + { + mutateManifest(manifest) { + manifest.runtimeFiles = manifest.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so.2' + ); + }, + expected: /must include a versioned libmpv\.so\.N entry/, + }, + ]; + + for (const { expected, mutateManifest } of invalidConfigurations) { + assertStageRejected(t, { mutateManifest }, expected); + } +}); diff --git a/tools/embedded-mpv/linux-source-archive-contract.cjs b/tools/embedded-mpv/linux-source-archive-contract.cjs new file mode 100644 index 000000000..e6e892bfd --- /dev/null +++ b/tools/embedded-mpv/linux-source-archive-contract.cjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const { isDeepStrictEqual } = require('node:util'); + +const SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION = 1; +const SOURCE_ARCHIVE_NAME = 'linux-frame-copy-runtime-sources.tar.xz'; +const SOURCE_ARCHIVE_BINDING_NAME = 'source-archive-binding.json'; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +function validateLinuxSourceArchiveBinding( + binding, + { expectedRepositoryRevision, expectedSha256 } = {} +) { + const errors = []; + if ( + binding === null || + typeof binding !== 'object' || + Array.isArray(binding) + ) { + return ['Linux source archive binding must be an object.']; + } + if ( + !isDeepStrictEqual(Object.keys(binding).sort(), [ + 'name', + 'repositoryRevision', + 'schemaVersion', + 'sha256', + ]) + ) { + errors.push( + 'Linux source archive binding must contain only schemaVersion, name, sha256, and repositoryRevision.' + ); + } + if (binding.schemaVersion !== SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION) { + errors.push( + `Linux source archive binding schemaVersion must equal ${SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION}.` + ); + } + if (binding.name !== SOURCE_ARCHIVE_NAME) { + errors.push( + `Linux source archive binding name must equal ${SOURCE_ARCHIVE_NAME}.` + ); + } + if ( + typeof binding.sha256 !== 'string' || + !SHA256_PATTERN.test(binding.sha256) + ) { + errors.push( + 'Linux source archive binding sha256 must be a lowercase SHA-256 digest.' + ); + } + if ( + typeof binding.repositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(binding.repositoryRevision) + ) { + errors.push( + 'Linux source archive binding repositoryRevision must be a full Git commit.' + ); + } + if ( + expectedRepositoryRevision !== undefined && + binding.repositoryRevision !== expectedRepositoryRevision + ) { + errors.push( + 'Linux source archive binding repositoryRevision does not match the expected release commit.' + ); + } + if (expectedSha256 !== undefined && binding.sha256 !== expectedSha256) { + errors.push( + 'Linux source archive binding sha256 does not match the source archive bytes.' + ); + } + return errors; +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.alloc(1024 * 1024); + try { + let bytesRead; + do { + bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (bytesRead > 0) { + hash.update(buffer.subarray(0, bytesRead)); + } + } while (bytesRead > 0); + } finally { + fs.closeSync(descriptor); + } + return hash.digest('hex'); +} + +function createLinuxSourceArchiveBinding({ archivePath, repositoryRevision }) { + const stat = fs.lstatSync(archivePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error( + 'Linux source archive must be a non-empty regular file.' + ); + } + const binding = { + schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + name: SOURCE_ARCHIVE_NAME, + sha256: sha256File(archivePath), + repositoryRevision, + }; + const errors = validateLinuxSourceArchiveBinding(binding); + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } + return binding; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + if (tokens.length % 2 !== 0) { + throw new Error('Linux source archive binding arguments are invalid.'); + } + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const token = tokens[index]; + const value = tokens[index + 1]; + if ( + !token.startsWith('--') || + value === undefined || + Object.hasOwn(options, token.slice(2)) + ) { + throw new Error( + 'Linux source archive binding arguments are invalid.' + ); + } + options[token.slice(2)] = value; + } + return { command, options }; +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command !== 'create' || + !options.archive || + !options['repository-revision'] || + !options.output + ) { + throw new Error( + 'Usage: linux-source-archive-contract.cjs create --archive --repository-revision --output ' + ); + } + const binding = createLinuxSourceArchiveBinding({ + archivePath: options.archive, + repositoryRevision: options['repository-revision'], + }); + fs.writeFileSync(options.output, `${JSON.stringify(binding, null, 2)}\n`, { + mode: 0o644, + }); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} + +module.exports = { + SOURCE_ARCHIVE_BINDING_NAME, + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + createLinuxSourceArchiveBinding, + sha256File, + validateLinuxSourceArchiveBinding, +}; diff --git a/tools/embedded-mpv/linux-source-archive-contract.d.cts b/tools/embedded-mpv/linux-source-archive-contract.d.cts new file mode 100644 index 000000000..0ecb0bce8 --- /dev/null +++ b/tools/embedded-mpv/linux-source-archive-contract.d.cts @@ -0,0 +1,26 @@ +interface LinuxSourceArchiveBinding { + schemaVersion: 1; + name: 'linux-frame-copy-runtime-sources.tar.xz'; + sha256: string; + repositoryRevision: string; +} + +declare const LINUX_SOURCE_ARCHIVE_CONTRACT: { + readonly SOURCE_ARCHIVE_BINDING_NAME: 'source-archive-binding.json'; + readonly SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION: 1; + readonly SOURCE_ARCHIVE_NAME: 'linux-frame-copy-runtime-sources.tar.xz'; + createLinuxSourceArchiveBinding(options: { + archivePath: string; + repositoryRevision: string; + }): LinuxSourceArchiveBinding; + sha256File(filePath: string): string; + validateLinuxSourceArchiveBinding( + binding: unknown, + options?: { + expectedRepositoryRevision?: string; + expectedSha256?: string; + } + ): string[]; +}; + +export = LINUX_SOURCE_ARCHIVE_CONTRACT; diff --git a/tools/embedded-mpv/runtime-probe-contract.cjs b/tools/embedded-mpv/runtime-probe-contract.cjs new file mode 100644 index 000000000..28e8bde7d --- /dev/null +++ b/tools/embedded-mpv/runtime-probe-contract.cjs @@ -0,0 +1,6 @@ +const RUNTIME_PROBE_CONTRACT = Object.freeze({ + RUNTIME_PROBE_MAX_BUFFER_BYTES: 16 * 1024 * 1024, + RUNTIME_PROBE_TIMEOUT_MS: 3000, +}); + +module.exports = RUNTIME_PROBE_CONTRACT; diff --git a/tools/embedded-mpv/runtime-probe-contract.d.cts b/tools/embedded-mpv/runtime-probe-contract.d.cts new file mode 100644 index 000000000..545231e21 --- /dev/null +++ b/tools/embedded-mpv/runtime-probe-contract.d.cts @@ -0,0 +1,6 @@ +declare const RUNTIME_PROBE_CONTRACT: Readonly<{ + readonly RUNTIME_PROBE_MAX_BUFFER_BYTES: 16777216; + readonly RUNTIME_PROBE_TIMEOUT_MS: 3000; +}>; + +export = RUNTIME_PROBE_CONTRACT; diff --git a/tools/embedded-mpv/stage-runtime.mjs b/tools/embedded-mpv/stage-runtime.mjs index 81b06e3fa..1b758034c 100644 --- a/tools/embedded-mpv/stage-runtime.mjs +++ b/tools/embedded-mpv/stage-runtime.mjs @@ -1,5 +1,14 @@ +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { + isLinuxSystemBuildInputManifest, + validateLinuxRuntimeManifest, + validateLinuxSystemBuildInputManifest, +} = require('./linux-runtime-manifest.cjs'); const rawArgs = process.argv.slice(2); const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; @@ -161,48 +170,191 @@ function readJsonIfExists(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } -try { - assertExists( - path.join(sourceIncludeDir, 'mpv', 'client.h'), - 'Missing libmpv header' - ); - if (platform !== 'linux' && !findRuntimeFile(sourceLibDir)) { - throw new Error( - `Missing libmpv runtime for ${platform} in ${sourceLibDir}` - ); +function readLinuxRuntimeManifest() { + const manifestPath = path.join(normalizedPrefix, 'runtime-manifest.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Missing Linux runtime manifest: ${manifestPath}`); } - if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) { + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { throw new Error( - `Missing Windows libmpv import library in ${sourceLibDir}` + `Invalid JSON in Linux runtime manifest ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }` ); } - fs.rmSync(destinationIncludeDir, { recursive: true, force: true }); - fs.rmSync(destinationLibDir, { recursive: true, force: true }); - fs.mkdirSync(destinationRoot, { recursive: true }); + const mode = isLinuxSystemBuildInputManifest(manifest) + ? 'system-build-inputs' + : 'bundled-runtime'; + const errors = + mode === 'system-build-inputs' + ? validateLinuxSystemBuildInputManifest(manifest) + : validateLinuxRuntimeManifest(manifest); + if (errors.length > 0) { + throw new Error( + ['Invalid Linux runtime manifest.', ...errors].join('\n') + ); + } - copyDirectory( - path.join(sourceIncludeDir, 'mpv'), - path.join(destinationIncludeDir, 'mpv') + return { manifest, mode }; +} + +function sha256Contents(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function resolvePathInsideDirectory(filePath, directory, message) { + const resolvedDirectory = fs.realpathSync(directory); + const resolvedFilePath = fs.realpathSync(filePath); + const relativePath = path.relative(resolvedDirectory, resolvedFilePath); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error(`${message}: ${filePath}`); + } + + return resolvedFilePath; +} + +function readVerifiedLinuxRuntimeFiles(manifest) { + return manifest.runtimeFiles.map((runtimeFile) => { + const declaredSourcePath = path.join(sourceLibDir, runtimeFile.name); + if (!fs.existsSync(declaredSourcePath)) { + throw new Error( + `Missing declared Linux runtime file: ${declaredSourcePath}` + ); + } + + const sourcePath = resolvePathInsideDirectory( + declaredSourcePath, + sourceLibDir, + 'Declared Linux runtime file resolves outside prefix/lib' + ); + const sourceStat = fs.statSync(sourcePath); + if (!sourceStat.isFile()) { + throw new Error( + `Declared Linux runtime path is not a regular file: ${declaredSourcePath}` + ); + } + + const contents = fs.readFileSync(sourcePath); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for Linux runtime file ${declaredSourcePath}: expected ${runtimeFile.size}, received ${contents.byteLength}` + ); + } + + const actualSha256 = sha256Contents(contents); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for Linux runtime file ${declaredSourcePath}: expected ${runtimeFile.sha256}, received ${actualSha256}` + ); + } + + return { contents, runtimeFile }; + }); +} + +function readLinuxHeaderFiles() { + resolvePathInsideDirectory( + sourceIncludeDir, + normalizedPrefix, + 'Linux include directory resolves outside source prefix' ); - if (platform !== 'linux') { - copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter); - } - if (platform === 'win32') { - copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter); + + const headerFiles = []; + function visitDirectory(sourceDirectory, relativeDirectory) { + for (const entry of fs.readdirSync(sourceDirectory, { + withFileTypes: true, + })) { + const sourcePath = path.join(sourceDirectory, entry.name); + const relativePath = path.join(relativeDirectory, entry.name); + if (entry.isDirectory()) { + visitDirectory(sourcePath, relativePath); + continue; + } + if (!entry.isFile() && !entry.isSymbolicLink()) { + continue; + } + + const resolvedSourcePath = resolvePathInsideDirectory( + sourcePath, + sourceIncludeDir, + 'Linux header resolves outside prefix/include' + ); + if (!fs.statSync(resolvedSourcePath).isFile()) { + throw new Error( + `Linux header is not a regular file: ${sourcePath}` + ); + } + headerFiles.push({ + contents: fs.readFileSync(resolvedSourcePath), + relativePath, + }); + } } - const externalManifest = - readJsonIfExists( - path.join(normalizedPrefix, 'runtime-manifest.json') - ) ?? {}; - const manifest = { + visitDirectory(path.join(sourceIncludeDir, 'mpv'), 'mpv'); + return headerFiles; +} + +function lstatIfExists(filePath) { + try { + return fs.lstatSync(filePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function assertSafeLinuxDestinationPath() { + const relativeDestination = path.relative(workspaceRoot, destinationRoot); + if ( + relativeDestination === '..' || + relativeDestination.startsWith(`..${path.sep}`) || + path.isAbsolute(relativeDestination) + ) { + throw new Error( + `Linux runtime destination escapes the workspace: ${destinationRoot}` + ); + } + + let currentPath = workspaceRoot; + for (const segment of relativeDestination.split(path.sep)) { + currentPath = path.join(currentPath, segment); + const stat = lstatIfExists(currentPath); + if (stat?.isSymbolicLink()) { + throw new Error( + `Linux runtime destination path contains a symbolic link: ${currentPath}` + ); + } + } +} + +function createLinuxStagedManifest(externalManifest, mode) { + return { ...externalManifest, origin: 'vendored-lgpl', + ...(mode === 'bundled-runtime' + ? { sourceBuildOrigin: externalManifest.origin } + : {}), platform, arch, stagedAt: new Date().toISOString(), - runtimeFiles: listRuntimeFiles(destinationLibDir), + runtimeFiles: + mode === 'bundled-runtime' + ? externalManifest.runtimeFiles.map((runtimeFile) => ({ + ...runtimeFile, + })) + : [], ffmpeg: { licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree', @@ -219,15 +371,193 @@ try { externalManifest.mpv?.mesonFlags ?? 'Record the exact mpv Meson flags used to build this runtime.', }, - sourceDistribution: - externalManifest.sourceDistribution ?? - `Publish exact source archives and local patches with the ${platform}-${arch} binary release.`, + sourceDistribution: externalManifest.sourceDistribution, }; +} + +function writeLinuxStagingTree( + stagingRoot, + headerFiles, + verifiedRuntimeFiles, + stagedManifest +) { + for (const headerFile of headerFiles) { + const destinationPath = path.join( + stagingRoot, + 'include', + headerFile.relativePath + ); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, headerFile.contents); + fs.chmodSync(destinationPath, 0o644); + } + + for (const verifiedRuntimeFile of verifiedRuntimeFiles) { + const destinationPath = path.join( + stagingRoot, + 'lib', + verifiedRuntimeFile.runtimeFile.name + ); + fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); + fs.writeFileSync(destinationPath, verifiedRuntimeFile.contents); + fs.chmodSync(destinationPath, 0o755); + } fs.writeFileSync( - path.join(destinationRoot, 'runtime-manifest.json'), - `${JSON.stringify(manifest, null, 2)}\n` + path.join(stagingRoot, 'runtime-manifest.json'), + `${JSON.stringify(stagedManifest, null, 2)}\n` ); +} + +function publishLinuxStagingTree(stagingRoot) { + const token = `${process.pid}-${crypto.randomBytes(8).toString('hex')}`; + const backupRoot = path.join( + path.dirname(destinationRoot), + `.linux-x64.backup-${token}` + ); + let movedPreviousDestination = false; + + try { + assertSafeLinuxDestinationPath(); + const destinationStat = lstatIfExists(destinationRoot); + if (destinationStat && !destinationStat.isDirectory()) { + throw new Error( + `Linux runtime destination is not a directory: ${destinationRoot}` + ); + } + if (destinationStat) { + fs.renameSync(destinationRoot, backupRoot); + movedPreviousDestination = true; + } + + fs.renameSync(stagingRoot, destinationRoot); + if (movedPreviousDestination) { + fs.rmSync(backupRoot, { recursive: true, force: true }); + } + } catch (error) { + if ( + movedPreviousDestination && + !lstatIfExists(destinationRoot) && + lstatIfExists(backupRoot) + ) { + fs.renameSync(backupRoot, destinationRoot); + } + throw error; + } finally { + fs.rmSync(stagingRoot, { recursive: true, force: true }); + if (lstatIfExists(destinationRoot)) { + fs.rmSync(backupRoot, { recursive: true, force: true }); + } + } +} + +function stageLinuxRuntime(headerFiles, verifiedRuntimeFiles, stagedManifest) { + assertSafeLinuxDestinationPath(); + const destinationParent = path.dirname(destinationRoot); + fs.mkdirSync(destinationParent, { recursive: true }); + assertSafeLinuxDestinationPath(); + + const stagingRoot = path.join( + destinationParent, + `.linux-x64.stage-${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}` + ); + fs.mkdirSync(stagingRoot); + try { + writeLinuxStagingTree( + stagingRoot, + headerFiles, + verifiedRuntimeFiles, + stagedManifest + ); + publishLinuxStagingTree(stagingRoot); + } catch (error) { + fs.rmSync(stagingRoot, { recursive: true, force: true }); + throw error; + } +} + +try { + assertExists( + path.join(sourceIncludeDir, 'mpv', 'client.h'), + 'Missing libmpv header' + ); + + if (platform === 'linux') { + const { manifest: externalManifest, mode } = readLinuxRuntimeManifest(); + const headerFiles = readLinuxHeaderFiles(); + const verifiedRuntimeFiles = + mode === 'bundled-runtime' + ? readVerifiedLinuxRuntimeFiles(externalManifest) + : []; + stageLinuxRuntime( + headerFiles, + verifiedRuntimeFiles, + createLinuxStagedManifest(externalManifest, mode) + ); + } else { + const externalManifest = + readJsonIfExists( + path.join(normalizedPrefix, 'runtime-manifest.json') + ) ?? {}; + if (!findRuntimeFile(sourceLibDir)) { + throw new Error( + `Missing libmpv runtime for ${platform} in ${sourceLibDir}` + ); + } + if (platform === 'win32' && !hasWindowsImportLibrary(sourceLibDir)) { + throw new Error( + `Missing Windows libmpv import library in ${sourceLibDir}` + ); + } + + fs.rmSync(destinationIncludeDir, { recursive: true, force: true }); + fs.rmSync(destinationLibDir, { recursive: true, force: true }); + fs.mkdirSync(destinationRoot, { recursive: true }); + + copyDirectory( + path.join(sourceIncludeDir, 'mpv'), + path.join(destinationIncludeDir, 'mpv') + ); + copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter); + if (platform === 'win32') { + copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter); + } + + const manifest = { + ...externalManifest, + origin: 'vendored-lgpl', + platform, + arch, + stagedAt: new Date().toISOString(), + runtimeFiles: listRuntimeFiles(destinationLibDir), + ffmpeg: { + licensePolicy: + 'LGPL, built without --enable-gpl and --enable-nonfree', + ...externalManifest.ffmpeg, + configureFlags: + externalManifest.ffmpeg?.configureFlags ?? + 'Record the exact FFmpeg configure flags used to build this runtime.', + }, + mpv: { + licensePolicy: + 'LGPL-compatible libmpv, built with -Dlibmpv=true -Dgpl=false', + ...externalManifest.mpv, + mesonFlags: + externalManifest.mpv?.mesonFlags ?? + 'Record the exact mpv Meson flags used to build this runtime.', + }, + sourceDistribution: + externalManifest.sourceDistribution ?? + `Publish exact source archives and local patches with the ${platform}-${arch} binary release.`, + }; + + fs.writeFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + } console.log( `Staged embedded MPV runtime for ${platform}-${arch} at ${path.relative( diff --git a/tools/packaging/asar-dependency-closure.mjs b/tools/packaging/asar-dependency-closure.mjs index 7f7768a0f..6fd4d775c 100644 --- a/tools/packaging/asar-dependency-closure.mjs +++ b/tools/packaging/asar-dependency-closure.mjs @@ -15,10 +15,226 @@ * so the core logic stays pure and unit-testable without a real archive. */ +import fs from 'node:fs'; import path from 'node:path'; +import { TextDecoder } from 'node:util'; const PACKAGE_MANIFEST = 'package.json'; const NODE_MODULES_SEGMENT = '/node_modules/'; +const EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT = '/electron-backend/native'; +const ASAR_SIZE_PREFIX_BYTES = 8; +const ASAR_HEADER_MAX_BYTES = 32 * 1024 * 1024; +const ASAR_ENTRY_LIMIT = 250_000; +const ASAR_PATH_MAX_BYTES = 32 * 1024; +const ASAR_LISTED_PATH_MAX_BYTES = 32 * 1024 * 1024; + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function* ownRecordEntries(record) { + for (const name in record) { + if (Object.hasOwn(record, name)) { + yield [name, record[name]]; + } + } +} + +function readExactly(descriptor, buffer, position) { + let offset = 0; + while (offset < buffer.length) { + const bytesRead = fs.readSync( + descriptor, + buffer, + offset, + buffer.length - offset, + position + offset + ); + if (bytesRead === 0) { + throw new Error('ASAR archive ended before its header was read.'); + } + offset += bytesRead; + } +} + +/** + * Lists an ASAR from its bounded Chromium-Pickle JSON header using only Node + * built-ins. Public-release verification runs from a clean tag checkout, so it + * cannot rely on the workspace-only `@electron/asar` development dependency. + */ +export function listAsarPackageEntries( + archivePath, + { maxListedPathBytes = ASAR_LISTED_PATH_MAX_BYTES } = {} +) { + if ( + !Number.isSafeInteger(maxListedPathBytes) || + maxListedPathBytes <= 0 || + maxListedPathBytes > ASAR_LISTED_PATH_MAX_BYTES + ) { + throw new Error('ASAR listed-path byte limit is invalid.'); + } + const noFollow = fs.constants.O_NOFOLLOW ?? 0; + const descriptor = fs.openSync( + archivePath, + fs.constants.O_RDONLY | noFollow + ); + let header; + try { + const archiveStat = fs.fstatSync(descriptor); + if (!archiveStat.isFile()) { + throw new Error('ASAR archive must be a regular file.'); + } + const sizePrefix = Buffer.alloc(ASAR_SIZE_PREFIX_BYTES); + readExactly(descriptor, sizePrefix, 0); + if (sizePrefix.readUInt32LE(0) !== 4) { + throw new Error('ASAR size pickle is malformed.'); + } + const headerSize = sizePrefix.readUInt32LE(4); + if (headerSize > ASAR_HEADER_MAX_BYTES) { + throw new Error( + `ASAR header exceeds the ${String( + ASAR_HEADER_MAX_BYTES + )}-byte limit.` + ); + } + if ( + headerSize < 8 || + headerSize + ASAR_SIZE_PREFIX_BYTES > archiveStat.size + ) { + throw new Error('ASAR header size is invalid.'); + } + header = Buffer.alloc(headerSize); + readExactly(descriptor, header, ASAR_SIZE_PREFIX_BYTES); + } finally { + fs.closeSync(descriptor); + } + + const payloadSize = header.readUInt32LE(0); + if (payloadSize + 4 !== header.length || payloadSize < 4) { + throw new Error('ASAR header pickle is malformed.'); + } + const jsonLength = header.readInt32LE(4); + const alignedJsonLength = + jsonLength >= 0 ? Math.ceil(jsonLength / 4) * 4 : -1; + if ( + jsonLength <= 0 || + alignedJsonLength + 4 !== payloadSize || + header + .subarray(8 + jsonLength, 8 + alignedJsonLength) + .some((byte) => byte !== 0) + ) { + throw new Error('ASAR header JSON framing is malformed.'); + } + + let parsedHeader; + try { + const headerJson = new TextDecoder('utf-8', { fatal: true }).decode( + header.subarray(8, 8 + jsonLength) + ); + parsedHeader = JSON.parse(headerJson); + } catch (error) { + throw new Error( + `ASAR header JSON is invalid: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + if (!isRecord(parsedHeader) || !isRecord(parsedHeader.files)) { + throw new Error('ASAR header must contain a files mapping.'); + } + + const entries = []; + let listedPathBytes = 0; + const pendingDirectories = [ + { + entries: ownRecordEntries(parsedHeader.files), + parentPath: '', + }, + ]; + while (pendingDirectories.length > 0) { + const directory = pendingDirectories.at(-1); + const nextEntry = directory.entries.next(); + if (nextEntry.done) { + pendingDirectories.pop(); + continue; + } + const [name, node] = nextEntry.value; + const { parentPath } = directory; + if ( + !name || + name === '.' || + name === '..' || + name.includes('/') || + name.includes('\0') + ) { + throw new Error('ASAR header contains an invalid path segment.'); + } + if (!isRecord(node)) { + throw new Error('ASAR header contains an invalid filesystem node.'); + } + const entryPath = `${parentPath}/${name}`; + const entryPathBytes = Buffer.byteLength(entryPath, 'utf8'); + if (entryPathBytes > ASAR_PATH_MAX_BYTES) { + throw new Error('ASAR header contains an overlong entry path.'); + } + if (entryPathBytes > maxListedPathBytes - listedPathBytes) { + throw new Error( + `Cumulative ASAR entry paths exceed the ${String( + maxListedPathBytes + )}-byte limit.` + ); + } + listedPathBytes += entryPathBytes; + entries.push(entryPath); + if (entries.length > ASAR_ENTRY_LIMIT) { + throw new Error( + `ASAR header exceeds the ${String(ASAR_ENTRY_LIMIT)}-entry limit.` + ); + } + + if (Object.hasOwn(node, 'files')) { + if (!isRecord(node.files)) { + throw new Error( + 'ASAR header contains an invalid directory mapping.' + ); + } + pendingDirectories.push({ + entries: ownRecordEntries(node.files), + parentPath: entryPath, + }); + } else if ( + !( + (Number.isSafeInteger(node.size) && node.size >= 0) || + (typeof node.link === 'string' && node.link.length > 0) + ) + ) { + throw new Error('ASAR header contains an invalid file node.'); + } + } + return entries; +} + +/** + * Embedded MPV's native payload is profile-specific and is written only by + * afterPack. Any copy retained in app.asar predates that mutation and can leak + * x64 helpers, runtimes, manifests, or notices into marker-only/system builds. + */ +export function collectEmbeddedMpvNativeArchiveEntries( + asarEntries, + pathSep = path.sep +) { + const toPosix = (value) => + pathSep === '\\' ? value.replaceAll('\\', '/') : value; + + return asarEntries + .map(toPosix) + .map((entry) => (entry.startsWith('/') ? entry : `/${entry}`)) + .filter( + (entry) => + entry === EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT || + entry.startsWith(`${EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT}/`) + ); +} /** * A genuine installed package lives directly under a node_modules directory as @@ -70,7 +286,11 @@ export function collectAsarPackageDirs(asarEntries) { * as `/electron-backend/node_modules/foo`. Returns the resolved package * directory or null when the dependency is absent. */ -export function resolvePackagedDependency(fromDir, dependencyName, packageDirs) { +export function resolvePackagedDependency( + fromDir, + dependencyName, + packageDirs +) { let current = fromDir; while (true) { diff --git a/tools/packaging/asar-dependency-closure.test.mjs b/tools/packaging/asar-dependency-closure.test.mjs index 0c5fc81fe..d1737371a 100644 --- a/tools/packaging/asar-dependency-closure.test.mjs +++ b/tools/packaging/asar-dependency-closure.test.mjs @@ -1,13 +1,40 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { + collectEmbeddedMpvNativeArchiveEntries, collectAsarPackageDirs, findMissingPackagedDependencies, inspectPackagedDependencyClosure, + listAsarPackageEntries, resolvePackagedDependency, } from './asar-dependency-closure.mjs'; +const require = createRequire(import.meta.url); +const { + createPackage: createAsarPackage, + listPackage: listAsarPackageWithDependency, +} = require('@electron/asar'); + +function writeSyntheticAsarHeader(archivePath, files) { + const json = Buffer.from(JSON.stringify({ files }), 'utf8'); + const alignedJsonLength = Math.ceil(json.length / 4) * 4; + const headerPayloadSize = 4 + alignedJsonLength; + const headerSize = 4 + headerPayloadSize; + const sizePrefix = Buffer.alloc(8); + sizePrefix.writeUInt32LE(4, 0); + sizePrefix.writeUInt32LE(headerSize, 4); + const header = Buffer.alloc(headerSize); + header.writeUInt32LE(headerPayloadSize, 0); + header.writeInt32LE(json.length, 4); + json.copy(header, 8); + fs.writeFileSync(archivePath, Buffer.concat([sizePrefix, header])); +} + /** * Builds a `readManifest(dir)` backed by an in-memory map of * `{ packageDir: manifest }`, mirroring how manifests are read from an asar. @@ -16,6 +43,202 @@ function manifestReader(manifests) { return (packageDir) => manifests[packageDir] ?? null; } +test('collectEmbeddedMpvNativeArchiveEntries finds stale native payloads on every host separator', () => { + assert.deepEqual( + collectEmbeddedMpvNativeArchiveEntries( + [ + '/electron-backend/main.js', + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + '/web/index.html', + ], + '/' + ), + [ + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + ] + ); + assert.deepEqual( + collectEmbeddedMpvNativeArchiveEntries( + [ + '\\electron-backend\\native\\embedded-mpv-unavailable.txt', + '\\electron-backend\\node_modules\\package.json', + ], + '\\' + ), + ['/electron-backend/native/embedded-mpv-unavailable.txt'] + ); +}); + +test('listAsarPackageEntries matches Electron ASAR listings from a bounded header', async (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const sourceRoot = path.join(temporaryRoot, 'source'); + const archivePath = path.join(temporaryRoot, 'app.asar'); + fs.mkdirSync(path.join(sourceRoot, 'electron-backend', 'native'), { + recursive: true, + }); + fs.writeFileSync(path.join(sourceRoot, 'main.js'), 'main'); + fs.writeFileSync( + path.join( + sourceRoot, + 'electron-backend', + 'native', + 'embedded-mpv-runtime.json' + ), + '{}\n' + ); + await createAsarPackage(sourceRoot, archivePath); + + assert.deepEqual( + listAsarPackageEntries(archivePath), + listAsarPackageWithDependency(archivePath) + ); +}); + +test('listAsarPackageEntries rejects an unbounded declared header before allocation', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const archivePath = path.join(temporaryRoot, 'oversized.asar'); + const prefix = Buffer.alloc(8); + prefix.writeUInt32LE(4, 0); + prefix.writeUInt32LE(0xffffffff, 4); + fs.writeFileSync(archivePath, prefix); + + assert.throws( + () => listAsarPackageEntries(archivePath), + /ASAR header exceeds.*limit/i + ); +}); + +test('listAsarPackageEntries traverses untrusted directory mappings without bulk Object.entries allocation', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const archivePath = path.join(temporaryRoot, 'wide.asar'); + const files = Object.fromEntries( + Array.from({ length: 4096 }, (_, index) => [ + `entry-${String(index).padStart(4, '0')}`, + { size: 0, offset: '0' }, + ]) + ); + writeSyntheticAsarHeader(archivePath, files); + + const originalObjectEntries = Object.entries; + Object.entries = () => { + throw new Error( + 'untrusted ASAR mappings must not be materialized in bulk' + ); + }; + try { + assert.equal(listAsarPackageEntries(archivePath).length, 4096); + } finally { + Object.entries = originalObjectEntries; + } +}); + +test('listAsarPackageEntries bounds cumulative paths under a wide long prefix', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const archivePath = path.join(temporaryRoot, 'wide-prefix.asar'); + const children = Object.fromEntries( + Array.from({ length: 64 }, (_, index) => [ + `leaf-${String(index).padStart(2, '0')}`, + { size: 0, offset: '0' }, + ]) + ); + writeSyntheticAsarHeader(archivePath, { + ['prefix-'.repeat(32)]: { files: children }, + }); + + assert.throws( + () => + listAsarPackageEntries(archivePath, { + maxListedPathBytes: 1024, + }), + /cumulative ASAR entry paths exceed.*limit/i + ); +}); + +test('listAsarPackageEntries fails closed on malformed pickle, padding, and UTF-8', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const validArchivePath = path.join(temporaryRoot, 'valid.asar'); + writeSyntheticAsarHeader(validArchivePath, { + a: { size: 0, offset: '0' }, + }); + const validArchive = fs.readFileSync(validArchivePath); + + const malformedPickle = Buffer.from(validArchive); + malformedPickle.writeUInt32LE(malformedPickle.readUInt32LE(8) - 4, 8); + const malformedPicklePath = path.join( + temporaryRoot, + 'malformed-pickle.asar' + ); + fs.writeFileSync(malformedPicklePath, malformedPickle); + assert.throws( + () => listAsarPackageEntries(malformedPicklePath), + /header pickle is malformed/i + ); + + const malformedPadding = Buffer.from(validArchive); + malformedPadding[malformedPadding.length - 1] = 0xff; + const malformedPaddingPath = path.join( + temporaryRoot, + 'malformed-padding.asar' + ); + fs.writeFileSync(malformedPaddingPath, malformedPadding); + assert.throws( + () => listAsarPackageEntries(malformedPaddingPath), + /JSON framing is malformed/i + ); + + const malformedUtf8 = Buffer.from(validArchive); + malformedUtf8[16] = 0xff; + const malformedUtf8Path = path.join(temporaryRoot, 'malformed-utf8.asar'); + fs.writeFileSync(malformedUtf8Path, malformedUtf8); + assert.throws( + () => listAsarPackageEntries(malformedUtf8Path), + /header JSON is invalid/i + ); +}); + +test('listAsarPackageEntries rejects unsafe archive path segments', (t) => { + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-asar-header-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + + for (const [index, unsafeSegment] of [ + '.', + '..', + 'nested/name', + 'nul\0name', + ].entries()) { + const archivePath = path.join( + temporaryRoot, + `unsafe-${String(index)}.asar` + ); + writeSyntheticAsarHeader(archivePath, { + [unsafeSegment]: { size: 0, offset: '0' }, + }); + assert.throws( + () => listAsarPackageEntries(archivePath), + /invalid path segment/i + ); + } +}); + test('collectAsarPackageDirs keeps only genuine package roots', () => { const dirs = collectAsarPackageDirs([ '/package.json', diff --git a/tools/packaging/configure-linux-frame-copy-build.mjs b/tools/packaging/configure-linux-frame-copy-build.mjs new file mode 100644 index 000000000..6cd570418 --- /dev/null +++ b/tools/packaging/configure-linux-frame-copy-build.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, +} = require('./linux-frame-copy-profile.cjs'); +const scriptPath = fileURLToPath(import.meta.url); + +function cloneJson(value) { + return JSON.parse(JSON.stringify(value)); +} + +function targetName(target) { + const value = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.target + : null; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + 'Electron Builder Linux targets must have a non-empty target name.' + ); + } + return value.trim().toLowerCase(); +} + +function targetObject(target) { + return typeof target === 'string' ? { target } : { ...target }; +} + +function fpmDependencyName(option) { + return String(option).match( + /^--depends(?:=|\s+)([A-Za-z0-9+_.-]+)(?:$|\s|[<>=])/ + )?.[1]; +} + +function configureSystemDependencies(config) { + for (const [format, dependencies] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + const frameCopyDependencies = new Set(dependencies); + const formatConfig = { ...(config[format] ?? {}) }; + const otherFpmOptions = (formatConfig.fpm ?? []).filter( + (option) => !frameCopyDependencies.has(fpmDependencyName(option)) + ); + formatConfig.fpm = [ + ...otherFpmOptions, + ...dependencies.map((dependency) => `--depends=${dependency}`), + ]; + config[format] = formatConfig; + } +} + +function removeForeignFrameCopyDependency(config, format) { + const dependencies = new Set( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? [] + ); + const formatConfig = { ...(config[format] ?? {}) }; + const retainedFpmOptions = (formatConfig.fpm ?? []).filter( + (option) => !dependencies.has(fpmDependencyName(option)) + ); + if (retainedFpmOptions.length > 0) { + formatConfig.fpm = retainedFpmOptions; + } else { + delete formatConfig.fpm; + } + config[format] = formatConfig; +} + +export function configureLinuxFrameCopyBuild( + electronBuilderConfig, + { profileName, foreignDeb = false, foreignArch } = {} +) { + const hasForeignArch = foreignArch !== undefined; + if ( + !electronBuilderConfig || + typeof electronBuilderConfig !== 'object' || + Array.isArray(electronBuilderConfig) + ) { + throw new TypeError( + 'Electron Builder configuration must be an object.' + ); + } + if (foreignDeb && profileName) { + throw new Error( + 'The marker-only foreign DEB pass must not select a frame-copy profile.' + ); + } + if (hasForeignArch && !foreignDeb) { + throw new Error( + 'A marker-only foreign architecture requires --foreign-deb.' + ); + } + const configured = cloneJson(electronBuilderConfig); + const targets = configured.linux?.target; + if (!Array.isArray(targets)) { + throw new Error('Electron Builder linux.target must be an array.'); + } + + if (foreignDeb) { + const debTarget = targets.find( + (target) => targetName(target) === 'deb' + ); + if (!debTarget) { + throw new Error( + 'Electron Builder has no DEB target for the foreign-architecture pass.' + ); + } + const configuredDebTarget = targetObject(debTarget); + const configuredArches = Array.isArray(configuredDebTarget.arch) + ? configuredDebTarget.arch + : [configuredDebTarget.arch].filter(Boolean); + const foreignArches = configuredArches.filter( + (architecture) => architecture !== 'x64' + ); + if (foreignArches.length === 0) { + throw new Error( + 'Electron Builder DEB target has no foreign architectures.' + ); + } + if ( + hasForeignArch && + (typeof foreignArch !== 'string' || + !foreignArches.includes(foreignArch)) + ) { + throw new Error( + `Unsupported marker-only foreign DEB architecture "${foreignArch}". Expected one of: ${foreignArches.join( + ', ' + )}.` + ); + } + configuredDebTarget.arch = hasForeignArch + ? [foreignArch] + : foreignArches; + configured.linux.target = [configuredDebTarget]; + configured.directories = { + ...(configured.directories ?? {}), + output: 'dist/executables-linux-foreign', + }; + removeForeignFrameCopyDependency(configured, 'deb'); + return configured; + } + + const profile = resolveLinuxFrameCopyProfile(profileName); + const allowedTargets = new Set(profile.targets); + const targetsByName = new Map( + profile.targets.map((profileTarget) => [profileTarget, []]) + ); + for (const target of targets) { + const name = targetName(target); + if (allowedTargets.has(name)) { + targetsByName.get(name).push(target); + } + } + const duplicateTargets = profile.targets.filter( + (profileTarget) => targetsByName.get(profileTarget).length > 1 + ); + const missingTargets = profile.targets.filter( + (profileTarget) => targetsByName.get(profileTarget).length === 0 + ); + if (duplicateTargets.length > 0 || missingTargets.length > 0) { + throw new Error( + [ + `Invalid Electron Builder targets for Linux profile "${profile.name}".`, + ...(duplicateTargets.length > 0 + ? [ + `Found duplicate target "${duplicateTargets.join( + '", "' + )}".`, + ] + : []), + ...(missingTargets.length > 0 + ? [`Missing targets: ${missingTargets.join(', ')}.`] + : []), + ].join(' ') + ); + } + configured.linux.target = profile.targets.map((profileTarget) => { + const target = targetsByName.get(profileTarget)[0]; + const configuredTarget = targetObject(target); + if (profile.name === 'system') { + configuredTarget.arch = ['x64']; + } + return configuredTarget; + }); + if (profile.name === 'system') { + configureSystemDependencies(configured); + } + return configured; +} + +function parseArguments(argv) { + const normalized = argv[0] === '--' ? argv.slice(1) : argv; + let configPath = 'electron-builder.json'; + let profileName; + let foreignDeb = false; + let foreignArch; + const nextValue = (flag, index) => { + const value = normalized[index + 1]; + if ( + typeof value !== 'string' || + value.trim() === '' || + value.startsWith('--') + ) { + throw new Error(`${flag} requires a value.`); + } + return value; + }; + for (let index = 0; index < normalized.length; index += 1) { + const argument = normalized[index]; + if (argument === '--config') { + configPath = nextValue(argument, index); + index += 1; + } else if (argument === '--profile') { + profileName = nextValue(argument, index); + index += 1; + } else if (argument === '--foreign-deb') { + foreignDeb = true; + } else if (argument === '--foreign-arch') { + foreignArch = nextValue(argument, index); + index += 1; + } else { + throw new Error(`Unsupported configurator argument: ${argument}`); + } + } + if (!foreignDeb && !profileName) { + throw new Error('--profile or --foreign-deb is required.'); + } + return { + configPath: path.resolve(configPath), + profileName, + foreignDeb, + foreignArch, + }; +} + +function main() { + const options = parseArguments(process.argv.slice(2)); + const config = JSON.parse(fs.readFileSync(options.configPath, 'utf8')); + const configured = configureLinuxFrameCopyBuild(config, options); + fs.writeFileSync( + options.configPath, + `${JSON.stringify(configured, null, 4)}\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs new file mode 100644 index 000000000..4f3ec6b81 --- /dev/null +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -0,0 +1,866 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { parse as parseYaml } from 'yaml'; + +import { configureLinuxFrameCopyBuild } from './configure-linux-frame-copy-build.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + '..', + '..' +); +const electronBuilderConfig = JSON.parse( + fs.readFileSync(path.join(workspaceRoot, 'electron-builder.json'), 'utf8') +); +const buildWorkflow = fs.readFileSync( + path.join(workspaceRoot, '.github', 'workflows', 'build-and-make.yaml'), + 'utf8' +); +const buildWorkflowConfig = parseYaml(buildWorkflow); + +function workflowStep(name) { + const marker = ` - name: ${name}\n`; + const start = buildWorkflow.indexOf(marker); + assert.notEqual(start, -1, `Missing workflow step: ${name}`); + const nextStep = buildWorkflow.indexOf('\n - name:', start + 1); + return buildWorkflow.slice( + start, + nextStep === -1 ? buildWorkflow.length : nextStep + ); +} + +function configuredTargets(config) { + return config.linux.target.map(({ target, arch }) => ({ + target: target.toLowerCase(), + arch, + })); +} + +test('configures an x64-only system pass with exact package dependencies', () => { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: ['x64'] }, + { target: 'rpm', arch: ['x64'] }, + { target: 'pacman', arch: ['x64'] }, + ]); + assert.deepEqual(configured.deb.fpm, [ + '--depends=libmpv2', + '--depends=libegl1', + '--depends=libgl1', + '--depends=libgbm1', + ]); + assert.deepEqual(configured.rpm.fpm, [ + '--depends=mpv-libs', + '--depends=libglvnd-egl', + '--depends=libglvnd-glx', + '--depends=mesa-libgbm', + ]); + assert.deepEqual(configured.pacman.fpm, [ + '--depends=mpv', + '--depends=libglvnd', + '--depends=mesa', + ]); +}); + +test('configures portable and flatpak passes without mixing targets', () => { + const portable = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'portable', + }); + assert.deepEqual(configuredTargets(portable), [ + { target: 'appimage', arch: ['x64', 'armv7l', 'arm64'] }, + { target: 'snap', arch: ['x64', 'armv7l'] }, + ]); + + const flatpak = configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'flatpak', + }); + assert.deepEqual(configuredTargets(flatpak), [ + { target: 'flatpak', arch: ['x64'] }, + ]); + assert.equal(portable.snap.base, 'core22'); + assert.equal(portable.snap.confinement, 'strict'); + assert.deepEqual(portable.snap.layout, { + '/usr/share/libdrm': { + bind: '$SNAP/graphics/libdrm', + }, + '/usr/share/drirc.d': { + symlink: '$SNAP/graphics/drirc.d', + }, + }); + assert.deepEqual(portable.snap.plugs, [ + 'default', + { + 'graphics-core22': { + interface: 'content', + target: '$SNAP/graphics', + 'default-provider': 'mesa-core22', + }, + }, + { + 'shared-memory': { + interface: 'shared-memory', + private: true, + }, + }, + ]); +}); + +test('configures a separate marker-only foreign DEB pass without libmpv metadata', () => { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: ['armv7l', 'arm64'] }, + ]); + assert.equal( + configured.deb.fpm?.some((entry) => + entry.includes('--depends=libmpv2') + ) ?? false, + false + ); + assert.equal( + configured.directories.output, + 'dist/executables-linux-foreign' + ); +}); + +test('configures exactly one requested marker-only foreign DEB architecture', () => { + for (const foreignArch of ['armv7l', 'arm64']) { + const configured = configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch, + }); + + assert.deepEqual(configuredTargets(configured), [ + { target: 'deb', arch: [foreignArch] }, + ]); + } +}); + +test('CLI writes an exact marker-only foreign DEB architecture', (t) => { + const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-build-config-') + ); + t.after(() => + fs.rmSync(temporaryDirectory, { recursive: true, force: true }) + ); + const configPath = path.join(temporaryDirectory, 'electron-builder.json'); + fs.writeFileSync( + configPath, + `${JSON.stringify(electronBuilderConfig, null, 4)}\n` + ); + + const result = spawnSync( + process.execPath, + [ + path.join( + workspaceRoot, + 'tools', + 'packaging', + 'configure-linux-frame-copy-build.mjs' + ), + '--config', + configPath, + '--foreign-deb', + '--foreign-arch', + 'arm64', + ], + { encoding: 'utf8' } + ); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual( + configuredTargets(JSON.parse(fs.readFileSync(configPath, 'utf8'))), + [{ target: 'deb', arch: ['arm64'] }] + ); + + const missingValue = spawnSync( + process.execPath, + [ + path.join( + workspaceRoot, + 'tools', + 'packaging', + 'configure-linux-frame-copy-build.mjs' + ), + '--config', + configPath, + '--foreign-deb', + '--foreign-arch', + ], + { encoding: 'utf8' } + ); + assert.notEqual(missingValue.status, 0); + assert.match(missingValue.stderr, /--foreign-arch requires a value/); +}); + +test('does not mutate the shared electron-builder configuration', () => { + const before = JSON.stringify(electronBuilderConfig); + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + }); + assert.equal(JSON.stringify(electronBuilderConfig), before); +}); + +test('rejects an unknown profile and conflicting foreign/profile modes', () => { + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'standard', + }), + /Unsupported Linux frame-copy profile/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'system', + foreignDeb: true, + }), + /must not select a frame-copy profile/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignArch: 'arm64', + }), + /foreign architecture requires --foreign-deb/ + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch: 'x64', + }), + /Unsupported marker-only foreign DEB architecture/ + ); + for (const foreignArch of ['', 64]) { + assert.throws( + () => + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + foreignArch, + }), + /Unsupported marker-only foreign DEB architecture/ + ); + } +}); + +test('rejects duplicate profile targets even when target count looks complete', () => { + const malformed = structuredClone(electronBuilderConfig); + malformed.linux.target = malformed.linux.target.map((target) => + target.target === 'Snap' ? { ...target, target: 'AppImage' } : target + ); + assert.throws( + () => + configureLinuxFrameCopyBuild(malformed, { + profileName: 'portable', + }), + /duplicate target "appimage".*missing.*snap/is + ); +}); + +test('preserves unrelated fpm dependencies and normalizes only frame-copy dependencies', () => { + const customized = structuredClone(electronBuilderConfig); + customized.deb = { + fpm: [ + '--depends=unrelated-runtime', + '--depends=mesa', + '--depends=libmpv2 >= 2', + '--depends=libgl1', + ], + }; + const system = configureLinuxFrameCopyBuild(customized, { + profileName: 'system', + }); + assert.deepEqual(system.deb.fpm, [ + '--depends=unrelated-runtime', + '--depends=mesa', + '--depends=libmpv2', + '--depends=libegl1', + '--depends=libgl1', + '--depends=libgbm1', + ]); + + const foreign = configureLinuxFrameCopyBuild(customized, { + foreignDeb: true, + }); + assert.deepEqual(foreign.deb.fpm, [ + '--depends=unrelated-runtime', + '--depends=mesa', + ]); +}); + +test('Linux CI builds one cached source runtime and packages three isolated profiles', () => { + assert.match(buildWorkflow, /^ {4}linux-embedded-mpv-runtime:$/m); + for (const profile of ['system', 'portable', 'flatpak']) { + assert.match( + buildWorkflow, + new RegExp(`linux_profile: ${profile}(?:\\s|$)`) + ); + } + assert.doesNotMatch(buildWorkflow, /linux_profile: standard/); + assert.match( + buildWorkflow, + /configure-linux-frame-copy-build\.mjs --profile/ + ); + assert.match( + buildWorkflow, + /configure-linux-frame-copy-build\.mjs[\s\S]*--foreign-deb/ + ); + assert.match( + buildWorkflow, + /apt-cache policy[\s\S]*meson=1\.7\.2[\s\S]*toolchain-sha256/ + ); + assert.match( + buildWorkflow, + /key: \$\{\{ steps\.linux-runtime-cache-key\.outputs\.key \}\}/ + ); + const cacheStep = workflowStep( + 'Restore pinned Linux runtime and immutable source inputs' + ); + assert.match(cacheStep, /dist\/linux-frame-copy-runtime-source-inputs/); + assert.doesNotMatch(cacheStep, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.doesNotMatch(cacheStep, /THIRD_PARTY_NOTICES/); + + const complianceStep = workflowStep( + 'Generate Linux runtime notices and assemble source compliance' + ); + assert.doesNotMatch(complianceStep, /^\s+if:/m); + assert.match( + complianceStep, + /generate-linux-runtime-notices\.cjs generate/ + ); + assert.match(complianceStep, /git rev-parse HEAD/); + assert.match(complianceStep, /git diff --binary HEAD/); + assert.match( + 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/); + assert.match( + complianceStep, + /SOURCE_INPUT_ROOT.*license-inputs[\s\S]*SOURCE_BUNDLE_ROOT.*license-inputs/ + ); + assert.match( + complianceStep, + /new Set\(expectedArchiveHashes\)\.size[\s\S]*archives\.length !== expectedArchiveHashes\.length/ + ); + assert.match(complianceStep, /prepare-linux-runtime-source-snapshot\.cjs/); + assert.doesNotMatch( + complianceStep, + /cp -a "\$\{SOURCE_INPUT_ROOT\}\/git\/\."/ + ); + assert.ok( + complianceStep.indexOf('prepare-linux-runtime-source-snapshot.cjs') < + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) + ); + assert.match( + complianceStep, + /libplacebo-source-record\.json[\s\S]*sourceGitCommit[\s\S]*sourceSubmodules/ + ); + assert.match( + complianceStep, + /prepare-linux-runtime-source-snapshot\.cjs assert-vcs-free/ + ); + assert.ok( + complianceStep.indexOf( + 'prepare-linux-runtime-source-snapshot.cjs assert-vcs-free' + ) < + complianceStep.indexOf( + '--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz' + ) + ); + assert.match( + 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/); + assert.match( + buildStep, + /linux-frame-copy-runtime-source-inputs[\s\S]*archives[\s\S]*git\/libplacebo/ + ); + assert.ok( + buildStep.indexOf('git clean -ffdqx') < + buildStep.indexOf( + 'cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo"' + ) + ); + assert.doesNotMatch(buildStep, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match( + buildWorkflow, + /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ + ); + const makeStep = workflowStep('Make Electron app'); + assert.match( + makeStep, + /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/ + ); + 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', () => { + const crossPlatformJob = buildWorkflowConfig.jobs?.['build-cross-platform']; + const linuxJob = buildWorkflowConfig.jobs?.['build-linux']; + + assert.ok(crossPlatformJob); + assert.ok(linuxJob); + assert.equal(crossPlatformJob.needs, undefined); + assert.deepEqual( + crossPlatformJob.strategy.matrix.include.map(({ os, arch }) => ({ + os, + arch, + })), + [ + { os: 'macos', arch: 'x64' }, + { os: 'macos', arch: 'arm64' }, + { os: 'windows', arch: 'x64' }, + ] + ); + assert.equal(crossPlatformJob.strategy['fail-fast'], false); + assert.equal(linuxJob.needs, 'linux-embedded-mpv-runtime'); + assert.deepEqual( + linuxJob.strategy.matrix.include.map( + ({ os, arch, linux_profile: profile }) => ({ + os, + arch, + profile, + }) + ), + [ + { os: 'linux', arch: 'x64', profile: 'system' }, + { os: 'linux', arch: 'x64', profile: 'portable' }, + { os: 'linux', arch: 'x64', profile: 'flatpak' }, + ] + ); + assert.equal( + linuxJob.name, + 'Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }})' + ); + assert.equal(linuxJob.strategy['fail-fast'], false); + assert.deepEqual(linuxJob.steps, crossPlatformJob.steps); + assert.deepEqual(buildWorkflowConfig.jobs['create-release'].needs, [ + 'build-cross-platform', + 'build-linux', + ]); + assert.match(buildWorkflow, /steps:\s+&electron-build-steps/); + assert.match(buildWorkflow, /steps:\s+\*electron-build-steps/); +}); + +test('Linux runtime toolchain installs fontconfig generators without network wraps', () => { + const cacheKeyStep = workflowStep( + 'Resolve Linux runtime toolchain cache key' + ); + const installStep = workflowStep( + 'Install pinned Linux runtime build dependencies' + ); + + assert.match(cacheKeyStep, /\bapt-cache policy[\s\S]*\bgperf\b/); + assert.match(installStep, /^\s+gperf\s+\\$/m); +}); + +test('Linux CI verifies every package family and exercises intended environments', () => { + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + for (const suffix of [ + 'AppImage', + 'deb', + 'rpm', + 'pacman', + 'snap', + 'flatpak', + ]) { + assert.match( + buildWorkflow, + new RegExp(`\\.${suffix.replace('.', '\\.')}(?:['"*:/\\s]|$)`) + ); + } + assert.ok( + buildWorkflow.match(/verify-linux-frame-copy-runtime\.mjs/g).length >= 6 + ); + assert.match(buildWorkflow, /ubuntu:24\.04/); + assert.match(buildWorkflow, /fedora:latest/); + assert.match(buildWorkflow, /archlinux:latest/); + assert.match( + buildWorkflow, + /snap run iptvnator --embedded-mpv-runtime-probe/ + ); + assert.doesNotMatch(buildWorkflow, /snap run --shell iptvnator/); + assert.match( + buildWorkflow, + /flatpak run --command=sh com\.fourgray\.iptvnator/ + ); + assert.match( + flatpakVerificationStep, + /flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ + ); + assert.doesNotMatch( + flatpakVerificationStep, + /HELPER_PATH=.*iptvnator_mpv_helper/ + ); + assert.doesNotMatch(buildWorkflow, /\bldd\b/); +}); + +test('Flatpak application runtime probe runs under an isolated D-Bus session', () => { + const installStep = workflowStep('Install Linux system dependencies'); + const flatpakVerificationStep = workflowStep( + 'Verify Flatpak payload, launcher, and sandboxed runtime' + ); + + assert.match( + flatpakVerificationStep, + /xvfb-run -a dbus-run-session -- flatpak run\s+\\\s+--env=LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+com\.fourgray\.iptvnator\s+\\\s+--embedded-mpv-runtime-probe/ + ); + assert.match(installStep, /^\s+dbus-daemon\s+\\$/m); + assert.match( + flatpakVerificationStep, + /xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1\s+\\\s+flatpak run --command=sh com\.fourgray\.iptvnator/ + ); +}); + +test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { + const foreignDebStep = workflowStep( + 'Make marker-only foreign-architecture DEB packages' + ); + + assert.match(foreignDebStep, /for foreign_arch in armv7l arm64; do/); + assert.match( + foreignDebStep, + /--foreign-deb\s+\\\s+--foreign-arch "\$\{foreign_arch\}"/ + ); + assert.match(foreignDebStep, /--arch="\$\{foreign_arch\}"/); + assert.match( + foreignDebStep, + /for foreign_arch[\s\S]*cp "\$\{RUNNER_TEMP\}\/electron-builder\.base\.json" electron-builder\.json[\s\S]*configure-linux-frame-copy-build\.mjs/ + ); + assert.match(foreignDebStep, /rm -rf dist\/executables-linux-foreign/); + assert.match( + foreignDebStep, + /mapfile -t foreign_debs < <\(\s*find dist\/executables-linux-foreign[\s\S]*-name '\*\.deb' -print\s*\)/ + ); + assert.match(foreignDebStep, /test "\$\{#foreign_debs\[@\]\}" -eq 1/); + assert.match(foreignDebStep, /armv7l\) expected_deb_arch=armhf/); + assert.match(foreignDebStep, /arm64\) expected_deb_arch=arm64/); + assert.match( + foreignDebStep, + /actual_deb_arch="\$\(dpkg-deb --field "\$\{foreign_debs\[0\]\}" Architecture\)"[\s\S]*test "\$\{actual_deb_arch\}" = "\$\{expected_deb_arch\}"/ + ); + assert.match( + foreignDebStep, + /mv "\$\{foreign_debs\[0\]\}" dist\/executables\// + ); + assert.match(foreignDebStep, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ''/); + assert.match(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1'/); + assert.doesNotMatch(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0'/); +}); + +test('system package smoke environments install every direct helper runtime dependency', () => { + const debStep = workflowStep('Verify DEB payloads and x64 system runtime'); + for (const dependency of ['libmpv2', 'libegl1', 'libgl1', 'libgbm1']) { + assert.match(debStep, new RegExp(`\\b${dependency}\\b`)); + } + + const rpmStep = workflowStep('Verify RPM payload and x64 system runtime'); + for (const dependency of [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ]) { + assert.match(rpmStep, new RegExp(`\\b${dependency}\\b`)); + } + + const pacmanStep = workflowStep( + 'Verify Pacman payload and x64 system runtime' + ); + for (const dependency of ['mpv', 'libglvnd', 'mesa']) { + assert.match(pacmanStep, new RegExp(`\\b${dependency}\\b`)); + } +}); + +test('Snap verifier preserves fail-closed status while exposing captured diagnostics', () => { + const snapStep = workflowStep( + 'Verify Snap payloads and strict-confinement runtime' + ); + + assert.match(snapStep, /set -euo pipefail/); + assert.match(snapStep, /2>&1 \| tee \/dev\/stderr/); + assert.doesNotMatch( + snapStep, + /^\s+printf '%s\\n' "\$\{verification\}"\s*$/m + ); + assert.ok( + snapStep.indexOf('verification="$(') < + snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") + ); + assert.ok( + snapStep.indexOf("grep -Fq 'Verified snap x64 Linux'") < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.match( + snapStep, + /snap list mesa-core22 >\/dev\/null 2>&1 \|\| sudo snap install mesa-core22/ + ); + assert.match( + snapStep, + /snap list gnome-3-28-1804 >\/dev\/null 2>&1 \|\| sudo snap install gnome-3-28-1804/ + ); + assert.ok( + snapStep.indexOf('sudo snap install mesa-core22') < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.ok( + snapStep.indexOf('sudo snap install gnome-3-28-1804') < + snapStep.indexOf('sudo snap install --dangerous') + ); + assert.ok( + snapStep.indexOf('sudo snap install --dangerous') < + snapStep.indexOf('installed_x64=true') + ); + assert.match( + snapStep, + /sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22/ + ); + assert.match( + snapStep, + /sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:graphics-core22" && \$3 == "mesa-core22:graphics-core22"/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:gnome-3-28-1804" && \$3 == "gnome-3-28-1804:gnome-3-28-1804"/ + ); + assert.match( + snapStep, + /\$1 == "shared-memory" && \$2 == "iptvnator:shared-memory" && \$3 == ":shared-memory"/ + ); + assert.match( + snapStep, + /sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22/ + ); + assert.match( + snapStep, + /\$2 == "iptvnator:graphics-core22" && \$3 == "-" \{ found=1 \} END \{ exit !found \}/ + ); + assert.match(snapStep, /disconnected_probe="\$\(/); + assert.match(snapStep, /disconnected_status=\$\?/); + assert.match(snapStep, /test "\$\{disconnected_status\}" -eq 1/); + assert.ok( + snapStep.includes( + `grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}'` + ) + ); + const firstGraphicsConnect = snapStep.indexOf( + 'sudo snap connect iptvnator:graphics-core22' + ); + const graphicsDisconnect = snapStep.indexOf( + 'sudo snap disconnect iptvnator:graphics-core22' + ); + const secondGraphicsConnect = snapStep.indexOf( + 'sudo snap connect iptvnator:graphics-core22', + firstGraphicsConnect + 1 + ); + assert.ok(firstGraphicsConnect < graphicsDisconnect); + assert.ok(graphicsDisconnect < snapStep.indexOf('disconnected_probe="$(')); + assert.ok( + snapStep.indexOf('test "${disconnected_status}" -eq 1') < + secondGraphicsConnect + ); + const firstRuntimeProbe = snapStep.indexOf( + 'snap run iptvnator --embedded-mpv-runtime-probe' + ); + const successfulRuntimeProbe = snapStep.lastIndexOf( + 'snap run iptvnator --embedded-mpv-runtime-probe' + ); + const graphicsConnectionAssertion = snapStep.indexOf( + '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22"' + ); + const gnomeConnectionAssertion = snapStep.indexOf( + '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804"' + ); + const sharedMemoryConnectionAssertion = snapStep.indexOf( + '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory"' + ); + assert.ok(firstRuntimeProbe < secondGraphicsConnect); + assert.ok(firstRuntimeProbe < successfulRuntimeProbe); + assert.ok(secondGraphicsConnect < successfulRuntimeProbe); + assert.ok(secondGraphicsConnect < graphicsConnectionAssertion); + assert.ok(graphicsConnectionAssertion < gnomeConnectionAssertion); + assert.ok(gnomeConnectionAssertion < sharedMemoryConnectionAssertion); + assert.ok(sharedMemoryConnectionAssertion < successfulRuntimeProbe); + assert.match(snapStep, /snap run iptvnator --embedded-mpv-runtime-probe/); + for (const traceSelector of [ + 'IPTVNATOR_TRACE_PLAYER=1', + 'EGL_LOG_LEVEL=debug', + 'LIBGL_DEBUG=verbose', + ]) { + assert.match( + snapStep.slice(secondGraphicsConnect), + new RegExp(traceSelector) + ); + } + for (const hostileOverride of [ + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'XDG_CONFIG_HOME', + 'XDG_CONFIG_DIRS', + 'XDG_DATA_HOME', + 'XDG_DATA_DIRS', + ]) { + assert.match( + snapStep.slice(secondGraphicsConnect), + new RegExp(`${hostileOverride}=/tmp/hostile`) + ); + } + assert.doesNotMatch(snapStep, /snap run --shell/); + assert.doesNotMatch(snapStep, /iptvnator_mpv_helper/); + assert.doesNotMatch(snapStep, /LD_LIBRARY_PATH/); +}); + +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); + assert.doesNotMatch(linuxDependencies, /\blibopengl-dev\b/); + assert.match(linuxDependencies, /^\s+xauth\s*\\?$/m); + assert.match(linuxDependencies, /^\s+xvfb\s*\\?$/m); + const packagedSmoke = workflowStep( + 'Run packaged x64 frame-copy and fallback smoke' + ); + assert.match( + packagedSmoke, + /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ + ); + assert.match( + packagedSmoke, + /IPTVNATOR_E2E_PACKAGED_EXECUTABLE: \$\{\{ github\.workspace \}\}\/dist\/executables\/linux-unpacked\/iptvnator/ + ); + assert.match( + packagedSmoke, + /electron-backend-e2e:packaged-frame-copy-smoke/ + ); + const hardwareDiagnostic = workflowStep( + 'Diagnose packaged x64 frame-copy hardware path' + ); + assert.match(hardwareDiagnostic, /continue-on-error: true/); + assert.match(hardwareDiagnostic, /\/dev\/dri\/renderD128/); + assert.match( + hardwareDiagnostic, + /IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1'/ + ); + assert.doesNotMatch(hardwareDiagnostic, /LIBGL_ALWAYS_SOFTWARE/); +}); + +test('draft release consumes split Linux artifacts and source compliance', () => { + for (const artifactName of [ + 'linux-system-artifacts', + 'linux-portable-artifacts', + 'linux-flatpak-artifacts', + 'linux-frame-copy-runtime-sources', + ]) { + assert.match(buildWorkflow, new RegExp(artifactName)); + } + const systemUpload = workflowStep('Upload system-runtime Linux artifacts'); + for (const artifactGlob of [ + 'dist/executables/*.deb', + 'dist/executables/*.rpm', + 'dist/executables/*.pacman', + 'dist/executables/*.pkg.tar.*', + ]) { + assert.ok(systemUpload.includes(artifactGlob)); + } + const portableUpload = workflowStep( + 'Upload portable-runtime Linux artifacts' + ); + for (const artifactGlob of [ + 'dist/executables/*.AppImage', + 'dist/executables/*.snap', + 'dist/executables/**/latest-linux*.yml', + 'dist/executables/**/*.blockmap', + ]) { + assert.ok(portableUpload.includes(artifactGlob)); + } + assert.ok( + workflowStep('Upload Flatpak-runtime Linux artifacts').includes( + 'dist/executables/**/*.flatpak' + ) + ); + const release = workflowStep('Create Draft Release'); + for (const releasePath of [ + 'artifacts/linux-system-artifacts/*.deb', + 'artifacts/linux-system-artifacts/*.rpm', + 'artifacts/linux-system-artifacts/*.pacman', + 'artifacts/linux-system-artifacts/*.pkg.tar.*', + 'artifacts/linux-portable-artifacts/*.AppImage', + 'artifacts/linux-portable-artifacts/*.snap', + 'artifacts/linux-flatpak-artifacts/*.flatpak', + 'artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz', + ]) { + assert.ok(release.includes(releasePath)); + } + assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); +}); diff --git a/tools/packaging/electron-after-pack.cjs b/tools/packaging/electron-after-pack.cjs index e86f23667..ee6f9d640 100644 --- a/tools/packaging/electron-after-pack.cjs +++ b/tools/packaging/electron-after-pack.cjs @@ -4,6 +4,10 @@ const { resolveElectronBuilderArchName, validatePackagedEmbeddedMpv, } = require('./embedded-mpv-packaging.cjs'); +const { + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const fs = require('fs'); const path = require('path'); const { @@ -22,7 +26,12 @@ function isTruthy(value) { ); } -function copyEmbeddedMpvNativeOutput(resourceDir, projectDir, platform) { +function copyEmbeddedMpvNativeOutput( + resourceDir, + projectDir, + platform, + preparationOptions +) { const sourceDir = path.join( projectDir, 'dist', @@ -45,7 +54,24 @@ function copyEmbeddedMpvNativeOutput(resourceDir, projectDir, platform) { fs.rmSync(destinationDir, { recursive: true, force: true }); fs.cpSync(sourceDir, destinationDir, { recursive: true }); - preparePackagedFrameCopyArtifacts(destinationDir, platform); + const resolvedPreparationOptions = + platform === 'linux' && preparationOptions + ? { + ...preparationOptions, + noticeSourceDir: path.join( + projectDir, + 'vendor', + 'embedded-mpv', + 'linux-x64', + 'notices' + ), + } + : preparationOptions; + return preparePackagedFrameCopyArtifacts( + destinationDir, + platform, + resolvedPreparationOptions + ); } function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) { @@ -64,12 +90,127 @@ function writeEmbeddedMpvUnavailableMarker(resourceDir, targetArch) { ); } -async function afterPackHook(params) { - await linuxAfterPack(params); +function resolveLinuxFrameCopyPackagingContext( + params, + { + required = isTruthy(process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV), + environment = process.env, + } = {} +) { + if (params.electronPlatformName !== 'linux') { + return null; + } + const targetArch = resolveElectronBuilderArchName(params.arch); + if (!targetArch) { + throw new Error( + `Unknown Electron Builder architecture: ${String(params.arch)}.` + ); + } + const targetNames = []; + for (const target of params.targets ?? []) { + const targetName = String(target?.name ?? '') + .trim() + .toLowerCase(); + if (!targetName) { + throw new Error( + 'Linux Electron Builder targets must expose a non-empty name.' + ); + } + if (targetNames.includes(targetName)) { + throw new Error( + `Linux Electron Builder target "${targetName}" is duplicated.` + ); + } + targetNames.push(targetName); + } + if (targetNames.length === 0) { + throw new Error( + 'Linux Electron Builder must provide at least one packaging target.' + ); + } + + // Official Linux frame-copy artifacts are intentionally x64-only. Do not + // let a caller-provided build-arch environment value promote an ARM + // package to a supported layout: every non-x64 target must remain the + // marker-only native-view fallback. + const foreignArch = targetArch !== 'x64'; + const profileValue = + environment.IPTVNATOR_LINUX_FRAME_COPY_PROFILE?.trim() ?? ''; + if (!profileValue) { + if (required && targetArch === 'x64') { + resolveLinuxFrameCopyProfile(profileValue); + } + return { + targetArch, + foreignArch, + targetNames, + profile: null, + }; + } + + const profile = resolveLinuxFrameCopyProfile(profileValue); + const targetErrors = validateLinuxProfileTargets(profile.name, targetNames); + if (targetErrors.length > 0) { + throw new Error(targetErrors.join('\n')); + } + return { + targetArch, + foreignArch, + targetNames, + profile, + }; +} + +function ensureSnapGraphicsContentMount(appOutDir, targetNames) { + if (!targetNames.includes('snap')) { + return null; + } + + const mountPath = path.join(appOutDir, 'graphics'); + if (!fs.existsSync(mountPath)) { + fs.mkdirSync(mountPath, { mode: 0o755 }); + } + const stat = fs.lstatSync(mountPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error( + `Snap graphics content mount must be a real empty directory: ${mountPath}` + ); + } + if (fs.readdirSync(mountPath).length > 0) { + throw new Error( + `Snap graphics content mount directory must be empty: ${mountPath}` + ); + } + fs.chmodSync(mountPath, 0o755); + return mountPath; +} + +async function afterPackHook(params) { const requireEmbeddedMpv = isTruthy( process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ); + const linuxPackagingContext = resolveLinuxFrameCopyPackagingContext( + params, + { + required: requireEmbeddedMpv, + environment: process.env, + } + ); + + await linuxAfterPack(params); + if (linuxPackagingContext) { + const graphicsMountPath = ensureSnapGraphicsContentMount( + params.appOutDir, + linuxPackagingContext.targetNames + ); + if (graphicsMountPath) { + log( + `prepared empty Snap graphics content mount at ${graphicsMountPath}` + ); + } + } + log( requireEmbeddedMpv ? `validating required embedded MPV ${params.electronPlatformName} runtime` @@ -77,12 +218,11 @@ async function afterPackHook(params) { ); const resourceDir = getResourceDir(params); - const foreignArch = isForeignLinuxEmbeddedMpvArch( - params.electronPlatformName, - params.arch - ); + const foreignArch = + linuxPackagingContext?.foreignArch ?? + isForeignLinuxEmbeddedMpvArch(params.electronPlatformName, params.arch); if (foreignArch) { - const targetArch = resolveElectronBuilderArchName(params.arch); + const targetArch = linuxPackagingContext.targetArch; log( `embedded MPV addon is not built for ${targetArch}; packaging an unavailable marker instead` ); @@ -91,7 +231,13 @@ async function afterPackHook(params) { copyEmbeddedMpvNativeOutput( resourceDir, params.packager.projectDir ?? process.cwd(), - params.electronPlatformName + params.electronPlatformName, + linuxPackagingContext + ? { + profile: linuxPackagingContext.profile?.name, + targetNames: linuxPackagingContext.targetNames, + } + : undefined ); } @@ -99,6 +245,10 @@ async function afterPackHook(params) { platform: params.electronPlatformName, required: requireEmbeddedMpv, foreignArch, + targetArch: linuxPackagingContext?.targetArch, + profile: linuxPackagingContext?.profile?.name, + targetNames: linuxPackagingContext?.targetNames, + executableName: params.packager.executableName, }); if (errors.length > 0) { @@ -136,3 +286,8 @@ function getResourceDir(params) { } module.exports = afterPackHook; +module.exports.ensureSnapGraphicsContentMount = ensureSnapGraphicsContentMount; +module.exports.resolveLinuxFrameCopyPackagingContext = + resolveLinuxFrameCopyPackagingContext; +module.exports.writeEmbeddedMpvUnavailableMarker = + writeEmbeddedMpvUnavailableMarker; diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index a1b2a7a13..64d934b8d 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -196,7 +196,7 @@ test('GitHub Releases auto-update metadata is generated and uploaded', () => { ); assert.match( buildAndMakeWorkflow, - /artifacts\/linux-artifacts\/latest-linux\*\.yml/ + /artifacts\/linux-portable-artifacts\/latest-linux\*\.yml/ ); assert.match( buildAndMakeWorkflow, @@ -298,6 +298,16 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( packageLayoutVerifier, /builderEffectiveConfigPath && fileExists\(builderEffectiveConfigPath\)/ ); + assert.match(packageLayoutVerifier, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE/); + assert.match(packageLayoutVerifier, /profile:\s*linuxFrameCopyProfile/); + assert.match(packageLayoutVerifier, /targetNames:\s*linuxTargetNames/); + assert.match( + packageLayoutVerifier, + /validateLinuxProfileTargets\(\s*linuxFrameCopyProfile,\s*linuxTargetNames\s*\)/s + ); + assert.match(packageLayoutVerifier, /dirArch !== 'x64'/); + assert.doesNotMatch(packageLayoutVerifier, /getEmbeddedMpvAddonArch/); + assert.match(electronAfterPackSource, /targetArch !== 'x64'/); }); test('nx-electron packaging does not copy duplicate root package metadata', () => { @@ -330,6 +340,19 @@ test('embedded MPV runtime binaries are unpacked on every supported desktop plat } }); +test('embedded MPV native payload is owned exclusively by afterPack outside app.asar', () => { + assert.ok( + electronBuilderConfig.files.includes( + '!electron-backend/native{,/**/*}' + ), + 'electron-builder files must exclude the entire pre-afterPack native payload from app.asar' + ); + assert.match( + packageLayoutVerifier, + /collectEmbeddedMpvNativeArchiveEntries/ + ); +}); + test('embedded MPV package validation accepts Windows runtime files and Linux process isolation', () => { const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'iptvnator-mpv-package-')); @@ -458,13 +481,24 @@ test('embedded MPV package validation accepts Windows runtime files and Linux pr fs.writeFileSync(join(linuxNativeDir, 'embedded_mpv.node'), ''); fs.writeFileSync( join(linuxNativeDir, 'embedded-mpv-runtime.json'), - JSON.stringify({ origin: 'external-mpv-process' }) + JSON.stringify({ + schemaVersion: 1, + origin: 'external-mpv-process', + platform: 'linux', + arch: 'x64', + runtimeMode: 'native-view-only', + frameCopyAvailable: false, + artifacts: { + addon: 'embedded_mpv.node', + }, + nativeViewFallback: 'process-isolated mpv --wid', + }) ); assert.deepEqual( validatePackagedEmbeddedMpv(linuxResourceDir, { platform: 'linux', - required: true, + required: false, }), [] ); @@ -711,6 +745,16 @@ test('Windows CI packages embedded MPV from a staged x64 runtime', () => { const requireEmbeddedMpvLines = buildAndMakeWorkflow .split(/\r?\n/) .filter((line) => line.includes('IPTVNATOR_REQUIRE_EMBEDDED_MPV:')); + const defaultRuntimeUrls = [ + ...buildAndMakeWorkflow.matchAll( + /IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL:\s+(\S+)/g + ), + ].map((match) => match[1]); + const defaultRuntimeSha256s = [ + ...buildAndMakeWorkflow.matchAll( + /IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256:\s+([a-f0-9]{64})/g + ), + ].map((match) => match[1]); assert.equal( packageMetadata.scripts?.['embedded-mpv:stage-runtime:windows-archive'], @@ -733,6 +777,16 @@ test('Windows CI packages embedded MPV from a staged x64 runtime', () => { buildAndMakeWorkflow, /IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https:\/\/github\.com\/zhongfly\/mpv-winbuild\/releases\/download\// ); + assert.deepEqual( + [...new Set(defaultRuntimeUrls)], + [ + 'https://github.com/zhongfly/mpv-winbuild/releases/download/2026-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z', + ] + ); + assert.deepEqual( + [...new Set(defaultRuntimeSha256s)], + ['6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0'] + ); assert.match(buildAndMakeWorkflow, /refs\/tags\/v\*/); assert.match( buildAndMakeWorkflow, diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index 863f7a3af..ca37b0d02 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -1,19 +1,1316 @@ import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import { createRequire } from 'node:module'; import os from 'node:os'; -import { join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; import test from 'node:test'; const require = createRequire(import.meta.url); +const { + DEFAULT_SYSTEM_PKG_CONFIG_DIRS, + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, + MINIMUM_TOOL_VERSIONS, + REQUIRED_TOOLS, + SOURCE_PACKAGES, + createLinuxRuntimeManifest, +} = require('../embedded-mpv/build-linux-runtime.cjs'); const { isForeignLinuxEmbeddedMpvArch, linuxUnpackedDirArch, + resolveConfiguredLinuxTargetNames, resolveElectronBuilderArchName, validatePackagedEmbeddedMpv, } = require('./embedded-mpv-packaging.cjs'); +const { + preparePackagedFrameCopyArtifacts, +} = require('./embedded-mpv-frame-copy-files.cjs'); +const { + LICENSE_PATHS_BY_PACKAGE, + collectLinuxRuntimeLicenseInputs, + generateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); +const { + ensureSnapGraphicsContentMount, + resolveLinuxFrameCopyPackagingContext, +} = require('./electron-after-pack.cjs'); const X64_ADDON_ENV = { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' }; +const SYSTEM_PACKAGE_DEPENDENCIES = { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], +}; +const FRAME_COPY_ARTIFACTS = { + addon: 'embedded_mpv.node', + frameReader: 'embedded_mpv_frame_reader.node', + helper: 'iptvnator_mpv_helper', +}; + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +function sourcePackageRecord(sourcePackage) { + return { + version: sourcePackage.version, + sourceUrl: sourcePackage.sourceUrl, + ...(sourcePackage.sourceTag + ? { sourceTag: sourcePackage.sourceTag } + : {}), + ...(sourcePackage.sourceKind === 'archive' + ? { sourceSha256: sourcePackage.expectedSha256 } + : { + sourceGitCommit: sourcePackage.expectedGitCommit, + sourceSubmodules: [...sourcePackage.expectedSubmodules], + }), + license: sourcePackage.license, + }; +} + +function createSourceRuntime(runtimeContents) { + const runtimeFiles = Object.entries(runtimeContents) + .map(([name, contents]) => ({ + name, + size: Buffer.byteLength(contents), + sha256: sha256(contents), + })) + .sort((left, right) => left.name.localeCompare(right.name)); + const runtimeNames = new Set(runtimeFiles.map(({ name }) => name)); + const entries = runtimeFiles.map(({ name }) => { + const needed = name.startsWith('libmpv.so') + ? [ + ...(runtimeNames.has('libavcodec.so.61') + ? ['libavcodec.so.61'] + : []), + 'libEGL.so.1', + 'libc.so.6', + ] + : ['libm.so.6']; + return { + name, + soname: name === 'libmpv.so' ? 'libmpv.so.2' : null, + needed: needed.sort(), + rpath: [], + runpath: ['$ORIGIN'], + }; + }); + const externalDependencies = [ + ...new Set( + entries + .flatMap(({ needed }) => needed) + .filter((name) => !runtimeNames.has(name)) + ), + ].sort(); + const sourceRecords = Object.fromEntries( + SOURCE_PACKAGES.map((sourcePackage) => [ + sourcePackage.id, + { + ...sourcePackage, + ...sourcePackageRecord(sourcePackage), + }, + ]) + ); + + return createLinuxRuntimeManifest({ + sourceRecords, + runtimeFiles, + abiRecords: runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + dependencyClosure: { + entries, + externalDependencies, + }, + buildHost: { + platform: 'linux', + arch: 'x64', + release: 'fixture-kernel', + glibcVersion: '2.35', + systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], + systemPkgConfigPackages: Object.fromEntries( + EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((name) => [ + name, + `${name}-fixture`, + ]) + ), + tools: Object.fromEntries( + REQUIRED_TOOLS.map((name) => [ + name, + `${name} ${MINIMUM_TOOL_VERSIONS[name]}`, + ]) + ), + }, + generatedAt: '2026-07-17T00:00:00.000Z', + }); +} + +function createBuildManifest(runtimeContents) { + const sourceRuntime = createSourceRuntime(runtimeContents); + return { + schemaVersion: 1, + origin: 'linux-frame-copy-build', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + buildInputMode: 'bundled-runtime', + sourceRuntimeValidated: true, + allowedPackageRuntimeModes: ['system', 'bundled'], + packageRuntimeAvailability: { + system: true, + bundled: true, + }, + artifacts: { ...FRAME_COPY_ARTIFACTS }, + processIsolation: { + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + 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, + }; +} + +function createNoticeFixture(fixtureRoot, sourceRuntime) { + const sourceRoot = join(fixtureRoot, 'upstream-license-sources'); + for (const sourcePackage of SOURCE_PACKAGES) { + for (const relativePath of LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]) { + const sourcePath = join(sourceRoot, sourcePackage.id, relativePath); + fs.mkdirSync(dirname(sourcePath), { recursive: true }); + fs.writeFileSync( + sourcePath, + `verbatim ${sourcePackage.id} ${relativePath}\n` + ); + } + } + const licenseInputRoot = join(fixtureRoot, 'license-inputs'); + const noticeSourceDir = join(fixtureRoot, 'generated-notices'); + collectLinuxRuntimeLicenseInputs({ + sourceRoot, + outputRoot: licenseInputRoot, + runtimeManifest: sourceRuntime, + }); + generateLinuxRuntimeNotices({ + licenseInputRoot, + outputRoot: noticeSourceDir, + runtimeManifest: sourceRuntime, + }); + return noticeSourceDir; +} + +function createNativeFixture({ + runtimeContents = { + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }, + buildManifest = createBuildManifest(runtimeContents), +} = {}) { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-embedded-mpv-layout-') + ); + const appOutDir = join(fixtureRoot, 'linux-unpacked'); + const resourceDir = join(appOutDir, 'resources'); + const nativeDir = join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(join(nativeDir, 'lib'), { recursive: true }); + fs.writeFileSync(join(nativeDir, FRAME_COPY_ARTIFACTS.addon), 'addon'); + fs.writeFileSync( + join(nativeDir, FRAME_COPY_ARTIFACTS.frameReader), + 'reader' + ); + fs.writeFileSync(join(nativeDir, FRAME_COPY_ARTIFACTS.helper), 'helper', { + mode: 0o644, + }); + fs.writeFileSync( + join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(buildManifest, null, 2)}\n` + ); + for (const [name, contents] of Object.entries(runtimeContents)) { + fs.writeFileSync(join(nativeDir, 'lib', name), contents); + } + const noticeSourceDir = createNoticeFixture( + fixtureRoot, + buildManifest.sourceRuntime + ); + fs.cpSync(noticeSourceDir, nativeDir, { recursive: true }); + fs.writeFileSync(join(appOutDir, 'iptvnator.bin'), 'electron'); + return { + buildManifest, + fixtureRoot, + resourceDir, + appOutDir, + nativeDir, + noticeSourceDir, + }; +} + +function readManifest(nativeDir) { + return JSON.parse( + fs.readFileSync(join(nativeDir, 'embedded-mpv-runtime.json'), 'utf8') + ); +} + +function pureValidationOptions(options = {}) { + return { + platform: 'linux', + required: true, + foreignArch: false, + hostPlatform: 'darwin', + ...options, + }; +} + +function validElfInspector(nativeDir, manifest, overrides = {}) { + const libDir = join(nativeDir, 'lib'); + const records = new Map([ + ['iptvnator.bin', { needed: ['libc.so.6'], rpath: [], runpath: [] }], + [ + FRAME_COPY_ARTIFACTS.addon, + { needed: ['libX11.so.6'], rpath: [], runpath: [] }, + ], + [ + FRAME_COPY_ARTIFACTS.frameReader, + { needed: ['libc.so.6'], rpath: [], runpath: [] }, + ], + [ + FRAME_COPY_ARTIFACTS.helper, + { + needed: [manifest.libmpvSoname, 'libEGL.so.1', 'libc.so.6'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + ], + ]); + for (const entry of manifest.runtimeDependencyClosure?.entries ?? []) { + records.set(entry.name, { + soname: entry.soname ?? null, + needed: [...entry.needed], + rpath: [...entry.rpath], + runpath: [...entry.runpath], + }); + } + for (const [name, value] of Object.entries(overrides)) { + records.set(name, value); + } + + return (binaryPath) => { + const name = + dirname(binaryPath) === libDir + ? basename(binaryPath) + : basename(binaryPath); + const record = records.get(name); + if (!record) { + throw new Error(`Missing ELF fixture for ${binaryPath}`); + } + return record; + }; +} + +test('resolves the required Linux profile from afterPack targets before mutation', () => { + assert.deepEqual( + resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 1, + targets: [{ name: 'DEB' }, { name: 'rpm' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64', + }, + } + ), + { + targetArch: 'x64', + foreignArch: false, + targetNames: ['deb', 'rpm'], + profile: { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }, + } + ); +}); + +test('keeps every non-x64 Linux target marker-only even when the configured addon arch matches it', () => { + const context = resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 3, + targets: [{ name: 'deb' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }, + } + ); + + assert.equal(context.targetArch, 'arm64'); + assert.equal(context.foreignArch, true); +}); + +test('rejects missing, mixed, and unknown required Linux packaging context', () => { + const baseParams = { + electronPlatformName: 'linux', + arch: 1, + targets: [{ name: 'deb' }], + }; + + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext(baseParams, { + required: true, + environment: { IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64' }, + }), + /Linux frame-copy profile is required/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { + ...baseParams, + targets: [{ name: 'deb' }, { name: 'AppImage' }], + }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64', + }, + } + ), + /cannot build target "appimage"/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { ...baseParams, arch: 99 }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + }, + } + ), + /Unknown Electron Builder architecture/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { ...baseParams, arch: 'mips64' }, + { + required: true, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system', + }, + } + ), + /Unknown Electron Builder architecture/ + ); + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext(baseParams, { + required: true, + environment: { + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }, + }), + /Linux frame-copy profile is required/ + ); +}); + +test('validates a provided Linux profile even when embedded MPV is optional', () => { + assert.throws( + () => + resolveLinuxFrameCopyPackagingContext( + { + electronPlatformName: 'linux', + arch: 1, + targets: [{ name: 'snap' }, { name: 'deb' }], + }, + { + required: false, + environment: { + IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'portable', + IPTVNATOR_EMBEDDED_MPV_ARCH: 'x64', + }, + } + ), + /cannot build target "deb"/ + ); +}); + +test('creates an exact empty Snap graphics content mount directory', (t) => { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-snap-graphics-mount-') + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + + assert.equal( + ensureSnapGraphicsContentMount(fixtureRoot, ['appimage']), + null + ); + assert.equal(fs.existsSync(join(fixtureRoot, 'graphics')), false); + + const mountPath = ensureSnapGraphicsContentMount(fixtureRoot, [ + 'appimage', + 'snap', + ]); + assert.equal(mountPath, join(fixtureRoot, 'graphics')); + const mountStat = fs.lstatSync(mountPath); + assert.equal(mountStat.isDirectory(), true); + assert.equal(mountStat.isSymbolicLink(), false); + assert.equal(mountStat.mode & 0o777, 0o755); + assert.deepEqual(fs.readdirSync(mountPath), []); + + fs.chmodSync(mountPath, 0o700); + assert.equal( + ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + mountPath + ); + assert.equal(fs.lstatSync(mountPath).mode & 0o777, 0o755); +}); + +test('rejects a non-empty or redirected Snap graphics content mount', (t) => { + const fixtureRoot = fs.mkdtempSync( + join(os.tmpdir(), 'iptvnator-snap-graphics-invalid-') + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const mountPath = join(fixtureRoot, 'graphics'); + + fs.writeFileSync(mountPath, 'not a directory'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /real empty directory/ + ); + + fs.rmSync(mountPath); + fs.mkdirSync(mountPath); + fs.writeFileSync(join(mountPath, 'unexpected'), 'not empty'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /must be empty/ + ); + + fs.rmSync(mountPath, { recursive: true }); + const outsidePath = join(fixtureRoot, 'outside'); + fs.mkdirSync(outsidePath); + fs.symlinkSync(outsidePath, mountPath, 'dir'); + assert.throws( + () => ensureSnapGraphicsContentMount(fixtureRoot, ['snap']), + /real empty directory/ + ); +}); + +test('prepares a normalized system profile with no private runtime', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + fs.writeFileSync( + join(fixture.nativeDir, 'iptvnator_mpv_helper.exe'), + 'stale' + ); + fs.writeFileSync( + join(fixture.nativeDir, 'embedded-mpv-unavailable.txt'), + 'stale' + ); + + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'system', + targetNames: ['deb', 'rpm', 'pacman'], + } + ); + + assert.equal(manifest.profile, 'system'); + assert.equal(manifest.runtimeMode, 'system'); + assert.equal(manifest.origin, 'system-libmpv-frame-copy'); + assert.deepEqual(manifest.targets, ['deb', 'pacman', 'rpm']); + assert.deepEqual(manifest.packageDependencies, SYSTEM_PACKAGE_DEPENDENCIES); + 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 & + 0o777, + 0o755 + ); + assert.equal( + fs.statSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.frameReader)) + .mode & 0o777, + 0o644 + ); + assert.equal( + fs.existsSync(join(fixture.nativeDir, 'iptvnator_mpv_helper.exe')), + false + ); + assert.equal( + fs.existsSync(join(fixture.nativeDir, 'embedded-mpv-unavailable.txt')), + false + ); + for (const legalPath of [ + 'embedded-mpv-notices.json', + 'THIRD_PARTY_NOTICES.txt', + 'licenses', + ]) { + assert.equal(fs.existsSync(join(fixture.nativeDir, legalPath)), false); + } + assert.deepEqual( + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb', 'rpm', 'pacman'], + }) + ), + [] + ); +}); + +test('prepares portable and Flatpak manifests with the exact bundled closure', (t) => { + const portable = createNativeFixture(); + const flatpak = createNativeFixture(); + t.after(() => { + for (const fixture of [portable, flatpak]) { + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); + } + }); + for (const legalPath of [ + 'embedded-mpv-notices.json', + 'THIRD_PARTY_NOTICES.txt', + 'licenses', + ]) { + fs.rmSync(join(portable.nativeDir, legalPath), { + recursive: true, + force: true, + }); + } + + const portableManifest = preparePackagedFrameCopyArtifacts( + portable.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['AppImage', 'SNAP'], + noticeSourceDir: portable.noticeSourceDir, + } + ); + const flatpakManifest = preparePackagedFrameCopyArtifacts( + flatpak.nativeDir, + 'linux', + { + profile: 'flatpak', + targetNames: ['flatpak'], + } + ); + + assert.equal(portableManifest.profile, 'portable'); + assert.equal(portableManifest.runtimeMode, 'bundled'); + assert.equal(portableManifest.origin, 'bundled-lgpl-frame-copy'); + assert.deepEqual(portableManifest.targets, ['appimage', 'snap']); + assert.deepEqual(portableManifest.packageDependencies, {}); + assert.deepEqual( + portableManifest.runtimeFiles.map(({ name }) => name).sort(), + ['libavcodec.so.61', 'libmpv.so', 'libmpv.so.2'] + ); + assert.equal( + portableManifest.runtimeTotalBytes, + portableManifest.runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ) + ); + assert.equal( + portableManifest.sourceRuntime.origin, + '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' + ) + ); + const notices = JSON.parse( + fs.readFileSync( + join(portable.nativeDir, 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + assert.equal(notices.schemaVersion, 1); + assert.equal(notices.origin, 'pinned-linux-runtime-upstream-licenses'); + assert.equal(notices.noticeFile.path, 'THIRD_PARTY_NOTICES.txt'); + assert.deepEqual( + notices.packages.map(({ id }) => id), + SOURCE_PACKAGES.map(({ id }) => id).sort() + ); + assert.ok(notices.packages.every(({ files }) => files.length >= 1)); + 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, + pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage', 'snap'], + }) + ), + [] + ); + assert.deepEqual( + validatePackagedEmbeddedMpv( + flatpak.resourceDir, + pureValidationOptions({ + profile: 'flatpak', + targetNames: ['flatpak'], + }) + ), + [] + ); +}); + +test('rejects missing, tampered, undeclared, and symlinked bundled legal files', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ); + const options = pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }); + const notices = JSON.parse( + fs.readFileSync( + join(fixture.nativeDir, 'embedded-mpv-notices.json'), + 'utf8' + ) + ); + const licensePath = join( + fixture.nativeDir, + notices.packages[0].files[0].path + ); + const originalContents = fs.readFileSync(licensePath); + + fs.appendFileSync(licensePath, 'tampered\n'); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /(?:Size|SHA-256) mismatch.*packaged license/i + ); + + fs.writeFileSync(licensePath, originalContents); + fs.rmSync(licensePath); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /Missing .*packaged license/i + ); + + fs.writeFileSync(licensePath, originalContents); + const undeclaredPath = join(fixture.nativeDir, 'licenses', 'stale.txt'); + fs.writeFileSync(undeclaredPath, 'stale\n'); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /undeclared packaged legal file.*stale\.txt/i + ); + + fs.rmSync(undeclaredPath); + fs.rmSync(licensePath); + fs.symlinkSync( + join(fixture.nativeDir, 'THIRD_PARTY_NOTICES.txt'), + licensePath + ); + assert.match( + validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'), + /packaged license.*symbolic link/i + ); +}); + +test('rejects invalid build provenance and incomplete bundled runtime input', (t) => { + const extra = createNativeFixture(); + const missing = createNativeFixture(); + const invalidSource = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + invalidSource.sourceRuntime.ffmpeg.configureFlags.push('--enable-gpl'); + const invalid = createNativeFixture({ buildManifest: invalidSource }); + const unexpectedModeManifest = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + unexpectedModeManifest.allowedPackageRuntimeModes.push('development'); + const unexpectedMode = createNativeFixture({ + buildManifest: unexpectedModeManifest, + }); + const wrongSonameManifest = createBuildManifest({ + 'libavcodec.so.61': 'libavcodec-runtime', + 'libmpv.so': 'libmpv-runtime', + 'libmpv.so.2': 'libmpv-runtime', + }); + wrongSonameManifest.libmpvSoname = 'libmpv.so.9'; + 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, + missing, + invalid, + unexpectedMode, + wrongSoname, + wrongSourceArchive, + ]) { + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }); + } + }); + fs.writeFileSync(join(extra.nativeDir, 'lib', 'libstale.so.1'), 'stale'); + fs.rmSync(join(missing.nativeDir, 'lib', 'libavcodec.so.61')); + + assert.throws( + () => + preparePackagedFrameCopyArtifacts(extra.nativeDir, 'linux', { + profile: 'portable', + targetNames: ['appimage'], + }), + /undeclared bundled runtime file.*libstale\.so\.1/i + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts(missing.nativeDir, 'linux', { + profile: 'flatpak', + targetNames: ['flatpak'], + }), + /Missing bundled runtime file.*libavcodec\.so\.61/ + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts(invalid.nativeDir, 'linux', { + profile: 'portable', + targetNames: ['snap'], + }), + /--enable-gpl/ + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts( + unexpectedMode.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ), + /allowedPackageRuntimeModes.*exactly/i + ); + assert.throws( + () => + preparePackagedFrameCopyArtifacts(wrongSoname.nativeDir, 'linux', { + profile: 'portable', + targetNames: ['appimage'], + }), + /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) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + + assert.throws( + () => + preparePackagedFrameCopyArtifacts(fixture.nativeDir, 'linux', { + profile: 'portable', + targetNames: [], + }), + /at least one target/ + ); +}); + +test( + 'rejects symlinked frame-copy artifacts before chmod or packaging', + { skip: process.platform === 'win32' }, + (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const readerPath = join( + fixture.nativeDir, + FRAME_COPY_ARTIFACTS.frameReader + ); + fs.rmSync(readerPath); + fs.symlinkSync(FRAME_COPY_ARTIFACTS.addon, readerPath); + + assert.throws( + () => + preparePackagedFrameCopyArtifacts(fixture.nativeDir, 'linux', { + profile: 'system', + targetNames: ['deb'], + }), + /frame reader.*regular file/i + ); + assert.equal(fs.lstatSync(readerPath).isSymbolicLink(), true); + } +); + +test('keeps optional unprofiled Linux packages explicitly native-view-only', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux' + ); + + assert.equal(manifest.origin, 'external-mpv-process'); + assert.equal(manifest.runtimeMode, 'native-view-only'); + assert.equal(manifest.frameCopyAvailable, false); + assert.equal( + fs.existsSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.addon)), + true + ); + assert.equal( + fs.existsSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper)), + false + ); + assert.equal( + fs.existsSync( + join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.frameReader) + ), + false + ); + assert.equal(fs.existsSync(join(fixture.nativeDir, 'lib')), false); + assert.notEqual( + readManifest(fixture.nativeDir).origin, + 'linux-frame-copy-build' + ); +}); + +test('rejects incorrect artifact modes and profile mismatches deterministically', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + preparePackagedFrameCopyArtifacts(fixture.nativeDir, 'linux', { + profile: 'system', + targetNames: ['deb'], + }); + fs.chmodSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper), 0o644); + + const modeErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ); + assert.match(modeErrors.join('\n'), /mode 0755/); + + fs.chmodSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper), 0o4755); + assert.match( + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ).join('\n'), + /mode 0755/ + ); + + fs.chmodSync(join(fixture.nativeDir, FRAME_COPY_ARTIFACTS.helper), 0o755); + const profileErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'portable', + targetNames: ['appimage'], + }) + ); + assert.match(profileErrors.join('\n'), /profile.*portable.*system/i); +}); + +test('turns malformed packaged manifest JSON into a deterministic error', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + fs.writeFileSync( + join(fixture.nativeDir, 'embedded-mpv-runtime.json'), + '{not-json' + ); + + assert.doesNotThrow(() => + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ) + ); + assert.match( + validatePackagedEmbeddedMpv( + fixture.resourceDir, + pureValidationOptions({ + profile: 'system', + targetNames: ['deb'], + }) + ).join('\n'), + /Invalid JSON in embedded MPV runtime manifest/ + ); +}); + +test('validates Linux ELF process isolation, helper linkage, and bundled closure', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['appimage'], + } + ); + const options = { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'portable', + targetNames: ['appimage'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }; + + assert.deepEqual( + validatePackagedEmbeddedMpv(fixture.resourceDir, options), + [] + ); + + for (const artifactName of [ + FRAME_COPY_ARTIFACTS.addon, + FRAME_COPY_ARTIFACTS.frameReader, + ]) { + const isolationErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [artifactName]: { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + isolationErrors.join('\n'), + /must not link libmpv/, + `${artifactName} must remain process-isolated from libmpv` + ); + } + + const pathBearingAddonLinkErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.addon]: { + needed: ['/tmp/build/libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + pathBearingAddonLinkErrors.join('\n'), + /must not link libmpv.*\/tmp\/build\/libmpv\.so\.2/ + ); + + const helperLinkErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: ['libmpv.so.1'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + }); + assert.match( + helperLinkErrors.join('\n'), + /must directly need libmpv\.so\.2/ + ); + + const unexpectedHelperLinkErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: [ + manifest.libmpvSoname, + 'libEGL.so.1', + 'libc.so.6', + 'libsurprise.so.1', + ], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + } + ); + assert.match( + unexpectedHelperLinkErrors.join('\n'), + /helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/ + ); + + const closureErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libavcodec.so.61': { + needed: ['libsurprise.so.1'], + rpath: [], + runpath: ['/tmp/runtime-prefix'], + }, + }), + }); + assert.match(closureErrors.join('\n'), /RUNPATH must be exactly \$ORIGIN/); + assert.match( + closureErrors.join('\n'), + /not bundled or allowlisted.*libsurprise\.so\.1/ + ); + + fs.writeFileSync( + join(fixture.appOutDir, 'libelectron-extra.so'), + 'electron library' + ); + const electronLibraryErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libelectron-extra.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + electronLibraryErrors.join('\n'), + /Linux Electron library must not link libmpv.*libelectron-extra\.so/ + ); +}); + +test('recursively validates pristine Electron libraries before target packaging', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['snap'], + } + ); + const nestedElectronLibrary = join( + fixture.appOutDir, + 'future-electron-runtime', + 'libfuture-electron.so' + ); + fs.mkdirSync(dirname(nestedElectronLibrary), { recursive: true }); + fs.writeFileSync(nestedElectronLibrary, 'future electron library'); + + const validationOptions = { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'portable', + targetNames: ['snap'], + hostPlatform: 'linux', + }; + const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...validationOptions, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libfuture-electron.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + }); + + assert.match( + errors.join('\n'), + /Linux Electron library must not link libmpv.*libfuture-electron\.so/ + ); + + fs.rmSync(dirname(nestedElectronLibrary), { + recursive: true, + force: true, + }); + for (const packageLibraryDir of [ + join(fixture.appOutDir, 'lib', 'x86_64-linux-gnu'), + join(fixture.appOutDir, 'usr', 'lib', 'x86_64-linux-gnu'), + ]) { + const targetPath = join(packageLibraryDir, 'libpackage.so.1.0'); + fs.mkdirSync(packageLibraryDir, { recursive: true }); + fs.writeFileSync(targetPath, 'Snap template library'); + fs.symlinkSync( + 'libpackage.so.1.0', + join(packageLibraryDir, 'libpackage.so.1') + ); + } + + assert.deepEqual( + validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...validationOptions, + artifactFormat: 'snap', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }), + [] + ); + + fs.mkdirSync(dirname(nestedElectronLibrary), { recursive: true }); + fs.writeFileSync(nestedElectronLibrary, 'future electron library'); + const snapIsolationErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...validationOptions, + artifactFormat: 'snap', + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libfuture-electron.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + snapIsolationErrors.join('\n'), + /Linux Electron library must not link libmpv.*libfuture-electron\.so/ + ); +}); + +test('rejects undeclared helper dependencies in system-runtime packages', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'system', + targetNames: ['deb'], + } + ); + + const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'system', + targetNames: ['deb'], + hostPlatform: 'linux', + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [FRAME_COPY_ARTIFACTS.helper]: { + needed: [ + manifest.libmpvSoname, + 'libEGL.so.1', + 'libc.so.6', + 'libsurprise.so.1', + ], + rpath: [], + runpath: ['$ORIGIN/lib'], + }, + }), + }); + + assert.match( + errors.join('\n'), + /helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/ + ); +}); test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => { assert.equal(resolveElectronBuilderArchName(0), 'ia32'); @@ -24,8 +1321,11 @@ test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => assert.equal(resolveElectronBuilderArchName(99), null); }); -test('flags Linux packages whose arch differs from the built addon', () => { - assert.equal(isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV), true); +test('flags every non-x64 Linux package regardless of configured build arch', () => { + assert.equal( + isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV), + true + ); assert.equal( isForeignLinuxEmbeddedMpvArch('linux', 'armv7l', X64_ADDON_ENV), true @@ -34,6 +1334,12 @@ test('flags Linux packages whose arch differs from the built addon', () => { isForeignLinuxEmbeddedMpvArch('linux', 'x64', X64_ADDON_ENV), false ); + assert.equal( + isForeignLinuxEmbeddedMpvArch('linux', 'arm64', { + IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64', + }), + true + ); // Only Linux fans out foreign arches from one dist tree. assert.equal( isForeignLinuxEmbeddedMpvArch('darwin', 'arm64', X64_ADDON_ENV), @@ -53,6 +1359,33 @@ test('derives package arch from electron-builder Linux output directory names', assert.equal(linuxUnpackedDirArch('win-unpacked'), null); }); +test('derives the exact selected Linux targets for each configured architecture', () => { + const configuredTargets = [ + { + target: 'AppImage', + arch: ['x64', 'arm64'], + }, + { + target: 'deb', + arch: ['x64'], + }, + { + target: 'Snap', + arch: ['arm64'], + }, + 'rpm', + ]; + + assert.deepEqual( + resolveConfiguredLinuxTargetNames(configuredTargets, 'x64'), + ['appimage', 'deb', 'rpm'] + ); + assert.deepEqual( + resolveConfiguredLinuxTargetNames(configuredTargets, 'arm64'), + ['appimage', 'rpm', 'snap'] + ); +}); + function createResourceDir(files) { const resourceDir = fs.mkdtempSync( join(os.tmpdir(), 'iptvnator-embedded-mpv-arch-') diff --git a/tools/packaging/embedded-mpv-frame-copy-files.cjs b/tools/packaging/embedded-mpv-frame-copy-files.cjs index 51c83b8c2..eedffeccc 100644 --- a/tools/packaging/embedded-mpv-frame-copy-files.cjs +++ b/tools/packaging/embedded-mpv-frame-copy-files.cjs @@ -1,35 +1,563 @@ -const fs = require('fs'); -const path = require('path'); +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +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, + validateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const FRAME_COPY_HELPER = 'iptvnator_mpv_helper'; const WINDOWS_FRAME_COPY_HELPER = 'iptvnator_mpv_helper.exe'; const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node'; +const EMBEDDED_MPV_ADDON = 'embedded_mpv.node'; +const RUNTIME_MANIFEST = 'embedded-mpv-runtime.json'; +const UNAVAILABLE_MARKER = 'embedded-mpv-unavailable.txt'; +const LICENSES_DIRECTORY = 'licenses'; +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const EXPECTED_ARTIFACTS = Object.freeze({ + addon: EMBEDDED_MPV_ADDON, + frameReader: FRAME_COPY_READER, + helper: FRAME_COPY_HELPER, +}); +const EXPECTED_PROCESS_ISOLATION = Object.freeze({ + addonLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: Object.freeze(['$ORIGIN/lib']), +}); -function preparePackagedFrameCopyArtifacts(nativeDir, platform) { - if (platform === 'linux') { - // Until Linux ships a bundled libmpv runtime, do not package a helper - // linked against the build host's system library. Remove both names - // so a stale cross-platform build artifact cannot leak into a package. - for (const fileName of [ - FRAME_COPY_HELPER, - WINDOWS_FRAME_COPY_HELPER, - ]) { - fs.rmSync(path.join(nativeDir, fileName), { force: true }); +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function readJsonFile(filePath, label) { + let contents; + try { + contents = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + throw new Error( + `Unable to read ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + try { + return JSON.parse(contents); + } catch (error) { + throw new Error( + `Invalid JSON in ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function assertRegularReadableFile(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a regular file: ${filePath}`); + } + try { + fs.accessSync(filePath, fs.constants.R_OK); + } catch { + throw new Error(`${label} must be readable: ${filePath}`); + } + return stat; +} + +function sameJson(left, right) { + return isDeepStrictEqual(left, right); +} + +function normalizeTargetNames(targetNames) { + if (!Array.isArray(targetNames) || targetNames.length === 0) { + throw new Error( + 'Linux frame-copy packaging requires at least one target.' + ); + } + const normalized = []; + for (const targetName of targetNames) { + const name = String(targetName ?? '') + .trim() + .toLowerCase(); + if (!name) { + throw new Error( + 'Linux frame-copy target names must be non-empty strings.' + ); } - return; + if (normalized.includes(name)) { + throw new Error(`Linux frame-copy target "${name}" is duplicated.`); + } + normalized.push(name); + } + return normalized.sort(); +} + +function validateLinuxFrameCopyBuildManifest(manifest) { + const errors = []; + if (!isObject(manifest)) { + return ['Linux frame-copy build manifest must be an object.']; + } + for (const [field, expected] of [ + ['schemaVersion', 1], + ['origin', 'linux-frame-copy-build'], + ['platform', 'linux'], + ['arch', 'x64'], + ['buildInputMode', 'bundled-runtime'], + ['sourceRuntimeValidated', true], + ]) { + if (manifest[field] !== expected) { + errors.push( + `Linux frame-copy build manifest ${field} must equal ${JSON.stringify( + expected + )}.` + ); + } + } + if (!sameJson(manifest.allowedPackageRuntimeModes, ['system', 'bundled'])) { + errors.push( + 'Linux frame-copy build manifest allowedPackageRuntimeModes must contain exactly system and bundled.' + ); + } + if ( + !sameJson(manifest.packageRuntimeAvailability, { + system: true, + bundled: true, + }) + ) { + errors.push( + 'Linux frame-copy build manifest packageRuntimeAvailability must mark exactly system and bundled as available.' + ); + } + if (!sameJson(manifest.artifacts, EXPECTED_ARTIFACTS)) { + errors.push( + 'Linux frame-copy build manifest artifacts must name the addon, frame reader, and helper exactly.' + ); + } + if (!sameJson(manifest.processIsolation, EXPECTED_PROCESS_ISOLATION)) { + errors.push( + 'Linux frame-copy build manifest processIsolation contract is invalid.' + ); + } + if (manifest.nativeViewFallback !== 'process-isolated mpv --wid') { + errors.push( + 'Linux frame-copy build manifest nativeViewFallback contract is invalid.' + ); + } + if ( + typeof manifest.generatedAt !== 'string' || + Number.isNaN(Date.parse(manifest.generatedAt)) + ) { + errors.push( + 'Linux frame-copy build manifest generatedAt must be a valid timestamp.' + ); + } + if ( + typeof manifest.libmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(manifest.libmpvSoname) + ) { + errors.push( + 'Linux frame-copy build manifest libmpvSoname must be a versioned libmpv SONAME.' + ); + } + const sourceLinkerAlias = + manifest.sourceRuntime?.runtimeDependencyClosure?.entries?.find( + (entry) => entry?.name === 'libmpv.so' + ); + if ( + typeof manifest.libmpvSoname === 'string' && + sourceLinkerAlias?.soname !== manifest.libmpvSoname + ) { + errors.push( + 'Linux frame-copy build manifest libmpvSoname must be derived from the validated source runtime libmpv.so SONAME.' + ); + } + const sourceErrors = validateLinuxRuntimeManifest(manifest.sourceRuntime); + errors.push( + ...sourceErrors.map( + (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) && + !sameJson(manifest.runtimeFiles, manifest.sourceRuntime.runtimeFiles) + ) { + errors.push( + 'Linux frame-copy build manifest runtimeFiles must exactly match sourceRuntime.runtimeFiles.' + ); + } + const expectedTotal = Array.isArray(manifest.runtimeFiles) + ? manifest.runtimeFiles.reduce( + (total, runtimeFile) => + total + + (isObject(runtimeFile) && + Number.isInteger(runtimeFile.size) && + runtimeFile.size > 0 + ? runtimeFile.size + : 0), + 0 + ) + : 0; + if (manifest.runtimeTotalBytes !== expectedTotal) { + errors.push( + `Linux frame-copy build manifest runtimeTotalBytes must equal ${expectedTotal}.` + ); + } + if ( + Array.isArray(manifest.runtimeFiles) && + typeof manifest.libmpvSoname === 'string' && + !manifest.runtimeFiles.some( + (runtimeFile) => runtimeFile.name === manifest.libmpvSoname + ) + ) { + errors.push( + 'Linux frame-copy build manifest runtimeFiles must include libmpvSoname.' + ); + } + return errors; +} + +function verifyBundledRuntimeFiles(nativeDir, manifest) { + const libDir = path.join(nativeDir, 'lib'); + let libDirStat; + try { + libDirStat = fs.lstatSync(libDir); + } catch { + throw new Error(`Missing bundled runtime directory: ${libDir}`); + } + if (!libDirStat.isDirectory() || libDirStat.isSymbolicLink()) { + throw new Error( + `Bundled runtime directory must be a regular directory: ${libDir}` + ); + } + + const declaredFiles = new Map( + manifest.runtimeFiles.map((runtimeFile) => [ + runtimeFile.name, + runtimeFile, + ]) + ); + for (const entry of fs.readdirSync(libDir, { withFileTypes: true })) { + if (!declaredFiles.has(entry.name)) { + throw new Error( + `Found undeclared bundled runtime file ${entry.name} in ${libDir}.` + ); + } + } + + for (const runtimeFile of manifest.runtimeFiles) { + const runtimePath = path.join(libDir, runtimeFile.name); + let stat; + try { + stat = fs.lstatSync(runtimePath); + } catch { + throw new Error(`Missing bundled runtime file: ${runtimePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Bundled runtime file must be a materialized regular file: ${runtimePath}` + ); + } + try { + fs.accessSync(runtimePath, fs.constants.R_OK); + } catch { + throw new Error( + `Bundled runtime file must be readable: ${runtimePath}` + ); + } + const contents = fs.readFileSync(runtimePath); + if (contents.length !== runtimeFile.size) { + throw new Error( + `Bundled runtime file size mismatch for ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.length}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `Bundled runtime file SHA-256 mismatch for ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } +} + +function writeManifest(nativeDir, manifest) { + const manifestPath = path.join(nativeDir, RUNTIME_MANIFEST); + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, { + mode: 0o644, + }); + return manifest; +} + +function removeLinuxRuntimeNotices(nativeDir) { + for (const relativePath of [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + LICENSES_DIRECTORY, + 'notices', + ]) { + fs.rmSync(path.join(nativeDir, relativePath), { + recursive: true, + force: true, + }); + } +} + +function prepareBundledLinuxRuntimeNotices( + nativeDir, + sourceRuntime, + noticeSourceDir +) { + if (noticeSourceDir) { + const sourceErrors = validateLinuxRuntimeNotices( + noticeSourceDir, + sourceRuntime + ); + if (sourceErrors.length > 0) { + throw new Error( + [ + `Invalid Linux runtime notice source at ${noticeSourceDir}.`, + ...sourceErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + removeLinuxRuntimeNotices(nativeDir); + for (const relativePath of [ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + LICENSES_DIRECTORY, + ]) { + fs.cpSync( + path.join(noticeSourceDir, relativePath), + path.join(nativeDir, relativePath), + { + recursive: true, + force: true, + } + ); + } + } + + const packagedErrors = validateLinuxRuntimeNotices( + nativeDir, + sourceRuntime, + { allowUnrelatedFiles: true } + ); + if (packagedErrors.length > 0) { + throw new Error( + [ + `Invalid packaged Linux runtime notices at ${nativeDir}.`, + ...packagedErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } +} + +function createPackagedManifest(buildManifest, profile, targetNames) { + const bundled = profile.runtimeMode === 'bundled'; + const runtimeFiles = bundled + ? buildManifest.runtimeFiles.map((runtimeFile) => ({ ...runtimeFile })) + : []; + return { + schemaVersion: 1, + origin: profile.manifestOrigin, + generatedAt: buildManifest.generatedAt, + platform: 'linux', + arch: 'x64', + profile: profile.name, + runtimeMode: profile.runtimeMode, + targets: [...new Set(targetNames)].sort(), + artifacts: { + addon: { + name: EMBEDDED_MPV_ADDON, + regularFile: true, + readable: true, + }, + frameReader: { + name: FRAME_COPY_READER, + regularFile: true, + readable: true, + }, + helper: { + name: FRAME_COPY_HELPER, + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: buildManifest.nativeViewFallback, + libmpvSoname: buildManifest.libmpvSoname, + packageDependencies: + profile.runtimeMode === 'system' + ? { ...LINUX_SYSTEM_PACKAGE_DEPENDENCIES } + : {}, + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + ...(bundled + ? { + runtimeDependencyClosure: { + entries: + buildManifest.sourceRuntime.runtimeDependencyClosure.entries.map( + (entry) => ({ + name: entry.name, + soname: entry.soname ?? null, + needed: [...entry.needed], + rpath: [...entry.rpath], + runpath: [...entry.runpath], + }) + ), + externalDependencies: [ + ...buildManifest.sourceRuntime + .runtimeDependencyClosure.externalDependencies, + ], + }, + externalSystemLibraries: + buildManifest.sourceRuntime.externalSystemLibraries.map( + (entry) => ({ ...entry }) + ), + sourceArchive: structuredClone(buildManifest.sourceArchive), + sourceRuntime: structuredClone(buildManifest.sourceRuntime), + } + : {}), + }; +} + +function prepareNativeViewOnlyLinuxArtifacts(nativeDir) { + for (const fileName of [ + FRAME_COPY_HELPER, + WINDOWS_FRAME_COPY_HELPER, + FRAME_COPY_READER, + UNAVAILABLE_MARKER, + ]) { + fs.rmSync(path.join(nativeDir, fileName), { force: true }); + } + fs.rmSync(path.join(nativeDir, 'lib'), { recursive: true, force: true }); + removeLinuxRuntimeNotices(nativeDir); + + return writeManifest(nativeDir, { + schemaVersion: 1, + origin: 'external-mpv-process', + platform: 'linux', + arch: 'x64', + runtimeMode: 'native-view-only', + frameCopyAvailable: false, + artifacts: { + addon: EMBEDDED_MPV_ADDON, + }, + nativeViewFallback: 'process-isolated mpv --wid', + }); +} + +function prepareLinuxFrameCopyArtifacts(nativeDir, options = {}) { + if (!options.profile) { + return prepareNativeViewOnlyLinuxArtifacts(nativeDir); + } + const profile = resolveLinuxFrameCopyProfile(options.profile); + const targetNames = normalizeTargetNames(options.targetNames); + const targetErrors = validateLinuxProfileTargets(profile.name, targetNames); + if (targetErrors.length > 0) { + throw new Error(targetErrors.join('\n')); + } + + const addonPath = path.join(nativeDir, EMBEDDED_MPV_ADDON); + const readerPath = path.join(nativeDir, FRAME_COPY_READER); + const helperPath = path.join(nativeDir, FRAME_COPY_HELPER); + const manifestPath = path.join(nativeDir, RUNTIME_MANIFEST); + assertRegularReadableFile(addonPath, 'embedded MPV addon'); + assertRegularReadableFile(readerPath, 'embedded MPV frame reader'); + assertRegularReadableFile(helperPath, 'embedded MPV frame-copy helper'); + assertRegularReadableFile(manifestPath, 'embedded MPV runtime manifest'); + + const buildManifest = readJsonFile( + manifestPath, + 'embedded MPV runtime manifest' + ); + const manifestErrors = validateLinuxFrameCopyBuildManifest(buildManifest); + if (manifestErrors.length > 0) { + throw new Error( + ['Invalid Linux frame-copy build manifest.', ...manifestErrors] + .map((error) => `- ${error}`) + .join('\n') + ); + } + verifyBundledRuntimeFiles(nativeDir, buildManifest); + + fs.chmodSync(helperPath, 0o755); + fs.chmodSync(readerPath, 0o644); + fs.rmSync(path.join(nativeDir, WINDOWS_FRAME_COPY_HELPER), { force: true }); + fs.rmSync(path.join(nativeDir, UNAVAILABLE_MARKER), { force: true }); + if (profile.runtimeMode === 'system') { + fs.rmSync(path.join(nativeDir, 'lib'), { + recursive: true, + force: true, + }); + removeLinuxRuntimeNotices(nativeDir); + } else { + prepareBundledLinuxRuntimeNotices( + nativeDir, + buildManifest.sourceRuntime, + options.noticeSourceDir + ); + } + + return writeManifest( + nativeDir, + createPackagedManifest(buildManifest, profile, targetNames) + ); +} + +function preparePackagedFrameCopyArtifacts(nativeDir, platform, options = {}) { + if (platform === 'linux') { + return prepareLinuxFrameCopyArtifacts(nativeDir, options); } const helperPath = path.join( nativeDir, - platform === 'win32' - ? WINDOWS_FRAME_COPY_HELPER - : FRAME_COPY_HELPER + platform === 'win32' ? WINDOWS_FRAME_COPY_HELPER : FRAME_COPY_HELPER ); if (platform !== 'win32' && fs.existsSync(helperPath)) { // Asset copying drops POSIX modes; restore spawn permission. fs.chmodSync(helperPath, 0o755); } + return undefined; } function removeStaleFrameCopyArtifacts(nativeDir) { @@ -40,9 +568,11 @@ function removeStaleFrameCopyArtifacts(nativeDir) { ]) { fs.rmSync(path.join(nativeDir, fileName), { force: true }); } + removeLinuxRuntimeNotices(nativeDir); } module.exports = { preparePackagedFrameCopyArtifacts, removeStaleFrameCopyArtifacts, + validateLinuxFrameCopyBuildManifest, }; diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index a17234156..02e2a0711 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -1,6 +1,30 @@ +const crypto = require('node:crypto'); const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { isDeepStrictEqual } = require('node:util'); +const { + EXTERNAL_SYSTEM_LIBRARIES, + GLIBC_TOOLCHAIN_ALLOWLIST, + parseReadelfDynamic, + validateRuntimeDependencyClosure, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +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, + validateLinuxRuntimeNotices, +} = require('../embedded-mpv/generate-linux-runtime-notices.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/']; const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/']; @@ -10,6 +34,39 @@ const windowsMpvRuntimeNames = [ 'mpv.dll', 'libmpv.dll', ]; +const linuxFrameCopyArtifacts = Object.freeze({ + addon: Object.freeze({ + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }), + frameReader: Object.freeze({ + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }), + helper: Object.freeze({ + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }), +}); +const linuxFrameCopyProcessIsolation = Object.freeze({ + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: Object.freeze(['$ORIGIN/lib']), +}); +const linuxNativeViewFallback = 'process-isolated mpv --wid'; +const versionedLinuxLibmpvPattern = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const anyLinuxLibmpvPattern = /^libmpv\.so(?:\.|$)/; +const linuxRuntimeLegalPaths = Object.freeze([ + NOTICE_MANIFEST, + THIRD_PARTY_NOTICES, + 'licenses', +]); function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -638,31 +695,80 @@ const ELECTRON_BUILDER_ARCH_NAMES = [ function resolveElectronBuilderArchName(arch) { if (typeof arch === 'string') { - return arch; + return ELECTRON_BUILDER_ARCH_NAMES.includes(arch) ? arch : null; } return ELECTRON_BUILDER_ARCH_NAMES[arch] ?? null; } -function getEmbeddedMpvAddonArch(env = process.env) { - return env.IPTVNATOR_EMBEDDED_MPV_ARCH || process.arch; +function resolveConfiguredLinuxTargetNames(configuredTargets, targetArch) { + if (!Array.isArray(configuredTargets)) { + throw new TypeError('Electron Builder linux.target must be an array.'); + } + const archName = resolveElectronBuilderArchName(targetArch); + if (!archName) { + throw new Error( + `Unknown Electron Builder architecture: ${String(targetArch)}.` + ); + } + + const targetNames = new Set(); + for (const target of configuredTargets) { + const targetName = + typeof target === 'string' + ? target + : target && typeof target === 'object' + ? target.target + : null; + if (typeof targetName !== 'string' || targetName.trim() === '') { + throw new Error( + 'Electron Builder Linux targets must have a non-empty target name.' + ); + } + + if (target && typeof target === 'object' && target.arch !== undefined) { + const configuredArches = Array.isArray(target.arch) + ? target.arch + : [target.arch]; + const configuredArchNames = configuredArches.map( + (configuredArch) => { + const configuredArchName = + resolveElectronBuilderArchName(configuredArch); + if (!configuredArchName) { + throw new Error( + `Unknown Electron Builder architecture: ${String( + configuredArch + )}.` + ); + } + return configuredArchName; + } + ); + if (!configuredArchNames.includes(archName)) { + continue; + } + } + targetNames.add(targetName.trim().toLowerCase()); + } + + if (targetNames.size === 0) { + throw new Error( + `Electron Builder has no Linux targets configured for ${archName}.` + ); + } + return [...targetNames].sort(); } /** - * The embedded MPV addon is compiled once per CI host (x64 on Linux), but - * electron-builder also produces arm64/armv7l Linux packages from the same - * dist output. Those packages must not ship a foreign-architecture - * `embedded_mpv.node` — it can never load and produces a cryptic error. + * Official Linux frame-copy support is x64-only. electron-builder also + * produces arm64/armv7l packages, which must always carry only the + * unavailable marker and native-view fallback. */ -function isForeignLinuxEmbeddedMpvArch( - platform, - targetArch, - env = process.env -) { +function isForeignLinuxEmbeddedMpvArch(platform, targetArch) { if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') { return false; } const archName = resolveElectronBuilderArchName(targetArch); - return Boolean(archName) && archName !== getEmbeddedMpvAddonArch(env); + return Boolean(archName) && archName !== 'x64'; } // Maps electron-builder Linux output directory names (`linux-unpacked`, @@ -676,8 +782,1003 @@ function linuxUnpackedDirArch(unpackedDirName) { return match[1] ?? 'x64'; } +function pathExistsByLstat(filePath) { + try { + fs.lstatSync(filePath); + return true; + } catch { + return false; + } +} + +function inspectRegularReadableFile( + filePath, + label, + errors, + expectedMode = null +) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + errors.push(`Missing ${label}: ${filePath}`); + return null; + } + + if (!stat.isFile() || stat.isSymbolicLink()) { + errors.push(`${label} must be a regular file: ${filePath}`); + return null; + } + try { + fs.accessSync(filePath, fs.constants.R_OK); + } catch { + errors.push(`${label} must be readable: ${filePath}`); + } + + if (expectedMode !== null && (stat.mode & 0o7777) !== expectedMode) { + errors.push( + `${label} must have mode ${expectedMode + .toString(8) + .padStart(4, '0')}: ${filePath}` + ); + } + return stat; +} + +function readPackagedJson(filePath, label, errors) { + let contents; + try { + contents = fs.readFileSync(filePath, 'utf8'); + } catch (error) { + errors.push( + `Unable to read ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } + + try { + return JSON.parse(contents); + } catch (error) { + errors.push( + `Invalid JSON in ${label} at ${filePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return null; + } +} + +function validateForeignLinuxNativeDir(nativeDir) { + const errors = []; + const markerName = 'embedded-mpv-unavailable.txt'; + const markerPath = path.join(nativeDir, markerName); + let nativeStat; + try { + nativeStat = fs.lstatSync(nativeDir); + } catch { + return [ + `Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}`, + ]; + } + if (!nativeStat.isDirectory() || nativeStat.isSymbolicLink()) { + return [ + `Foreign-architecture embedded MPV native path must be a regular directory: ${nativeDir}`, + ]; + } + + const entries = fs.readdirSync(nativeDir).sort(); + const unexpectedEntries = entries.filter((name) => name !== markerName); + if (unexpectedEntries.length > 0) { + errors.push( + [ + 'Embedded MPV artifacts must not ship in foreign-architecture Linux packages; only the unavailable marker is allowed.', + ...unexpectedEntries.map( + (name) => `- ${path.join(nativeDir, name)}` + ), + ].join('\n') + ); + } + if (!entries.includes(markerName)) { + errors.push( + `Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}` + ); + } else { + inspectRegularReadableFile( + markerPath, + 'embedded MPV unavailable marker', + errors + ); + } + return errors; +} + +function validateNativeViewOnlyLinuxPackage( + nativeDir, + addonPath, + manifestPath, + errors +) { + for (const legalPath of linuxRuntimeLegalPaths) { + const packagedLegalPath = path.join(nativeDir, legalPath); + if (pathExistsByLstat(packagedLegalPath)) { + errors.push( + `Linux native-view-only packages must not ship bundled runtime legal files: ${packagedLegalPath}` + ); + } + } + for (const artifactName of [ + 'iptvnator_mpv_helper', + 'iptvnator_mpv_helper.exe', + 'embedded_mpv_frame_reader.node', + ]) { + const artifactPath = path.join(nativeDir, artifactName); + if (pathExistsByLstat(artifactPath)) { + errors.push( + `Linux native-view-only packages must not ship frame-copy helpers or readers: ${artifactPath}` + ); + } + } + + const markerPath = path.join(nativeDir, 'embedded-mpv-unavailable.txt'); + if (pathExistsByLstat(markerPath)) { + errors.push( + `Same-architecture Linux packages must not retain the unavailable marker: ${markerPath}` + ); + } + + const libDir = path.join(nativeDir, 'lib'); + if (pathExistsByLstat(libDir)) { + errors.push( + `Linux native-view-only packages must not bundle libmpv or retain a private runtime directory: ${libDir}` + ); + } + + inspectRegularReadableFile(addonPath, 'embedded MPV native addon', errors); + if ( + !inspectRegularReadableFile( + manifestPath, + 'embedded MPV runtime manifest', + errors + ) + ) { + return; + } + const manifest = readPackagedJson( + manifestPath, + 'embedded MPV runtime manifest', + errors + ); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + if (manifest !== null) { + errors.push('Embedded MPV runtime manifest must be an object.'); + } + return; + } + + const expectedFields = { + schemaVersion: 1, + origin: 'external-mpv-process', + platform: 'linux', + arch: 'x64', + runtimeMode: 'native-view-only', + frameCopyAvailable: false, + artifacts: { addon: 'embedded_mpv.node' }, + nativeViewFallback: linuxNativeViewFallback, + }; + for (const [field, expected] of Object.entries(expectedFields)) { + if (!isDeepStrictEqual(manifest[field], expected)) { + errors.push( + `Linux native-view-only manifest ${field} must equal ${JSON.stringify( + expected + )}; received ${JSON.stringify(manifest[field])}.` + ); + } + } + if (Object.hasOwn(manifest, 'profile')) { + errors.push( + 'Linux native-view-only manifest must not include a frame-copy profile.' + ); + } +} + +function normalizeLinuxTargetNames(targetNames, errors) { + if (!Array.isArray(targetNames) || targetNames.length === 0) { + errors.push( + 'Linux frame-copy package validation requires at least one target name.' + ); + return []; + } + const normalized = []; + for (const targetName of targetNames) { + const name = String(targetName ?? '') + .trim() + .toLowerCase(); + if (!name) { + errors.push( + 'Linux frame-copy target names must be non-empty strings.' + ); + continue; + } + if (normalized.includes(name)) { + errors.push(`Linux frame-copy target "${name}" is duplicated.`); + continue; + } + normalized.push(name); + } + return normalized.sort(); +} + +function validatePackagedManifestContract( + manifest, + profile, + targetNames, + errors +) { + const expectedFields = { + schemaVersion: 1, + origin: profile.manifestOrigin, + platform: 'linux', + arch: 'x64', + profile: profile.name, + runtimeMode: profile.runtimeMode, + targets: targetNames, + artifacts: linuxFrameCopyArtifacts, + processIsolation: linuxFrameCopyProcessIsolation, + nativeViewFallback: linuxNativeViewFallback, + }; + for (const [field, expected] of Object.entries(expectedFields)) { + if (!isDeepStrictEqual(manifest[field], expected)) { + errors.push( + `Linux frame-copy manifest ${field} for profile "${profile.name}" must equal ${JSON.stringify( + expected + )}; received ${JSON.stringify(manifest[field])}.` + ); + } + } + + if ( + typeof manifest.generatedAt !== 'string' || + manifest.generatedAt.trim() === '' || + Number.isNaN(Date.parse(manifest.generatedAt)) + ) { + errors.push( + 'Linux frame-copy manifest generatedAt must be a valid timestamp.' + ); + } + if ( + typeof manifest.libmpvSoname !== 'string' || + !versionedLinuxLibmpvPattern.test(manifest.libmpvSoname) + ) { + errors.push( + 'Linux frame-copy manifest libmpvSoname must be a versioned libmpv SONAME.' + ); + } +} + +function validateSystemLinuxRuntime(nativeDir, manifest, errors) { + for (const legalPath of linuxRuntimeLegalPaths) { + const packagedLegalPath = path.join(nativeDir, legalPath); + if (pathExistsByLstat(packagedLegalPath)) { + errors.push( + `Linux system frame-copy packages must not ship bundled runtime legal files: ${packagedLegalPath}` + ); + } + } + if ( + !isDeepStrictEqual( + manifest.packageDependencies, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + ) + ) { + errors.push( + `Linux system frame-copy manifest packageDependencies must equal ${JSON.stringify( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )}.` + ); + } + if (!isDeepStrictEqual(manifest.runtimeFiles, [])) { + errors.push( + 'Linux system frame-copy manifest runtimeFiles must be empty.' + ); + } + if (manifest.runtimeTotalBytes !== 0) { + errors.push( + 'Linux system frame-copy manifest runtimeTotalBytes must equal 0.' + ); + } + for (const forbiddenField of [ + 'runtimeDependencyClosure', + 'externalSystemLibraries', + 'sourceArchive', + 'sourceRuntime', + ]) { + if (Object.hasOwn(manifest, forbiddenField)) { + errors.push( + `Linux system frame-copy manifest must not include ${forbiddenField}.` + ); + } + } + + const libDir = path.join(nativeDir, 'lib'); + if (pathExistsByLstat(libDir)) { + errors.push( + `Linux system frame-copy packages must not retain a private runtime directory: ${libDir}` + ); + } +} + +function sha256File(filePath) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex'); +} + +function validateBundledLinuxRuntime(nativeDir, manifest, errors) { + if (!isDeepStrictEqual(manifest.packageDependencies, {})) { + errors.push( + 'Linux bundled frame-copy manifest packageDependencies must be empty.' + ); + } + + const sourceRuntimeErrors = validateLinuxRuntimeManifest( + manifest.sourceRuntime + ); + errors.push( + ...sourceRuntimeErrors.map( + (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, + }).map((error) => `Invalid packaged Linux runtime notices: ${error}`) + ); + if ( + !isDeepStrictEqual( + manifest.runtimeFiles, + manifest.sourceRuntime?.runtimeFiles + ) + ) { + errors.push( + 'Linux bundled frame-copy manifest runtimeFiles must exactly match sourceRuntime.runtimeFiles.' + ); + } + if ( + !isDeepStrictEqual( + manifest.runtimeDependencyClosure, + manifest.sourceRuntime?.runtimeDependencyClosure + ) + ) { + errors.push( + 'Linux bundled frame-copy manifest runtimeDependencyClosure must exactly match sourceRuntime.runtimeDependencyClosure.' + ); + } + if ( + !isDeepStrictEqual( + manifest.externalSystemLibraries, + manifest.sourceRuntime?.externalSystemLibraries + ) + ) { + errors.push( + 'Linux bundled frame-copy manifest externalSystemLibraries must exactly match sourceRuntime.externalSystemLibraries.' + ); + } + + if (!Array.isArray(manifest.runtimeFiles)) { + errors.push( + 'Linux bundled frame-copy manifest runtimeFiles must be an array.' + ); + return; + } + const expectedTotal = manifest.runtimeFiles.reduce( + (total, runtimeFile) => + total + + (runtimeFile && + Number.isSafeInteger(runtimeFile.size) && + runtimeFile.size > 0 + ? runtimeFile.size + : 0), + 0 + ); + if (manifest.runtimeTotalBytes !== expectedTotal) { + errors.push( + `Linux bundled frame-copy manifest runtimeTotalBytes must equal ${expectedTotal}.` + ); + } + + const libDir = path.join(nativeDir, 'lib'); + let libStat; + try { + libStat = fs.lstatSync(libDir); + } catch { + errors.push(`Missing bundled Linux runtime directory: ${libDir}`); + return; + } + if (!libStat.isDirectory() || libStat.isSymbolicLink()) { + errors.push( + `Bundled Linux runtime path must be a regular directory: ${libDir}` + ); + return; + } + + const declaredNames = new Set(); + for (const runtimeFile of manifest.runtimeFiles) { + if ( + !runtimeFile || + typeof runtimeFile.name !== 'string' || + path.basename(runtimeFile.name) !== runtimeFile.name || + runtimeFile.name === '.' || + runtimeFile.name === '..' + ) { + errors.push( + `Bundled Linux runtime manifest contains an unsafe file name: ${JSON.stringify( + runtimeFile?.name + )}.` + ); + continue; + } + if (declaredNames.has(runtimeFile.name)) { + errors.push( + `Bundled Linux runtime manifest contains duplicate file ${runtimeFile.name}.` + ); + continue; + } + declaredNames.add(runtimeFile.name); + } + + let packagedEntries; + try { + packagedEntries = fs.readdirSync(libDir).sort(); + } catch (error) { + errors.push( + `Unable to enumerate bundled Linux runtime at ${libDir}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return; + } + for (const entryName of packagedEntries) { + if (!declaredNames.has(entryName)) { + errors.push( + `Found undeclared bundled Linux runtime artifact: ${path.join( + libDir, + entryName + )}` + ); + } + } + + for (const runtimeFile of manifest.runtimeFiles) { + if (!runtimeFile || !declaredNames.has(runtimeFile.name)) { + continue; + } + const runtimePath = path.join(libDir, runtimeFile.name); + const stat = inspectRegularReadableFile( + runtimePath, + `bundled Linux runtime file ${runtimeFile.name}`, + errors + ); + if (!stat) { + continue; + } + if (stat.size !== runtimeFile.size) { + errors.push( + `Bundled Linux runtime size mismatch for ${runtimeFile.name}: expected ${runtimeFile.size}, received ${stat.size}.` + ); + } + let actualSha256; + try { + actualSha256 = sha256File(runtimePath); + } catch (error) { + errors.push( + `Unable to hash bundled Linux runtime file ${runtimePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + if (actualSha256 !== runtimeFile.sha256) { + errors.push( + `Bundled Linux runtime SHA-256 mismatch for ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } +} + +function normalizeElfInspection(value, binaryPath) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error( + `ELF inspection for ${binaryPath} must return an object.` + ); + } + const result = { + soname: value.soname ?? null, + }; + if ( + result.soname !== null && + (typeof result.soname !== 'string' || + path.basename(result.soname) !== result.soname) + ) { + throw new Error( + `ELF inspection for ${binaryPath} must return a safe SONAME or null.` + ); + } + for (const field of ['needed', 'rpath', 'runpath']) { + if ( + !Array.isArray(value[field]) || + value[field].some((entry) => typeof entry !== 'string') + ) { + throw new Error( + `ELF inspection for ${binaryPath} must return string array ${field}.` + ); + } + result[field] = [...new Set(value[field])].sort(); + } + return result; +} + +function dependencyFileName(dependencyName) { + return dependencyName.replaceAll('\\', '/').split('/').at(-1) ?? ''; +} + +function listElectronShippedLinuxLibraries(resourceDir, options = {}) { + const appDir = path.dirname(resourceDir); + const normalizedResourceDir = path.resolve(resourceDir); + const excludedSnapLibraryRoots = + options.artifactFormat === 'snap' + ? new Set( + [ + path.join(appDir, 'lib'), + path.join(appDir, 'usr', 'lib'), + ].map((directoryPath) => path.resolve(directoryPath)) + ) + : new Set(); + const libraries = []; + + function visit(directoryPath) { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const entryPath = path.join(directoryPath, entry.name); + if (path.resolve(entryPath) === normalizedResourceDir) { + continue; + } + if (entry.isDirectory()) { + if (excludedSnapLibraryRoots.has(path.resolve(entryPath))) { + continue; + } + visit(entryPath); + continue; + } + if ( + (entry.isFile() || entry.isSymbolicLink()) && + /\.so(?:\.\d+)*$/.test(entry.name) + ) { + libraries.push(entryPath); + } + } + } + + // afterPack and unpacked-layout checks see the pristine Electron tree, so + // recurse to catch future nested Electron libraries. An extracted Snap + // overlays package-manager lib/ and usr/lib/ trees onto that same root; + // exclude exactly those target-provided roots while scanning everything + // else recursively. + visit(appDir); + return libraries.sort(); +} + +function inspectLinuxElfIsolation( + resourceDir, + nativeDir, + manifest, + options, + errors +) { + const hostPlatform = options.hostPlatform ?? process.platform; + let inspectElf = options.elfInspector; + if (!inspectElf) { + if (hostPlatform !== 'linux') { + return; + } + if (!commandExists('readelf')) { + errors.push( + 'readelf is required to validate Linux embedded MPV packaging.' + ); + return; + } + inspectElf = (binaryPath) => + parseReadelfDynamic(run('readelf', ['-d', binaryPath])); + } + if (typeof inspectElf !== 'function') { + errors.push('Linux ELF inspector must be a function.'); + return; + } + + const executableName = options.executableName ?? 'iptvnator'; + const inspectedPaths = { + electron: path.join(path.dirname(resourceDir), `${executableName}.bin`), + addon: path.join(nativeDir, linuxFrameCopyArtifacts.addon.name), + reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name), + helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name), + }; + for (const [index, libraryPath] of listElectronShippedLinuxLibraries( + resourceDir, + { artifactFormat: options.artifactFormat } + ).entries()) { + inspectedPaths[`electronLibrary:${index}`] = libraryPath; + } + const inspections = {}; + for (const [label, binaryPath] of Object.entries(inspectedPaths)) { + if ( + !inspectRegularReadableFile( + binaryPath, + `Linux ${label} ELF binary`, + errors + ) + ) { + continue; + } + try { + inspections[label] = normalizeElfInspection( + inspectElf(binaryPath), + binaryPath + ); + } catch (error) { + errors.push( + `Unable to inspect Linux ${label} ELF binary at ${binaryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + for (const label of Object.keys(inspections).filter( + (name) => + name === 'electron' || + name === 'addon' || + name === 'reader' || + name.startsWith('electronLibrary:') + )) { + const dynamic = inspections[label]; + if (!dynamic) { + continue; + } + const displayLabel = label.startsWith('electronLibrary:') + ? 'Electron library' + : label; + for (const dependencyName of dynamic.needed) { + if (dependencyFileName(dependencyName) !== dependencyName) { + errors.push( + `Linux ${displayLabel} DT_NEEDED entry must not contain a path: ${dependencyName} in ${inspectedPaths[label]}.` + ); + } + } + const libmpvDependencies = dynamic.needed.filter((dependencyName) => + anyLinuxLibmpvPattern.test(dependencyFileName(dependencyName)) + ); + if (libmpvDependencies.length > 0) { + errors.push( + `Linux ${displayLabel} must not link libmpv; found ${libmpvDependencies.join( + ', ' + )} in ${inspectedPaths[label]}.` + ); + } + } + + const helper = inspections.helper; + if (helper) { + if (!helper.needed.includes(manifest.libmpvSoname)) { + errors.push( + `Linux frame-copy helper must directly need ${manifest.libmpvSoname}.` + ); + } + const unexpectedLibmpvDependencies = helper.needed.filter( + (dependencyName) => + anyLinuxLibmpvPattern.test( + dependencyFileName(dependencyName) + ) && dependencyName !== manifest.libmpvSoname + ); + if (unexpectedLibmpvDependencies.length > 0) { + errors.push( + `Linux frame-copy helper must not need a different libmpv SONAME: ${unexpectedLibmpvDependencies.join( + ', ' + )}.` + ); + } + if (helper.rpath.length > 0) { + errors.push( + `Linux frame-copy helper must not contain RPATH; found ${helper.rpath.join( + ':' + )}.` + ); + } + if ( + helper.runpath.length !== 1 || + helper.runpath[0] !== '$ORIGIN/lib' + ) { + errors.push( + `Linux frame-copy helper RUNPATH must be exactly $ORIGIN/lib; received ${ + helper.runpath.length > 0 + ? helper.runpath.join(':') + : '' + }.` + ); + } + + const allowedHelperDependencies = new Set([ + manifest.libmpvSoname, + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + ...(manifest.runtimeMode === 'bundled' && + Array.isArray(manifest.runtimeFiles) + ? manifest.runtimeFiles.map(({ name }) => name) + : []), + ...(manifest.runtimeMode === 'bundled' && + Array.isArray( + manifest.runtimeDependencyClosure?.externalDependencies + ) + ? manifest.runtimeDependencyClosure.externalDependencies + : []), + ]); + for (const dependencyName of helper.needed) { + if ( + dependencyFileName(dependencyName) !== dependencyName || + !allowedHelperDependencies.has(dependencyName) + ) { + errors.push( + `Linux frame-copy helper dependency is not bundled or allowlisted: ${dependencyName}.` + ); + } + } + } + + if ( + manifest.runtimeMode !== 'bundled' || + !Array.isArray(manifest.runtimeFiles) + ) { + return; + } + + const runtimeFileNames = manifest.runtimeFiles + .map((runtimeFile) => runtimeFile?.name) + .filter((name) => typeof name === 'string'); + const runtimeNameSet = new Set(runtimeFileNames); + const allowedExternalNames = new Set([ + ...GLIBC_TOOLCHAIN_ALLOWLIST, + ...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), + ]); + const closureEntries = []; + let closureHasErrors = false; + for (const runtimeFileName of runtimeFileNames) { + const runtimePath = path.join(nativeDir, 'lib', runtimeFileName); + let dynamic; + try { + dynamic = normalizeElfInspection( + inspectElf(runtimePath), + runtimePath + ); + } catch (error) { + closureHasErrors = true; + errors.push( + `Unable to inspect bundled Linux runtime ELF at ${runtimePath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + closureEntries.push({ + name: runtimeFileName, + soname: dynamic.soname, + needed: dynamic.needed, + rpath: dynamic.rpath, + runpath: dynamic.runpath, + }); + if (dynamic.rpath.length > 0) { + closureHasErrors = true; + errors.push( + `${runtimeFileName} has forbidden RPATH ${dynamic.rpath.join( + ':' + )}.` + ); + } + if (dynamic.runpath.length !== 1 || dynamic.runpath[0] !== '$ORIGIN') { + closureHasErrors = true; + errors.push( + `${runtimeFileName} RUNPATH must be exactly $ORIGIN; got ${ + dynamic.runpath.length > 0 + ? dynamic.runpath.join(':') + : '' + }.` + ); + } + for (const dependencyName of dynamic.needed) { + if ( + !runtimeNameSet.has(dependencyName) && + !allowedExternalNames.has(dependencyName) + ) { + closureHasErrors = true; + errors.push( + `Runtime dependency is not bundled or allowlisted: ${runtimeFileName} -> ${dependencyName}.` + ); + } + } + } + + if (closureHasErrors) { + return; + } + try { + const actualClosure = validateRuntimeDependencyClosure({ + entries: closureEntries, + runtimeFileNames, + buildPrefix: '', + }); + if ( + !isDeepStrictEqual(actualClosure, manifest.runtimeDependencyClosure) + ) { + errors.push( + 'Actual bundled Linux ELF dependency closure does not match the packaged manifest.' + ); + } + } catch (error) { + errors.push( + `Invalid bundled Linux ELF dependency closure: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +function validateLinuxPackagedEmbeddedMpv(resourceDir, options) { + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + if (options.foreignArch) { + return validateForeignLinuxNativeDir(nativeDir); + } + + const errors = []; + const addonPath = path.join(nativeDir, 'embedded_mpv.node'); + const manifestPath = path.join(nativeDir, 'embedded-mpv-runtime.json'); + if (!pathExistsByLstat(addonPath)) { + if (options.required) { + errors.push(`Missing embedded MPV native addon: ${addonPath}`); + } + if (pathExistsByLstat(nativeDir)) { + for (const staleName of [ + 'embedded_mpv_frame_reader.node', + 'iptvnator_mpv_helper', + 'iptvnator_mpv_helper.exe', + 'embedded-mpv-runtime.json', + 'embedded-mpv-unavailable.txt', + 'lib', + ]) { + const stalePath = path.join(nativeDir, staleName); + if (pathExistsByLstat(stalePath)) { + errors.push( + `Embedded MPV addon is missing but stale packaged artifact remains: ${stalePath}` + ); + } + } + } + return errors; + } + + if (!options.profile) { + if (options.required) { + errors.push( + 'Linux frame-copy profile is required for a required same-architecture package.' + ); + } + validateNativeViewOnlyLinuxPackage( + nativeDir, + addonPath, + manifestPath, + errors + ); + return errors; + } + + let profile; + try { + profile = resolveLinuxFrameCopyProfile(options.profile); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + return errors; + } + const targetNames = normalizeLinuxTargetNames(options.targetNames, errors); + if (targetNames.length > 0) { + try { + errors.push( + ...validateLinuxProfileTargets(profile.name, targetNames) + ); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + } + } + + const readerPath = path.join( + nativeDir, + linuxFrameCopyArtifacts.frameReader.name + ); + const helperPath = path.join( + nativeDir, + linuxFrameCopyArtifacts.helper.name + ); + inspectRegularReadableFile(addonPath, 'embedded MPV native addon', errors); + inspectRegularReadableFile( + readerPath, + 'embedded MPV frame reader', + errors, + 0o644 + ); + inspectRegularReadableFile( + helperPath, + 'embedded MPV frame-copy helper', + errors, + 0o755 + ); + const staleWindowsHelper = path.join(nativeDir, 'iptvnator_mpv_helper.exe'); + if (pathExistsByLstat(staleWindowsHelper)) { + errors.push( + `Linux frame-copy package must not retain the Windows helper: ${staleWindowsHelper}` + ); + } + const staleMarker = path.join(nativeDir, 'embedded-mpv-unavailable.txt'); + if (pathExistsByLstat(staleMarker)) { + errors.push( + `Same-architecture Linux package must not retain the unavailable marker: ${staleMarker}` + ); + } + + if ( + !inspectRegularReadableFile( + manifestPath, + 'embedded MPV runtime manifest', + errors + ) + ) { + return errors; + } + const manifest = readPackagedJson( + manifestPath, + 'embedded MPV runtime manifest', + errors + ); + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) { + if (manifest !== null) { + errors.push('Embedded MPV runtime manifest must be an object.'); + } + return errors; + } + + validatePackagedManifestContract(manifest, profile, targetNames, errors); + if (profile.runtimeMode === 'system') { + validateSystemLinuxRuntime(nativeDir, manifest, errors); + } else { + validateBundledLinuxRuntime(nativeDir, manifest, errors); + } + inspectLinuxElfIsolation(resourceDir, nativeDir, manifest, options, errors); + return errors; +} + function validatePackagedEmbeddedMpv(resourceDir, options = {}) { const platform = normalizeEmbeddedMpvPlatform(options.platform); + if (platform === 'linux') { + return validateLinuxPackagedEmbeddedMpv(resourceDir, options); + } const unpackedNativeDir = path.join( resourceDir, 'app.asar.unpacked', @@ -692,24 +1793,6 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { ); const errors = []; - if (options.foreignArch) { - if (fs.existsSync(addonPath)) { - errors.push( - `Embedded MPV addon must not ship in foreign-architecture Linux packages: ${addonPath}` - ); - } - const markerPath = path.join( - unpackedNativeDir, - 'embedded-mpv-unavailable.txt' - ); - if (!fs.existsSync(markerPath)) { - errors.push( - `Missing embedded MPV unavailable marker for foreign-architecture package: ${markerPath}` - ); - } - return errors; - } - if (!fs.existsSync(addonPath)) { if (options.required) { errors.push(`Missing embedded MPV native addon: ${addonPath}`); @@ -721,8 +1804,7 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { // The frame-copy engine artifacts are built by the same binding.gyp // run as the addon; a macOS/Windows package that ships the addon // without them would silently lose the engine (support probe hides - // it). Linux packages intentionally strip the helper until the - // bundled-libmpv runtime lands (see electron-after-pack.cjs). + // it). const missingFrameCopyArtifacts = [ platform === 'win32' ? 'iptvnator_mpv_helper.exe' @@ -814,8 +1896,7 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`); } else { const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); - const expectedOrigin = - platform === 'linux' ? 'external-mpv-process' : 'vendored-lgpl'; + const expectedOrigin = 'vendored-lgpl'; if (manifest.origin !== expectedOrigin) { errors.push( `Embedded MPV packaged runtime must be ${expectedOrigin}, received: ${manifest.origin}` @@ -823,40 +1904,6 @@ function validatePackagedEmbeddedMpv(resourceDir, options = {}) { } } - if (platform === 'linux') { - const packagedFrameCopyHelpers = [ - path.join(unpackedNativeDir, 'iptvnator_mpv_helper'), - path.join(unpackedNativeDir, 'iptvnator_mpv_helper.exe'), - ].filter((candidate) => fs.existsSync(candidate)); - if (packagedFrameCopyHelpers.length > 0) { - errors.push( - [ - 'Linux packages must not ship frame-copy helpers linked against the build host system libmpv.', - 'Remove:', - ...packagedFrameCopyHelpers.map( - (candidate) => `- ${candidate}` - ), - ].join('\n') - ); - } - - const bundledLinuxRuntime = [ - path.join(libDir, 'libmpv.so.2'), - path.join(libDir, 'libmpv.so.1'), - path.join(libDir, 'libmpv.so'), - ].filter((candidate) => fs.existsSync(candidate)); - - if (bundledLinuxRuntime.length > 0) { - errors.push( - [ - 'Linux embedded MPV must use the external mpv process backend and must not bundle libmpv.', - 'Remove:', - ...bundledLinuxRuntime.map((candidate) => `- ${candidate}`), - ].join('\n') - ); - } - } - const runtimeCandidates = getPackagedRuntimeCandidates( libDir, platform, @@ -898,6 +1945,7 @@ module.exports = { commandExists, copyRuntimeToNativeBuild, findLibMpv, + listElectronShippedLinuxLibraries, listRuntimeFiles, listDylibs, parseOtoolDependencies, @@ -906,8 +1954,8 @@ module.exports = { validateNoForbiddenRuntimeLinks, getPackagedRuntimeCandidates, validatePackagedEmbeddedMpv, - getEmbeddedMpvAddonArch, isForeignLinuxEmbeddedMpvArch, linuxUnpackedDirArch, + resolveConfiguredLinuxTargetNames, resolveElectronBuilderArchName, }; diff --git a/tools/packaging/linux-frame-copy-profile.cjs b/tools/packaging/linux-frame-copy-profile.cjs new file mode 100644 index 000000000..0276e94c6 --- /dev/null +++ b/tools/packaging/linux-frame-copy-profile.cjs @@ -0,0 +1,100 @@ +'use strict'; + +function createProfile(name, runtimeMode, targets, manifestOrigin) { + return Object.freeze({ + name, + runtimeMode, + targets: Object.freeze([...targets]), + manifestOrigin, + }); +} + +const LINUX_FRAME_COPY_PROFILES = Object.freeze({ + system: createProfile( + 'system', + 'system', + ['deb', 'rpm', 'pacman'], + 'system-libmpv-frame-copy' + ), + portable: createProfile( + 'portable', + 'bundled', + ['appimage', 'snap'], + 'bundled-lgpl-frame-copy' + ), + flatpak: createProfile( + 'flatpak', + 'bundled', + ['flatpak'], + 'bundled-lgpl-frame-copy' + ), +}); +const SUPPORTED_PROFILE_NAMES = Object.freeze( + Object.keys(LINUX_FRAME_COPY_PROFILES) +); + +const LINUX_SYSTEM_PACKAGE_DEPENDENCIES = Object.freeze({ + deb: Object.freeze(['libmpv2', 'libegl1', 'libgl1', 'libgbm1']), + rpm: Object.freeze([ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + ]), + pacman: Object.freeze(['mpv', 'libglvnd', 'mesa']), +}); + +function expectedProfileNames() { + return SUPPORTED_PROFILE_NAMES.map((name) => `"${name}"`).join(', '); +} + +function findLinuxFrameCopyProfile(value) { + if (value == null || (typeof value === 'string' && value.trim() === '')) { + throw new Error( + `Linux frame-copy profile is required. Expected one of: ${expectedProfileNames()}.` + ); + } + + const name = typeof value === 'string' ? value.trim() : String(value); + if (!Object.hasOwn(LINUX_FRAME_COPY_PROFILES, name)) { + throw new Error( + `Unsupported Linux frame-copy profile "${name}". Expected one of: ${expectedProfileNames()}.` + ); + } + return LINUX_FRAME_COPY_PROFILES[name]; +} + +function resolveLinuxFrameCopyProfile(value) { + const profile = findLinuxFrameCopyProfile(value); + return createProfile( + profile.name, + profile.runtimeMode, + profile.targets, + profile.manifestOrigin + ); +} + +function validateLinuxProfileTargets(profileName, targetNames) { + const profile = findLinuxFrameCopyProfile(profileName); + if (!Array.isArray(targetNames)) { + throw new TypeError('Linux frame-copy targets must be an array.'); + } + + const allowedTargets = new Set(profile.targets); + return targetNames.flatMap((targetName) => { + const normalizedTarget = String(targetName).trim().toLowerCase(); + if (allowedTargets.has(normalizedTarget)) { + return []; + } + return [ + `Linux frame-copy profile "${profile.name}" cannot build target "${normalizedTarget}".`, + ]; + }); +} + +module.exports = { + LINUX_FRAME_COPY_PROFILES, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +}; diff --git a/tools/packaging/linux-frame-copy-profile.test.mjs b/tools/packaging/linux-frame-copy-profile.test.mjs new file mode 100644 index 000000000..8d3f858e0 --- /dev/null +++ b/tools/packaging/linux-frame-copy-profile.test.mjs @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { configureLinuxFrameCopyBuild } from './configure-linux-frame-copy-build.mjs'; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const { + LINUX_FRAME_COPY_PROFILES, + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); +const electronBuilderConfig = JSON.parse( + fs.readFileSync( + join(currentDir, '..', '..', 'electron-builder.json'), + 'utf8' + ) +); + +function hasSystemFrameCopyDependency(config, format, dependency) { + return (config[format]?.fpm ?? []).some((option) => + new RegExp(`^--depends(?:=|\\s+)${dependency}(?:$|\\s|[<>=])`).test( + option + ) + ); +} + +test('defines the exact immutable Linux frame-copy profile matrix', () => { + assert.deepEqual(LINUX_FRAME_COPY_PROFILES, { + system: { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }, + portable: { + name: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }, + flatpak: { + name: 'flatpak', + runtimeMode: 'bundled', + targets: ['flatpak'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }, + }); + assert.equal(Object.isFrozen(LINUX_FRAME_COPY_PROFILES), true); + for (const profile of Object.values(LINUX_FRAME_COPY_PROFILES)) { + assert.equal(Object.isFrozen(profile), true); + assert.equal(Object.isFrozen(profile.targets), true); + } +}); + +test('defines immutable system-package helper runtime dependencies', () => { + assert.deepEqual(LINUX_SYSTEM_PACKAGE_DEPENDENCIES, { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], + }); + assert.equal(Object.isFrozen(LINUX_SYSTEM_PACKAGE_DEPENDENCIES), true); + for (const dependencies of Object.values( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + assert.equal(Object.isFrozen(dependencies), true); + } +}); + +test('resolves each supported profile as an immutable defensive value', () => { + const firstSystemProfile = resolveLinuxFrameCopyProfile('system'); + const secondSystemProfile = resolveLinuxFrameCopyProfile('system'); + + assert.deepEqual(firstSystemProfile, { + name: 'system', + runtimeMode: 'system', + targets: ['deb', 'rpm', 'pacman'], + manifestOrigin: 'system-libmpv-frame-copy', + }); + assert.deepEqual(resolveLinuxFrameCopyProfile('portable'), { + name: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }); + assert.deepEqual(resolveLinuxFrameCopyProfile('flatpak'), { + name: 'flatpak', + runtimeMode: 'bundled', + targets: ['flatpak'], + manifestOrigin: 'bundled-lgpl-frame-copy', + }); + assert.notEqual(firstSystemProfile, LINUX_FRAME_COPY_PROFILES.system); + assert.notEqual(firstSystemProfile, secondSystemProfile); + assert.notEqual( + firstSystemProfile.targets, + LINUX_FRAME_COPY_PROFILES.system.targets + ); + assert.notEqual(firstSystemProfile.targets, secondSystemProfile.targets); + assert.equal(Object.isFrozen(firstSystemProfile), true); + assert.equal(Object.isFrozen(firstSystemProfile.targets), true); + assert.throws(() => firstSystemProfile.targets.push('appimage'), TypeError); + assert.deepEqual(resolveLinuxFrameCopyProfile('system').targets, [ + 'deb', + 'rpm', + 'pacman', + ]); +}); + +test('rejects missing and unsupported profile names with clear errors', () => { + for (const value of [undefined, null, '', ' ']) { + assert.throws( + () => resolveLinuxFrameCopyProfile(value), + /Linux frame-copy profile is required/ + ); + } + assert.throws( + () => resolveLinuxFrameCopyProfile('standard'), + /Unsupported Linux frame-copy profile "standard"/ + ); +}); + +test('rejects inherited object property names as unsupported profiles', () => { + for (const value of ['constructor', 'toString', '__proto__']) { + const unsupportedProfileError = new RegExp( + `Unsupported Linux frame-copy profile "${value}"` + ); + + assert.throws( + () => resolveLinuxFrameCopyProfile(value), + unsupportedProfileError + ); + assert.throws( + () => validateLinuxProfileTargets(value, ['deb']), + unsupportedProfileError + ); + } +}); + +test('validates profile targets case-insensitively with deterministic errors', () => { + const targets = ['DEB', 'AppImage', 'RPM']; + + assert.deepEqual(validateLinuxProfileTargets('system', targets), [ + 'Linux frame-copy profile "system" cannot build target "appimage".', + ]); + assert.deepEqual(targets, ['DEB', 'AppImage', 'RPM']); + assert.deepEqual( + validateLinuxProfileTargets('portable', ['APPIMAGE', 'sNaP']), + [] + ); + assert.deepEqual(validateLinuxProfileTargets('flatpak', ['FlatPak']), []); +}); + +test('rejects a non-array profile target list', () => { + assert.throws( + () => validateLinuxProfileTargets('system', 'deb'), + /Linux frame-copy targets must be an array/ + ); +}); + +test('keeps frame-copy package dependencies out of the base Electron Builder config', () => { + for (const [target, dependencies] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + assert.equal( + electronBuilderConfig[target]?.depends, + undefined, + `${target}.depends must remain unset so electron-builder keeps its defaults` + ); + for (const dependency of dependencies) { + assert.equal( + hasSystemFrameCopyDependency( + electronBuilderConfig, + target, + dependency + ), + false + ); + } + } +}); + +test('keeps frame-copy package dependencies out of non-system passes', () => { + const configs = [ + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'portable', + }), + configureLinuxFrameCopyBuild(electronBuilderConfig, { + profileName: 'flatpak', + }), + configureLinuxFrameCopyBuild(electronBuilderConfig, { + foreignDeb: true, + }), + ]; + + for (const config of configs) { + for (const [format, dependencies] of Object.entries( + LINUX_SYSTEM_PACKAGE_DEPENDENCIES + )) { + for (const dependency of dependencies) { + assert.equal( + hasSystemFrameCopyDependency(config, format, dependency), + false + ); + } + } + } +}); diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.cjs b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs new file mode 100644 index 000000000..bdf6b9e3c --- /dev/null +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.cjs @@ -0,0 +1,753 @@ +#!/usr/bin/env node + +'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 { + canonicalizeGitSubmoduleStatus, +} = require('../embedded-mpv/build-linux-runtime.cjs'); + +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 { + return childProcess + .execFileSync('git', ['-C', checkoutPath, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + .trim(); + } catch (error) { + const stderr = + error && typeof error === 'object' && 'stderr' in error + ? String(error.stderr).trim() + : ''; + throw new Error( + `Unable to inspect source checkout with git ${args.join(' ')}${stderr ? `: ${stderr}` : '.'}` + ); + } +} + +function assertExpectedGitRecord(expected) { + if ( + expected === null || + typeof expected !== 'object' || + typeof expected.sourceGitCommit !== 'string' || + !GIT_COMMIT_PATTERN.test(expected.sourceGitCommit) || + !Array.isArray(expected.sourceSubmodules) || + expected.sourceSubmodules.some( + (record) => typeof record !== 'string' || record.length === 0 + ) + ) { + throw new Error( + 'Expected source identity must contain one commit and a submodule record array.' + ); + } +} + +function assertCleanCheckout(checkoutPath, label) { + const status = gitOutput( + checkoutPath, + 'status', + '--porcelain=v1', + '--untracked-files=all', + '--ignore-submodules=none' + ); + if (status) { + throw new Error(`${label} checkout contains dirty or untracked files.`); + } +} + +function sourceSubmoduleIdentity(record) { + const match = record.match(/^([a-f0-9]{40,64})\s+([A-Za-z0-9_+./-]+)$/); + if (!match) { + throw new Error(`Invalid source submodule record: ${record}`); + } + const submodulePath = match[2]; + if ( + path.isAbsolute(submodulePath) || + submodulePath + .split('/') + .some((part) => part === '' || part === '.' || part === '..') + ) { + throw new Error(`Unsafe source submodule path: ${submodulePath}`); + } + return { + commit: match[1], + path: submodulePath, + }; +} + +function inspectCleanGitSource(checkoutPath, expected) { + assertExpectedGitRecord(expected); + const sourceGitCommit = gitOutput(checkoutPath, 'rev-parse', 'HEAD'); + if (sourceGitCommit !== expected.sourceGitCommit) { + throw new Error( + 'Source checkout commit does not match the runtime manifest.' + ); + } + const submoduleOutput = gitOutput( + checkoutPath, + 'submodule', + 'status', + '--recursive' + ); + const sourceSubmodules = canonicalizeGitSubmoduleStatus(submoduleOutput); + if (!isDeepStrictEqual(sourceSubmodules, expected.sourceSubmodules)) { + throw new Error( + 'Source checkout submodules do not match the runtime manifest.' + ); + } + + assertCleanCheckout(checkoutPath, 'Source'); + for (const submoduleRecord of sourceSubmodules) { + const submodule = sourceSubmoduleIdentity(submoduleRecord); + const submoduleCheckout = path.join( + checkoutPath, + ...submodule.path.split('/') + ); + if ( + gitOutput(submoduleCheckout, 'rev-parse', 'HEAD') !== + submodule.commit + ) { + throw new Error( + `Source submodule ${submodule.path} commit does not match its recorded identity.` + ); + } + assertCleanCheckout( + submoduleCheckout, + `Source submodule ${submodule.path}` + ); + } + return { + sourceGitCommit, + sourceSubmodules, + }; +} + +function gitMetadataEntries(rootPath) { + const entries = []; + function visit(directoryPath, relativeDirectory = '') { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directoryPath, entry.name); + if (entry.name === '.git') { + entries.push(relativePath); + } else if (entry.isDirectory()) { + visit(absolutePath, relativePath); + } + } + } + visit(rootPath); + return entries.sort(); +} + +function assertNoGitMetadata(rootPath) { + const entries = gitMetadataEntries(rootPath); + if (entries.length > 0) { + throw new Error( + `Prepared source snapshot must not contain VCS metadata: ${entries.join(', ')}` + ); + } +} + +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); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + throw error; + } +} + +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)}-`) + ); + try { + fs.cpSync(checkoutPath, temporaryPath, { + recursive: true, + dereference: false, + verbatimSymlinks: true, + 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; + } +} + +function prepareLinuxRuntimeSourceSnapshot({ + checkoutPath, + outputPath, + expected, + expectedSourceSnapshotSha256, +}) { + const checkoutStat = fs.lstatSync(checkoutPath); + if (!checkoutStat.isDirectory() || checkoutStat.isSymbolicLink()) { + throw new Error('Source checkout must be a real directory.'); + } + const checkoutRoot = fs.realpathSync(checkoutPath); + const outputRoot = path.resolve(outputPath); + const outputParent = path.dirname(outputRoot); + const outputParentStat = fs.lstatSync(outputParent); + if (!outputParentStat.isDirectory() || outputParentStat.isSymbolicLink()) { + throw new Error( + 'Prepared source snapshot parent must be a real directory.' + ); + } + if (lstatIfExists(outputRoot)) { + throw new Error( + `Prepared source snapshot output must not already exist: ${outputRoot}` + ); + } + const realOutputRoot = path.join( + fs.realpathSync(outputParent), + path.basename(outputRoot) + ); + const isInside = (root, candidate) => { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); + }; + if ( + isInside(checkoutRoot, realOutputRoot) || + isInside(realOutputRoot, checkoutRoot) + ) { + throw new Error( + 'Prepared source snapshot and checkout must be separate trees.' + ); + } + const record = inspectCleanGitSource(checkoutRoot, expected); + const sourceSnapshot = copyWorkingTreeWithoutGitMetadata( + checkoutRoot, + realOutputRoot, + expectedSourceSnapshotSha256 + ); + assertNoGitMetadata(realOutputRoot); + return { + ...record, + sourceSnapshot, + }; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const name = tokens[index]; + const value = tokens[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid command-line argument: ${name ?? ''}`); + } + options[name.slice(2)] = value; + } + return { command, options }; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command === 'prepare' && + options['runtime-manifest'] && + options.checkout && + options.output && + options['record-output'] + ) { + const runtimeManifest = readJson(options['runtime-manifest']); + const sourcePackage = runtimeManifest?.packages?.libplacebo; + const record = prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: options.checkout, + outputPath: options.output, + expected: { + sourceGitCommit: sourcePackage?.sourceGitCommit, + sourceSubmodules: sourcePackage?.sourceSubmodules, + }, + expectedSourceSnapshotSha256: + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + }); + writeJson(options['record-output'], record); + return; + } + if (command === 'assert-vcs-free' && options.directory) { + assertNoGitMetadata(options.directory); + return; + } + throw new Error( + 'Usage: prepare-linux-runtime-source-snapshot.cjs prepare --runtime-manifest --checkout --output --record-output | assert-vcs-free --directory ' + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT, + assertNoGitMetadata, + inspectCleanGitSource, + inventoryLinuxRuntimeSourceSnapshot, + prepareLinuxRuntimeSourceSnapshot, + validateLinuxRuntimeSourceSnapshot, +}; diff --git a/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs new file mode 100644 index 000000000..6d9d0e0a0 --- /dev/null +++ b/tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs @@ -0,0 +1,776 @@ +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); +const { + canonicalizeGitSubmoduleStatus, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const helperPath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'prepare-linux-runtime-source-snapshot.cjs' +); + +async function loadHelper() { + if (!fs.existsSync(helperPath)) { + return null; + } + return import(pathToFileURL(helperPath).href); +} + +function runGit(cwd, args, env = {}) { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + }); + assert.equal( + result.status, + 0, + `git ${args.join(' ')} failed:\n${result.stderr}` + ); + return result.stdout.trim(); +} + +function createEquivalentCheckouts(root) { + const origin = path.join(root, 'origin'); + fs.mkdirSync(origin); + runGit(origin, ['init', '--quiet']); + fs.mkdirSync(path.join(origin, 'src')); + fs.writeFileSync(path.join(origin, 'src', 'renderer.c'), 'int main() {}\n'); + fs.writeFileSync(path.join(origin, 'LICENSE'), 'license\n'); + fs.symlinkSync('../LICENSE', path.join(origin, 'src', 'license-link')); + runGit(origin, ['add', '.']); + runGit(origin, ['commit', '--quiet', '-m', 'source fixture'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }); + const commit = runGit(origin, ['rev-parse', 'HEAD']); + const first = path.join(root, 'first'); + const second = path.join(root, 'second'); + runGit(root, ['clone', '--quiet', origin, first]); + runGit(root, ['clone', '--quiet', origin, second]); + + fs.appendFileSync( + path.join(first, '.git', 'config'), + '\n[fixture]\n\tvalue = first\n' + ); + fs.appendFileSync( + path.join(second, '.git', 'config'), + '\n[fixture]\n\tvalue = second\n' + ); + fs.writeFileSync(path.join(first, '.git', 'fixture-cache'), 'one'); + fs.writeFileSync(path.join(second, '.git', 'fixture-cache'), 'two'); + const differentTime = new Date('2030-01-01T00:00:00Z'); + fs.utimesSync( + path.join(second, '.git', 'index'), + differentTime, + differentTime + ); + + return { commit, first, second }; +} + +function createCheckoutWithSubmodule(root) { + const submoduleOrigin = path.join(root, 'submodule-origin'); + fs.mkdirSync(submoduleOrigin); + runGit(submoduleOrigin, ['init', '--quiet']); + fs.writeFileSync( + path.join(submoduleOrigin, 'submodule-source.c'), + 'int submodule_source;\n' + ); + runGit(submoduleOrigin, ['add', '.']); + runGit(submoduleOrigin, ['commit', '--quiet', '-m', 'submodule source'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-01T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-01T00:00:00Z', + }); + + const checkout = path.join(root, 'checkout-with-submodule'); + fs.mkdirSync(checkout); + runGit(checkout, ['init', '--quiet']); + fs.writeFileSync(path.join(checkout, 'README'), 'source tree\n'); + runGit(checkout, ['add', 'README']); + runGit(checkout, [ + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + submoduleOrigin, + '3rdparty/example', + ]); + runGit(checkout, ['commit', '--quiet', '-m', 'source with submodule'], { + GIT_AUTHOR_NAME: 'Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_AUTHOR_DATE: '2000-01-02T00:00:00Z', + GIT_COMMITTER_NAME: 'Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_DATE: '2000-01-02T00:00:00Z', + }); + return { + checkout, + expected: { + sourceGitCommit: runGit(checkout, ['rev-parse', 'HEAD']), + sourceSubmodules: canonicalizeGitSubmoduleStatus( + runGit(checkout, ['submodule', 'status', '--recursive']) + ), + }, + }; +} + +function snapshotRecords(root) { + const records = []; + function visit(directory, relativeDirectory = '') { + for (const entry of fs + .readdirSync(directory, { withFileTypes: true }) + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + )) { + const relativePath = path.posix.join(relativeDirectory, entry.name); + const absolutePath = path.join(directory, entry.name); + const stat = fs.lstatSync(absolutePath); + if (entry.isDirectory()) { + records.push({ + path: `${relativePath}/`, + mode: stat.mode & 0o777, + }); + visit(absolutePath, relativePath); + } else if (entry.isSymbolicLink()) { + records.push({ + path: relativePath, + mode: stat.mode & 0o777, + symlink: fs.readlinkSync(absolutePath), + }); + } else { + records.push({ + path: relativePath, + mode: stat.mode & 0o777, + sha256: crypto + .createHash('sha256') + .update(fs.readFileSync(absolutePath)) + .digest('hex'), + }); + } + } + } + visit(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'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-snapshot-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first, second } = createEquivalentCheckouts(root); + const firstOutput = path.join(root, 'snapshot-first'); + const secondOutput = path.join(root, 'snapshot-second'); + const expected = { + sourceGitCommit: commit, + sourceSubmodules: [], + }; + + const firstRecord = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: firstOutput, + expected, + }); + const secondRecord = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: second, + outputPath: secondOutput, + 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) + ); + assert.equal( + snapshotRecords(firstOutput).some(({ path: recordPath }) => + recordPath.split('/').includes('.git') + ), + false + ); + assert.equal( + fs.readlinkSync(path.join(firstOutput, 'src', 'license-link')), + '../LICENSE' + ); +}); + +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'); + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-source-identity-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { commit, first, second } = createEquivalentCheckouts(root); + + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: path.join(root, 'wrong-commit'), + expected: { + sourceGitCommit: '0'.repeat(40), + sourceSubmodules: [], + }, + }), + /commit does not match/ + ); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: path.join(root, 'wrong-submodules'), + expected: { + sourceGitCommit: commit, + sourceSubmodules: [`${'1'.repeat(40)} 3rdparty/example`], + }, + }), + /submodules do not match/ + ); + + fs.writeFileSync(path.join(second, 'untracked-build-output'), 'dirty'); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: second, + outputPath: path.join(root, 'dirty'), + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /dirty or untracked files/ + ); + + const existingOutput = path.join(root, 'existing-output'); + fs.mkdirSync(existingOutput); + fs.writeFileSync( + path.join(existingOutput, 'owned-by-someone-else'), + 'keep' + ); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: existingOutput, + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /output.*must not already exist/i + ); + assert.equal( + fs.readFileSync( + path.join(existingOutput, 'owned-by-someone-else'), + 'utf8' + ), + 'keep' + ); + + const existingSymlink = path.join(root, 'existing-symlink'); + fs.symlinkSync('missing-target', existingSymlink); + assert.throws( + () => + helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: first, + outputPath: existingSymlink, + expected: { + sourceGitCommit: commit, + sourceSubmodules: [], + }, + }), + /output.*must not already exist/i + ); + assert.equal(fs.readlinkSync(existingSymlink), 'missing-target'); +}); + +test('preserves recursive submodule sources while stripping every nested .git link', 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-submodule-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const { checkout, expected } = createCheckoutWithSubmodule(root); + const output = path.join(root, 'submodule-snapshot'); + + const record = helper.prepareLinuxRuntimeSourceSnapshot({ + checkoutPath: checkout, + outputPath: output, + expected, + }); + assert.deepEqual( + { + 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'), + 'utf8' + ), + 'int submodule_source;\n' + ); + assert.doesNotThrow(() => helper.assertNoGitMetadata(output)); +}); + +test('fails closed when a prepared snapshot contains any nested .git entry', 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-vcs-entry-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.mkdirSync(path.join(root, 'nested', '.git'), { recursive: true }); + fs.writeFileSync(path.join(root, 'nested', '.git', 'index'), 'metadata'); + + assert.throws( + () => helper.assertNoGitMetadata(root), + /must not contain VCS metadata.*nested\/\.git/i + ); +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 25e5aa53f..1ac68583b 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -12,6 +12,7 @@ "{workspaceRoot}/package.json", "{workspaceRoot}/electron-builder.json", "{workspaceRoot}/.github/workflows/build-and-make.yaml", + "{workspaceRoot}/.github/workflows/publish-snap.yaml", "{workspaceRoot}/apps/electron-backend/build-embedded-mpv.js", "{workspaceRoot}/apps/electron-backend/project.json", "{workspaceRoot}/apps/electron-backend/native/src/embedded_mpv_win32.cc", @@ -21,16 +22,56 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.mjs", + "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", + "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", + "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", + "{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", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.cjs", + "{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", + "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}" } }, "lint": { - "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\"" + "inputs": [ + "default", + "{workspaceRoot}/.github/workflows/build-and-make.yaml", + "{workspaceRoot}/.github/workflows/publish-snap.yaml", + "{workspaceRoot}/tools/packaging/prepare-linux-runtime-source-snapshot.cjs", + "{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/linux-source-archive-contract.cjs\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"] diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs new file mode 100644 index 000000000..33085595f --- /dev/null +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -0,0 +1,507 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; +import { + assertBuildSnapWorkflowPolicy, + assertPublishSnapWorkflowPolicy, +} from './snap-workflow-policy.test-helpers.mjs'; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); +const buildWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'build-and-make.yaml' +); +const publishWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'publish-snap.yaml' +); +const releaseAssetHelperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-assets.cjs' +); + +async function loadReleaseAssetHelper() { + if (!fs.existsSync(releaseAssetHelperPath)) { + return null; + } + return import(pathToFileURL(releaseAssetHelperPath).href); +} + +function insertFirstJobStep(workflowText, stepSource) { + const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText); + assert.ok(stepsHeader, 'workflow must contain a steps list'); + const insertionIndex = stepsHeader.index + stepsHeader[0].length; + const stepIndent = `${stepsHeader[1]} `; + const indentedStep = stepSource + .split('\n') + .map((line) => `${stepIndent}${line}`) + .join('\n'); + return `${workflowText.slice( + 0, + insertionIndex + )}${indentedStep}\n${workflowText.slice(insertionIndex)}`; +} + +function insertWorkflowJob(workflowText, jobSource) { + const jobsHeader = /^([ \t]*)jobs:\r?\n/m.exec(workflowText); + assert.ok(jobsHeader, 'workflow must contain a jobs mapping'); + const insertionIndex = jobsHeader.index + jobsHeader[0].length; + const jobIndent = `${jobsHeader[1]} `; + const indentedJob = jobSource + .split('\n') + .map((line) => `${jobIndent}${line}`) + .join('\n'); + return `${workflowText.slice( + 0, + insertionIndex + )}${indentedJob}\n${workflowText.slice(insertionIndex)}`; +} + +function insertFirstJobField(workflowText, fieldSource) { + const stepsHeader = /^([ \t]+)steps:\r?\n/m.exec(workflowText); + assert.ok(stepsHeader, 'workflow must contain a steps list'); + const fieldIndent = stepsHeader[1]; + const indentedField = fieldSource + .split('\n') + .map((line) => `${fieldIndent}${line}`) + .join('\n'); + return `${workflowText.slice( + 0, + stepsHeader.index + )}${indentedField}\n${workflowText.slice(stepsHeader.index)}`; +} + +function assertStepRejectedByBothPolicies(stepSource) { + for (const [workflowPath, assertPolicy] of [ + [publishWorkflowPath, assertPublishSnapWorkflowPolicy], + [buildWorkflowPath, assertBuildSnapWorkflowPolicy], + ]) { + const workflowText = insertFirstJobStep( + fs.readFileSync(workflowPath, 'utf8'), + stepSource + ); + assert.doesNotThrow(() => parse(workflowText)); + assert.throws(() => assertPolicy(workflowText)); + } +} + +test('publishes Snap only after a public v-tag release contains binary and source assets', () => { + assert.equal( + fs.existsSync(publishWorkflowPath), + true, + 'the release-published Snap workflow must exist' + ); + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + assert.match(workflowText, /^permissions:\n {4}contents: read$/m); + assert.match( + workflowText, + /startsWith\(github\.event\.release\.tag_name,\s*'v'\)/ + ); + assert.match(workflowText, /github\.event\.release\.draft\s*==\s*false/); + + const validateIndex = workflowText.indexOf( + '- name: Select exact public release assets' + ); + const verifyIndex = workflowText.indexOf( + '- name: Verify downloaded public release assets' + ); + const uploadIndex = workflowText.indexOf( + '- name: Publish all public-release snaps to edge' + ); + assert.ok(validateIndex >= 0); + assert.ok(verifyIndex > validateIndex); + assert.ok(uploadIndex > verifyIndex); + + assert.match( + workflowText, + /github\.event\.release\.id[\s\S]*release-snap-assets\.cjs select/ + ); + assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match(workflowText, /release-snap-assets\.cjs verify/); + assertPublishSnapWorkflowPolicy(workflowText); + const disabledWorkflow = workflowText.replace( + 'github.event.release.draft == false }}', + 'github.event.release.draft == false && false }}' + ); + assert.notEqual(disabledWorkflow, workflowText); + assert.doesNotThrow(() => parse(disabledWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(disabledWorkflow)); + const extraTrigger = workflowText.replace( + ' - published', + ' - published\n - edited' + ); + assert.doesNotThrow(() => parse(extraTrigger)); + assert.throws(() => assertPublishSnapWorkflowPolicy(extraTrigger)); + assert.match( + workflowText, + /Candidate\/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke/ + ); + + const buildWorkflow = fs.readFileSync(buildWorkflowPath, 'utf8'); + assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); + 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 [ + '--release=edge,candidate,stable', + '--release edge,candidate,stable', + '--release=candidate', + '--release stable', + ]) { + const mixedChannelWorkflow = workflowText.replace( + '--release=edge', + forbiddenReleaseArgument + ); + assert.notEqual(mixedChannelWorkflow, workflowText); + assert.doesNotThrow(() => parse(mixedChannelWorkflow)); + assert.throws(() => + assertPublishSnapWorkflowPolicy(mixedChannelWorkflow) + ); + } +}); + +test('rejects edge upload text in non-executing shell contexts', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + 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`, + `cat <<\\EOF\n${edgeUpload}\nEOF`, + `printf '%s\\n' "\n${edgeUpload}\n"`, + `publish_snap() {\n${edgeUpload}\n}`, + `if false; then\n${edgeUpload}\nfi`, + `cat < parse(mutatedWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects extra CLI tokens across wrappers, YAML forms, quotes, and heredocs', () => { + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + for (const stepSource of [ + '- run: command snapcraft upload --release=stable package.snap', + '- run: |\n if true; then command snapcraft \\\n upload --release=edge,candidate,stable package.snap; fi', + '- run: |\n snap\\\n craft upload --release=stable package.snap', + '- run: |\n snapcraft up\\\n load --release=stable package.snap', + '- run: |2\n snapcraft upload --release=stable package.snap', + '- run: snapcraft upload --release=stable package.snap', + '- { run: snapcraft upload --release=stable package.snap }', + '- run: echo "snapcraft upload --release=edge package.snap"', + "- run: |\n cat <<'EOF'\n snapcraft upload --release=edge package.snap\n EOF", + '- run: command snapcraft release iptvnator stable', + ]) { + const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource); + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertPublishSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects continued uploads and release commands in the build workflow', () => { + const workflowText = fs.readFileSync(buildWorkflowPath, 'utf8'); + for (const stepSource of [ + '- run: |\n if true; then command snapcraft \\\n upload --release=edge package.snap; fi', + '- run: command snapcraft release iptvnator edge', + ]) { + const mutatedWorkflow = insertFirstJobStep(workflowText, stepSource); + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertBuildSnapWorkflowPolicy(mutatedWorkflow)); + } +}); + +test('rejects encoded, indirect, and unknown actions in both workflows', () => { + for (const stepSource of [ + '- uses: snapcore/action-publish@v1\n with:\n release: stable', + '- "uses": snapcore/action-publish@v1\n with:\n release: stable', + "- 'uses' : snapcore/action-publish@v1\n with:\n release: stable", + '- "\\x75ses": snapcore/action-publish@v1\n with:\n release: stable', + '- ? uses\n : snapcore/action-publish@v1\n with:\n release: stable', + '- { uses: snapcore/action-publish@v1, with: { release: stable } }', + '- uses: >-\n snapcore/action-publish@v1\n with:\n release: stable', + '- name: Alias publisher\n env:\n PUBLISHER: &publisher snapcore/action-publish@v1\n uses: *publisher\n with:\n release: stable', + '- uses: "\\x73napcore/action-publish@v1"\n with:\n release: stable', + '- uses: example/action-publish@v1\n with:\n release: stable', + ]) { + assertStepRejectedByBothPolicies(stepSource); + } +}); + +test('rejects job-level reusable workflow publication in both workflows', () => { + const reusableJob = + 'synthetic-publisher:\n uses: snapcore/action-publish/.github/workflows/publish.yml@v1\n with:\n release: stable\n secrets: inherit'; + for (const [workflowPath, assertPolicy] of [ + [publishWorkflowPath, assertPublishSnapWorkflowPolicy], + [buildWorkflowPath, assertBuildSnapWorkflowPolicy], + ]) { + const workflowText = insertWorkflowJob( + fs.readFileSync(workflowPath, 'utf8'), + reusableJob + ); + assert.doesNotThrow(() => parse(workflowText)); + assert.throws(() => assertPolicy(workflowText)); + } +}); + +test('rejects command-bearing explicit shell templates in both workflows', () => { + const maliciousShell = '"snapcraft release iptvnator stable; bash {0}"'; + for (const [workflowPath, assertPolicy] of [ + [publishWorkflowPath, assertPublishSnapWorkflowPolicy], + [buildWorkflowPath, assertBuildSnapWorkflowPolicy], + ]) { + const workflowText = fs.readFileSync(workflowPath, 'utf8'); + for (const mutatedWorkflow of [ + insertFirstJobStep( + workflowText, + `- shell: ${maliciousShell}\n run: echo safe` + ), + `${workflowText}\ndefaults:\n run:\n shell: ${maliciousShell}\n`, + insertFirstJobField( + workflowText, + `defaults:\n run:\n shell: ${maliciousShell}` + ), + ]) { + assert.doesNotThrow(() => parse(mutatedWorkflow)); + assert.throws(() => assertPolicy(mutatedWorkflow)); + } + } +}); + +test('ignores Snapcraft names in comments and allowlisted action uses', () => { + const commentStep = + '- run: |\n # snapcraft upload --release=stable package.snap\n # snapcraft release iptvnator stable'; + const publishWorkflow = insertFirstJobStep( + fs.readFileSync(publishWorkflowPath, 'utf8'), + commentStep + ); + const buildWorkflow = insertFirstJobStep( + fs.readFileSync(buildWorkflowPath, 'utf8'), + commentStep + ); + + assert.doesNotThrow(() => parse(publishWorkflow)); + assert.doesNotThrow(() => parse(buildWorkflow)); + assert.doesNotThrow(() => assertPublishSnapWorkflowPolicy(publishWorkflow)); + assert.doesNotThrow(() => assertBuildSnapWorkflowPolicy(buildWorkflow)); +}); + +test('selects every exact Snap and exactly one compliance source asset', async () => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + const selected = helper.selectSnapReleaseAssets([ + { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, + { id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz' }, + { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, + { id: 7, name: 'IPTVnator-1.0.0.AppImage' }, + ]); + + assert.deepEqual(selected, { + snapAssets: [ + { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, + { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }); +}); + +test('rejects a release missing either exact asset class or containing ambiguous source assets', async () => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + assert.throws( + () => + helper.selectSnapReleaseAssets([ + { + id: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + ]), + /at least one \.snap asset/ + ); + assert.throws( + () => + helper.selectSnapReleaseAssets([{ id: 1, name: 'IPTVnator.snap' }]), + /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ + ); + assert.throws( + () => + helper.selectSnapReleaseAssets([ + { id: 1, name: 'IPTVnator.snap' }, + { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + ]), + /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ + ); +}); + +test('verifies the complete selected download set before publication', async (t) => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const manifest = { + snapAssets: [{ id: 1, name: 'IPTVnator.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + + fs.writeFileSync(path.join(temporaryRoot, 'IPTVnator.snap'), 'snap'); + assert.throws( + () => helper.verifySnapReleaseDownloads(manifest, temporaryRoot), + /missing or empty.*linux-frame-copy-runtime-sources\.tar\.xz/i + ); + fs.writeFileSync( + path.join(temporaryRoot, 'linux-frame-copy-runtime-sources.tar.xz'), + 'sources' + ); + assert.deepEqual( + helper.verifySnapReleaseDownloads(manifest, temporaryRoot), + manifest + ); +}); diff --git a/tools/packaging/release-snap-assets.cjs b/tools/packaging/release-snap-assets.cjs new file mode 100644 index 000000000..0670df809 --- /dev/null +++ b/tools/packaging/release-snap-assets.cjs @@ -0,0 +1,701 @@ +#!/usr/bin/env node + +'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 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)) { + throw new Error('GitHub release assets must be an array.'); + } + return value.flatMap((entry) => + Array.isArray(entry) ? flattenAssetPages(entry) : [entry] + ); +} + +function normalizeReleaseAsset(asset) { + if ( + asset === null || + typeof asset !== 'object' || + !Number.isSafeInteger(asset.id) || + asset.id <= 0 || + typeof asset.name !== 'string' || + asset.name.length === 0 || + asset.name === '.' || + asset.name === '..' || + asset.name.includes('/') || + asset.name.includes('\\') || + [...asset.name].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error('GitHub release contains an invalid asset record.'); + } + return { + id: asset.id, + name: asset.name, + }; +} + +function selectSnapReleaseAssets(assets) { + const normalized = flattenAssetPages(assets).map(normalizeReleaseAsset); + const snapAssets = normalized + .filter(({ name }) => name.endsWith('.snap')) + .sort(({ name: left }, { name: right }) => left.localeCompare(right)); + if (snapAssets.length === 0) { + throw new Error( + 'Public release must contain at least one .snap asset.' + ); + } + + const sourceAssets = normalized.filter( + ({ name }) => name === SOURCE_ARCHIVE_NAME + ); + if (sourceAssets.length !== 1) { + throw new Error( + `Public release must contain exactly one ${SOURCE_ARCHIVE_NAME} asset.` + ); + } + const names = normalized.map(({ name }) => name); + if (new Set(names).size !== names.length) { + throw new Error('GitHub release asset names must be unique.'); + } + + return { + snapAssets, + sourceAsset: sourceAssets[0], + }; +} + +function canonicalSelection(selection) { + if ( + selection === null || + typeof selection !== 'object' || + !Array.isArray(selection.snapAssets) + ) { + throw new Error('Invalid selected Snap release asset manifest.'); + } + const canonical = selectSnapReleaseAssets([ + ...selection.snapAssets, + selection.sourceAsset, + ]); + if (!isDeepStrictEqual(selection, canonical)) { + throw new Error( + 'Selected Snap release asset manifest is not canonical.' + ); + } + return canonical; +} + +function verifySnapReleaseDownloads(selection, directoryPath) { + const canonical = canonicalSelection(selection); + const expectedNames = [ + ...canonical.snapAssets.map(({ name }) => name), + canonical.sourceAsset.name, + ].sort(); + try { + fs.accessSync(directoryPath); + } catch { + throw new Error( + `Missing public release asset directory: ${directoryPath}` + ); + } + for (const name of expectedNames) { + const assetPath = path.join(directoryPath, name); + let stat; + try { + stat = fs.lstatSync(assetPath); + } catch { + throw new Error(`Missing or empty public release asset: ${name}`); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error(`Missing or empty public release asset: ${name}`); + } + } + const actualNames = fs.readdirSync(directoryPath).sort(); + if (!isDeepStrictEqual(actualNames, expectedNames)) { + throw new Error( + 'Downloaded public release assets do not match the exact selected set.' + ); + } + 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 = {}; + for (let index = 0; index < tokens.length; index += 2) { + const name = tokens[index]; + const value = tokens[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid command-line argument: ${name ?? ''}`); + } + options[name.slice(2)] = value; + } + return { command, options }; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command === 'select' && + options['assets-json'] && + options['output-json'] + ) { + writeJson( + options['output-json'], + selectSnapReleaseAssets(readJson(options['assets-json'])) + ); + return; + } + if ( + command === 'verify' && + options.manifest && + options.directory && + options['repository-revision'] && + options['verified-directory'] + ) { + verifySnapReleaseCorrespondence( + readJson(options.manifest), + options.directory, + { + expectedRepositoryRevision: options['repository-revision'], + verifiedDirectory: options['verified-directory'], + } + ); + return; + } + if ( + command === 'verify-receipt' && + options.manifest && + options.directory && + options.receipt && + options['repository-revision'] + ) { + verifyVerifiedReleaseReceipt( + readJson(options.manifest), + options.directory, + options.receipt, + options['repository-revision'] + ); + return; + } + if ( + command === 'verify-sealed' && + options.manifest && + options.directory && + options.receipt && + options['repository-revision'] + ) { + verifySnapReleaseCorrespondence( + readJson(options.manifest), + options.directory, + { + expectedRepositoryRevision: options['repository-revision'], + verifiedReceiptPath: options.receipt, + } + ); + return; + } + throw new Error( + `Usage: release-snap-assets.cjs select --assets-json --output-json | verify --manifest --directory --repository-revision --verified-directory | verify-receipt --manifest --directory --receipt --repository-revision | verify-sealed --manifest --directory --receipt --repository-revision ` + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { + SOURCE_ARCHIVE_NAME, + VERIFIED_RELEASE_RECEIPT_NAME, + selectSnapReleaseAssets, + snapshotSnapReleaseDownloads, + verifyVerifiedReleaseReceipt, + verifySnapReleaseCorrespondence, + verifySnapReleaseDownloads, + verifySnapReleaseSourceBinding, +}; diff --git a/tools/packaging/release-snap-assets.test.mjs b/tools/packaging/release-snap-assets.test.mjs new file mode 100644 index 000000000..c1a3a4c1c --- /dev/null +++ b/tools/packaging/release-snap-assets.test.mjs @@ -0,0 +1,1649 @@ +import assert from 'node:assert/strict'; +import childProcess from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'yaml'; + +const require = createRequire(import.meta.url); +const { createPackage: createAsarPackage } = require('@electron/asar'); +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); +const helperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-assets.cjs' +); +const sourceBindingHelperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-source-binding.cjs' +); +const sourceArchiveContractPath = path.join( + workspaceRoot, + 'tools', + 'embedded-mpv', + 'linux-source-archive-contract.cjs' +); +const publishWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'publish-snap.yaml' +); + +async function loadHelper() { + return import(pathToFileURL(helperPath).href); +} + +async function loadSourceBindingHelper() { + return import(pathToFileURL(sourceBindingHelperPath).href); +} + +async function loadSourceArchiveContract() { + return import(pathToFileURL(sourceArchiveContractPath).href); +} + +function syntheticSquashfsListing() { + return [ + 'drwxr-xr-x 0/0 0 2026-07-18 00:00 squashfs-root', + '-rw-r--r-- 0/0 1 2026-07-18 00:00 squashfs-root/payload', + '', + ].join('\n'); +} + +function sha256(contents) { + return crypto.createHash('sha256').update(contents).digest('hex'); +} + +const SYNTHETIC_LIBPLACEBO_CONTENTS = Buffer.from( + 'libplacebo source snapshot\n' +); +const SYNTHETIC_LIBPLACEBO_ENTRIES = Object.freeze([ + Object.freeze({ + path: 'README.md', + type: 'file', + size: SYNTHETIC_LIBPLACEBO_CONTENTS.length, + executable: false, + sha256: sha256(SYNTHETIC_LIBPLACEBO_CONTENTS), + }), +]); +const SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT = (() => { + const canonical = { + schemaVersion: 1, + entryCount: SYNTHETIC_LIBPLACEBO_ENTRIES.length, + totalBytes: SYNTHETIC_LIBPLACEBO_CONTENTS.length, + entries: SYNTHETIC_LIBPLACEBO_ENTRIES, + }; + return Object.freeze({ + schemaVersion: canonical.schemaVersion, + sha256: sha256(`${JSON.stringify(canonical)}\n`), + entryCount: canonical.entryCount, + totalBytes: canonical.totalBytes, + entries: SYNTHETIC_LIBPLACEBO_ENTRIES, + }); +})(); +const EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES = Object.freeze([ + '450bd2232225d6c7728a4108055ac2e37cef6475 3rdparty/Vulkan-Headers', + '97b54ca9e75f5303507699d27c6b4f4efe4641a1 3rdparty/fast_float', + '73db193f853e2ee079bf3ca8a64aa2eaf6459043 3rdparty/glad', + '15206881c006c79667fe5154fe80c01c65410679 3rdparty/jinja', + '297fc8e356e6836a62087949245d09a28e9f1b13 3rdparty/markupsafe', + '242f35efa067a46c595645eeda7b1771ea1f83b1 demos/3rdparty/nuklear', +]); + +function sourcePackageIdentity(sourcePackage) { + return Object.fromEntries( + [ + 'version', + 'sourceUrl', + 'sourceTag', + 'sourceSha256', + 'sourceGitCommit', + 'license', + ] + .filter((field) => Object.hasOwn(sourcePackage, field)) + .map((field) => [field, sourcePackage[field]]) + ); +} + +function createComplianceInspection(sourceRuntime) { + const licenseInputFiles = []; + const noticeLicenseFiles = []; + const licenseInputPackages = []; + const noticePackages = []; + for (const [id, sourcePackage] of Object.entries(sourceRuntime.packages)) { + const contents = Buffer.from(`${id} license\n`); + const file = { + path: `licenses/${id}/LICENSE`, + size: contents.length, + sha256: sha256(contents), + }; + licenseInputFiles.push(file); + noticeLicenseFiles.push(file); + licenseInputPackages.push({ + id, + ...sourcePackageIdentity(sourcePackage), + files: [ + { + sourcePath: 'LICENSE', + ...file, + }, + ], + }); + noticePackages.push({ + id, + ...sourcePackageIdentity(sourcePackage), + files: [file], + }); + } + const aggregateNoticeContents = Buffer.from('third-party notices\n'); + const noticeFile = { + path: 'THIRD_PARTY_NOTICES.txt', + size: aggregateNoticeContents.length, + sha256: sha256(aggregateNoticeContents), + }; + return { + aggregateNoticeContents, + libplaceboSourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + licenseInputFiles, + licenseInputs: { + schemaVersion: 1, + origin: 'pinned-linux-runtime-license-inputs', + platform: 'linux', + arch: 'x64', + packages: licenseInputPackages, + }, + noticeFile, + noticeLicenseFiles, + notices: { + schemaVersion: 1, + origin: 'pinned-linux-runtime-upstream-licenses', + platform: 'linux', + arch: 'x64', + noticeFile, + packages: noticePackages, + totalBytes: + noticeFile.size + + noticeLicenseFiles.reduce( + (total, file) => total + file.size, + 0 + ), + }, + toolingValidated: true, + }; +} + +function createSourceBindingFixture() { + const repositoryRevision = 'a'.repeat(40); + const archiveSha256 = 'b'.repeat(64); + const sourceRuntime = { + generatedAt: '2026-07-18T00:00:00.000Z', + packages: { + ffmpeg: { + version: '1.0.0', + sourceUrl: 'https://example.test/ffmpeg.tar.xz', + sourceSha256: archiveSha256, + license: 'LGPL-2.1-or-later', + }, + libplacebo: { + version: '2.0.0', + sourceUrl: 'https://example.test/libplacebo.git', + sourceTag: 'v2.0.0', + sourceGitCommit: 'c'.repeat(40), + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + license: 'LGPL-2.1-or-later', + }, + }, + }; + const compliance = createComplianceInspection(sourceRuntime); + const sourceCompliance = { ...compliance }; + delete sourceCompliance.aggregateNoticeContents; + const sourceIndex = { + schemaVersion: 3, + repositoryRevision, + sourcePackages: sourceRuntime.packages, + archives: [ + { + name: 'ffmpeg.tar.xz', + sha256: archiveSha256, + }, + ], + libplacebo: { + sourceGitCommit: sourceRuntime.packages.libplacebo.sourceGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_SOURCE_SUBMODULES], + sourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + }, + legal: { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: compliance.notices.noticeFile, + packages: compliance.notices.packages, + }, + }; + const sourceInspection = { + archiveSha256: 'd'.repeat(64), + sourceRuntime, + sourceIndex, + repositoryRevision, + localChanges: Buffer.alloc(0), + archiveFiles: sourceIndex.archives, + compliance: sourceCompliance, + }; + const snapPayloads = { + 'IPTVnator-amd64.snap': { + assetName: 'IPTVnator-amd64.snap', + architecture: 'x64', + markerOnly: false, + manifest: { + platform: 'linux', + arch: 'x64', + profile: 'portable', + runtimeMode: 'bundled', + targets: ['appimage', 'snap'], + sourceArchive: { + schemaVersion: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + sha256: 'd'.repeat(64), + repositoryRevision, + }, + sourceRuntime, + }, + }, + 'IPTVnator-arm64.snap': { + assetName: 'IPTVnator-arm64.snap', + architecture: 'arm64', + markerOnly: true, + manifest: null, + }, + }; + return { + expectedSourceSnapshotSha256: + SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT.sha256, + repositoryRevision, + sourceInspection, + snapPayloads, + }; +} + +test('binds source metadata and checksums to every selected Snap before publication', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-binding-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const selection = { + snapAssets: [ + { id: 1, name: 'IPTVnator-amd64.snap' }, + { id: 2, name: 'IPTVnator-arm64.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(temporaryRoot, name), name); + } + const inspectedSnaps = []; + + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, temporaryRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => fixture.sourceInspection, + inspectSnapPayload: (_snapPath, asset) => { + inspectedSnaps.push(asset.name); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + }), + selection + ); + assert.deepEqual( + inspectedSnaps, + selection.snapAssets.map(({ name }) => name) + ); +}); + +test('publishes only a stable verified asset snapshot with an exact receipt', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-snapshot-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + fs.mkdirSync(downloadRoot); + const selection = { + snapAssets: [ + { id: 1, name: 'IPTVnator-amd64.snap' }, + { id: 2, name: 'IPTVnator-arm64.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + const inspectedPaths = []; + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + inspectedPaths.push(sourcePath); + return fixture.sourceInspection; + }, + inspectSnapPayload: (snapPath, asset) => { + inspectedPaths.push(snapPath); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }), + selection + ); + assert.ok( + inspectedPaths.every((filePath) => + filePath.startsWith(`${verifiedRoot}${path.sep}`) + ) + ); + const receiptPath = path.join( + verifiedRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + const receipt = helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + fixture.repositoryRevision + ); + assert.equal(receipt.repositoryRevision, fixture.repositoryRevision); + assert.deepEqual( + receipt.assets.map(({ name }) => name), + [ + ...selection.snapAssets.map(({ name }) => name), + selection.sourceAsset.name, + ] + ); + const sealedInspectionPaths = []; + assert.deepEqual( + helper.verifySnapReleaseCorrespondence(selection, verifiedRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + sealedInspectionPaths.push(sourcePath); + return fixture.sourceInspection; + }, + inspectSnapPayload: (snapPath, asset) => { + sealedInspectionPaths.push(snapPath); + return fixture.snapPayloads[asset.name]; + }, + validateRuntimeManifest: () => [], + verifiedReceiptPath: receiptPath, + }), + selection + ); + assert.ok( + sealedInspectionPaths.every((filePath) => + filePath.startsWith(`${verifiedRoot}${path.sep}`) + ) + ); + assert.throws( + () => + helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + 'f'.repeat(40) + ), + /invalid contract/i + ); + + const changedSnapPath = path.join( + verifiedRoot, + selection.snapAssets[0].name + ); + fs.chmodSync(changedSnapPath, 0o644); + fs.appendFileSync(changedSnapPath, 'changed'); + assert.throws( + () => + helper.verifyVerifiedReleaseReceipt( + selection, + verifiedRoot, + receiptPath, + fixture.repositoryRevision + ), + /no longer matches its receipt/i + ); +}); + +test('binds sealed inspection to the initially verified receipt across a mutually consistent replacement', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-replacement-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + const replacementRoot = path.join(temporaryRoot, 'replacement'); + const originalRoot = path.join(temporaryRoot, 'original'); + fs.mkdirSync(downloadRoot); + fs.mkdirSync(replacementRoot); + const selection = { + snapAssets: [{ id: 1, name: 'IPTVnator-amd64.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + selection.snapAssets[0].name, + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => fixture.sourceInspection, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }); + + const replacementSnapContents = Buffer.from('replacement Snap payload'); + const sourceContents = fs.readFileSync( + path.join(verifiedRoot, selection.sourceAsset.name) + ); + fs.writeFileSync( + path.join(replacementRoot, selection.snapAssets[0].name), + replacementSnapContents + ); + fs.writeFileSync( + path.join(replacementRoot, selection.sourceAsset.name), + sourceContents + ); + const replacementRecords = [ + { + ...selection.snapAssets[0], + sha256: sha256(replacementSnapContents), + size: replacementSnapContents.length, + }, + { + ...selection.sourceAsset, + sha256: sha256(sourceContents), + size: sourceContents.length, + }, + ]; + const replacementReceiptPath = path.join( + replacementRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + fs.writeFileSync( + replacementReceiptPath, + `${JSON.stringify( + { + schemaVersion: 1, + repositoryRevision: fixture.repositoryRevision, + assets: replacementRecords, + }, + null, + 2 + )}\n` + ); + assert.deepEqual( + helper.verifyVerifiedReleaseReceipt( + selection, + replacementRoot, + replacementReceiptPath, + fixture.repositoryRevision + ).assets, + replacementRecords + ); + const receiptPath = path.join( + verifiedRoot, + helper.VERIFIED_RELEASE_RECEIPT_NAME + ); + let replaced = false; + + assert.throws( + () => + helper.verifySnapReleaseCorrespondence(selection, verifiedRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: () => { + fs.renameSync(verifiedRoot, originalRoot); + fs.renameSync(replacementRoot, verifiedRoot); + replaced = true; + return fixture.sourceInspection; + }, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedReceiptPath: receiptPath, + }), + /initially verified receipt/i + ); + assert.equal(replaced, true); +}); + +test('removes a verified snapshot when an asset changes during inspection', async (t) => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-race-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const downloadRoot = path.join(temporaryRoot, 'downloads'); + const verifiedRoot = path.join(temporaryRoot, 'verified'); + fs.mkdirSync(downloadRoot); + const selection = { + snapAssets: [{ id: 1, name: 'IPTVnator-amd64.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + for (const name of [ + selection.snapAssets[0].name, + selection.sourceAsset.name, + ]) { + fs.writeFileSync(path.join(downloadRoot, name), name); + } + + assert.throws( + () => + helper.verifySnapReleaseCorrespondence(selection, downloadRoot, { + expectedRepositoryRevision: fixture.repositoryRevision, + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + inspectSourceArchive: (sourcePath) => { + fs.chmodSync(sourcePath, 0o644); + fs.appendFileSync(sourcePath, 'changed'); + return fixture.sourceInspection; + }, + inspectSnapPayload: (_snapPath, asset) => + fixture.snapPayloads[asset.name], + validateRuntimeManifest: () => [], + verifiedDirectory: verifiedRoot, + }), + /changed during inspection/i + ); + assert.equal(fs.existsSync(verifiedRoot), false); +}); + +test('fails closed for stale source identity, archive bytes, and x64 marker-only payloads', async () => { + const helper = await loadHelper(); + const fixture = createSourceBindingFixture(); + const validateRuntimeManifest = () => []; + + const staleRevision = structuredClone(fixture.sourceInspection); + staleRevision.sourceIndex.repositoryRevision = 'e'.repeat(40); + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: staleRevision, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /repository revision/i + ); + + const forgedSubmodules = structuredClone(fixture); + const forgedSubmoduleRecord = `${'f'.repeat(40)} 3rdparty/Vulkan-Headers`; + forgedSubmodules.sourceInspection.sourceRuntime.packages.libplacebo.sourceSubmodules[0] = + forgedSubmoduleRecord; + forgedSubmodules.sourceInspection.sourceIndex.libplacebo.sourceSubmodules[0] = + forgedSubmoduleRecord; + forgedSubmodules.sourceInspection.sourceIndex.sourcePackages = + forgedSubmodules.sourceInspection.sourceRuntime.packages; + forgedSubmodules.snapPayloads[ + 'IPTVnator-amd64.snap' + ].manifest.sourceRuntime = forgedSubmodules.sourceInspection.sourceRuntime; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: forgedSubmodules.sourceInspection, + snapPayloads: Object.values(forgedSubmodules.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /submodule/i + ); + + const tamperedArchive = structuredClone(fixture.sourceInspection); + tamperedArchive.archiveFiles[0].sha256 = 'f'.repeat(64); + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: tamperedArchive, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /archive.*checksum/i + ); + + const staleSnapPayloads = structuredClone(fixture.snapPayloads); + staleSnapPayloads[ + 'IPTVnator-amd64.snap' + ].manifest.sourceRuntime.generatedAt = '2025-01-01T00:00:00.000Z'; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: Object.values(staleSnapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /Snap source runtime.*source archive/i + ); + + const markerOnlyX64 = structuredClone(fixture.snapPayloads); + markerOnlyX64['IPTVnator-amd64.snap'] = { + assetName: 'IPTVnator-amd64.snap', + architecture: 'x64', + markerOnly: true, + manifest: null, + }; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: Object.values(markerOnlyX64), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /x64 Snap.*frame-copy/i + ); + + const incompleteCompliance = structuredClone(fixture.sourceInspection); + incompleteCompliance.compliance.toolingValidated = false; + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: incompleteCompliance, + snapPayloads: Object.values(fixture.snapPayloads), + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /source archive compliance/i + ); + + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: [ + fixture.snapPayloads['IPTVnator-arm64.snap'], + ], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /exactly one x64 Snap/i + ); + + assert.throws( + () => + helper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection: fixture.sourceInspection, + snapPayloads: [ + fixture.snapPayloads['IPTVnator-amd64.snap'], + { + ...fixture.snapPayloads['IPTVnator-amd64.snap'], + assetName: 'IPTVnator-second-amd64.snap', + }, + ], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest, + } + ), + /exactly one x64 Snap/i + ); +}); + +test('hashes the final source archive bytes and reads the exact packaged Snap binding', async (t) => { + const sourceBindingHelper = await loadSourceBindingHelper(); + const sourceArchiveContract = await loadSourceArchiveContract(); + const fixture = createSourceBindingFixture(); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-inspection-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + + const sourceRoot = path.join(temporaryRoot, 'source'); + const metadataRoot = path.join(sourceRoot, 'metadata'); + const archivesRoot = path.join(sourceRoot, 'archives'); + fs.mkdirSync(metadataRoot, { recursive: true }); + fs.mkdirSync(archivesRoot, { recursive: true }); + const pinnedSourceContents = Buffer.from('pinned ffmpeg source'); + const pinnedSourceSha256 = crypto + .createHash('sha256') + .update(pinnedSourceContents) + .digest('hex'); + const sourceRuntime = structuredClone( + fixture.sourceInspection.sourceRuntime + ); + sourceRuntime.packages.ffmpeg.sourceSha256 = pinnedSourceSha256; + const sourceIndex = structuredClone(fixture.sourceInspection.sourceIndex); + sourceIndex.sourcePackages = sourceRuntime.packages; + sourceIndex.archives = [ + { + name: 'ffmpeg.tar.xz', + sha256: pinnedSourceSha256, + }, + ]; + sourceIndex.libplacebo = { + sourceGitCommit: sourceRuntime.packages.libplacebo.sourceGitCommit, + sourceSubmodules: [ + ...sourceRuntime.packages.libplacebo.sourceSubmodules, + ], + sourceSnapshot: SYNTHETIC_LIBPLACEBO_SOURCE_SNAPSHOT, + }; + const compliance = createComplianceInspection(sourceRuntime); + sourceIndex.legal = { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: compliance.notices.noticeFile, + packages: compliance.notices.packages, + }; + fs.writeFileSync( + path.join(archivesRoot, 'ffmpeg.tar.xz'), + pinnedSourceContents + ); + const licenseInputRoot = path.join(sourceRoot, 'license-inputs'); + const noticesRoot = path.join(sourceRoot, 'notices'); + for (const [root, files] of [ + [licenseInputRoot, compliance.licenseInputFiles], + [noticesRoot, compliance.noticeLicenseFiles], + ]) { + for (const file of files) { + const filePath = path.join(root, ...file.path.split('/')); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${file.path.split('/')[1]} license\n`); + } + } + fs.writeFileSync( + path.join(licenseInputRoot, 'linux-runtime-license-inputs.json'), + `${JSON.stringify(compliance.licenseInputs)}\n` + ); + fs.writeFileSync( + path.join(noticesRoot, 'embedded-mpv-notices.json'), + `${JSON.stringify(compliance.notices)}\n` + ); + fs.writeFileSync( + path.join(noticesRoot, 'THIRD_PARTY_NOTICES.txt'), + compliance.aggregateNoticeContents + ); + const libplaceboRoot = path.join(sourceRoot, 'git', 'libplacebo'); + fs.mkdirSync(libplaceboRoot, { recursive: true }); + fs.writeFileSync( + path.join(libplaceboRoot, 'README.md'), + 'libplacebo source snapshot\n' + ); + const toolingFiles = [ + ['embedded-mpv', 'build-linux-runtime.cjs'], + ['embedded-mpv', 'build-linux-runtime.mjs'], + ['embedded-mpv', 'generate-linux-runtime-notices.cjs'], + ['embedded-mpv', 'linux-runtime-manifest.cjs'], + ['embedded-mpv', 'linux-source-archive-contract.cjs'], + ['embedded-mpv', 'stage-runtime.mjs'], + ['packaging', 'prepare-linux-runtime-source-snapshot.cjs'], + ]; + const toolingRoot = path.join(sourceRoot, 'tooling'); + fs.mkdirSync(toolingRoot, { recursive: true }); + for (const [directoryName, fileName] of toolingFiles) { + fs.copyFileSync( + path.join(workspaceRoot, 'tools', directoryName, fileName), + path.join(toolingRoot, fileName) + ); + } + fs.writeFileSync( + path.join(metadataRoot, 'runtime-manifest.json'), + `${JSON.stringify(sourceRuntime)}\n` + ); + fs.writeFileSync( + path.join(metadataRoot, 'source-index.json'), + `${JSON.stringify(sourceIndex)}\n` + ); + fs.writeFileSync( + path.join(metadataRoot, 'iptvnator-git-revision.txt'), + `${fixture.repositoryRevision}\n` + ); + fs.writeFileSync(path.join(metadataRoot, 'local-changes.patch'), ''); + const archiveChecksumsPath = path.join(metadataRoot, 'archive-sha256.txt'); + fs.writeFileSync( + archiveChecksumsPath, + `${pinnedSourceSha256} ffmpeg.tar.xz\n` + ); + + const sourceArchivePath = path.join( + temporaryRoot, + 'linux-frame-copy-runtime-sources.tar.xz' + ); + const tarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + sourceArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(tarResult.error, undefined); + assert.equal(tarResult.status, 0, tarResult.stderr); + + let observedTarFilesFromFile = false; + const runCommand = (command, args, options = {}) => { + if (command === 'tar' && args.includes('-T')) { + const filesFromPath = args[args.indexOf('-T') + 1]; + assert.equal(path.isAbsolute(filesFromPath), true); + assert.equal(fs.lstatSync(filesFromPath).isFile(), true); + assert.equal( + fs.lstatSync(filesFromPath).mode & 0o077, + 0, + 'tar files-from input must not be group/world accessible' + ); + const filesFromContents = fs.readFileSync(filesFromPath, 'utf8'); + assert.match( + filesFromContents, + /(?:^|\n)\.\/git\/libplacebo\/README\.md(?:\n|$)/, + 'tar files-from input must preserve the exact archive member names' + ); + assert.ok( + args.includes('--no-recursion'), + 'tar must not recursively consume descendants that are also listed explicitly' + ); + observedTarFilesFromFile = true; + } + const result = childProcess.spawnSync(command, args, { + encoding: + options.encoding === undefined ? 'utf8' : options.encoding, + input: options.input, + killSignal: 'SIGKILL', + maxBuffer: options.maxBuffer, + stdio: 'pipe', + timeout: options.timeout, + windowsHide: true, + }); + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + throw new Error( + `${command} exited with status ${String(result.status)}: ${ + result.stderr ?? '' + }` + ); + } + return result.stdout; + }; + const sourceArchive = sourceArchiveContract.createLinuxSourceArchiveBinding( + { + archivePath: sourceArchivePath, + repositoryRevision: fixture.repositoryRevision, + } + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(sourceArchivePath, { + runCommand, + }), + /snapshot digest mismatch/i + ); + const sourceInspection = sourceBindingHelper.inspectSourceArchive( + sourceArchivePath, + { + expectedSourceSnapshotSha256: fixture.expectedSourceSnapshotSha256, + runCommand, + } + ); + assert.equal(observedTarFilesFromFile, true); + assert.equal(sourceInspection.archiveSha256, sourceArchive.sha256); + assert.deepEqual(sourceInspection.archiveFiles, sourceIndex.archives); + + const hiddenSourceRoot = path.join(temporaryRoot, 'hidden-source'); + fs.mkdirSync(hiddenSourceRoot); + fs.writeFileSync( + path.join(hiddenSourceRoot, 'undeclared-hidden-source.txt'), + 'not part of the canonical source bundle' + ); + const hiddenSourceArchivePath = path.join( + temporaryRoot, + 'hidden-source.tar.xz' + ); + const hiddenSourceTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + hiddenSourceArchivePath, + '--directory', + hiddenSourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(hiddenSourceTarResult.error, undefined); + assert.equal(hiddenSourceTarResult.status, 0, hiddenSourceTarResult.stderr); + const concatenatedSourceArchivePath = path.join( + temporaryRoot, + 'concatenated-source.tar.xz' + ); + fs.writeFileSync( + concatenatedSourceArchivePath, + Buffer.concat([ + fs.readFileSync(sourceArchivePath), + fs.readFileSync(hiddenSourceArchivePath), + ]) + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive( + concatenatedSourceArchivePath, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + } + ), + /undeclared source archive member/i + ); + + fs.writeFileSync( + archiveChecksumsPath, + `${'0'.repeat(64)} ffmpeg.tar.xz\n` + ); + const badChecksumArchivePath = path.join( + temporaryRoot, + 'bad-checksum-source.tar.xz' + ); + const badChecksumTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + badChecksumArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(badChecksumTarResult.error, undefined); + assert.equal(badChecksumTarResult.status, 0, badChecksumTarResult.stderr); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(badChecksumArchivePath, { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + }), + /archive checksum/i + ); + fs.writeFileSync( + archiveChecksumsPath, + `${pinnedSourceSha256} ffmpeg.tar.xz\n` + ); + + const extraSourcePath = path.join(sourceRoot, 'undeclared-source.txt'); + fs.writeFileSync( + extraSourcePath, + 'not part of the canonical source bundle' + ); + const extraMemberArchivePath = path.join( + temporaryRoot, + 'extra-member-source.tar.xz' + ); + const extraMemberTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + extraMemberArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(extraMemberTarResult.error, undefined); + assert.equal(extraMemberTarResult.status, 0, extraMemberTarResult.stderr); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive(extraMemberArchivePath, { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + }), + /undeclared source archive member/i + ); + fs.unlinkSync(extraSourcePath); + + const oversizedSourceArchive = path.join( + temporaryRoot, + 'oversized-source.tar.xz' + ); + fs.writeFileSync(oversizedSourceArchive, ''); + fs.truncateSync(oversizedSourceArchive, 1024 * 1024 * 1024 + 1); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(oversizedSourceArchive), + /bounded non-empty regular file/i + ); + + const localChangesPath = path.join(metadataRoot, 'local-changes.patch'); + const emptyTargetPath = path.join(sourceRoot, 'empty-target'); + fs.writeFileSync(emptyTargetPath, ''); + fs.unlinkSync(localChangesPath); + fs.symlinkSync('../empty-target', localChangesPath); + const symlinkArchivePath = path.join( + temporaryRoot, + 'symlinked-source.tar.xz' + ); + const symlinkTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + symlinkArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(symlinkTarResult.error, undefined); + assert.equal(symlinkTarResult.status, 0, symlinkTarResult.stderr); + assert.throws( + () => sourceBindingHelper.inspectSourceArchive(symlinkArchivePath), + /required member.*regular file/i + ); + fs.unlinkSync(localChangesPath); + fs.writeFileSync(localChangesPath, ''); + + const oversizedMemberPath = path.join( + libplaceboRoot, + 'oversized-source-member.bin' + ); + fs.writeFileSync(oversizedMemberPath, ''); + fs.truncateSync(oversizedMemberPath, 128 * 1024 * 1024 + 1); + const oversizedMemberArchivePath = path.join( + temporaryRoot, + 'oversized-member-source.tar.xz' + ); + const oversizedMemberTarResult = childProcess.spawnSync( + 'tar', + [ + '--create', + '--xz', + '--file', + oversizedMemberArchivePath, + '--directory', + sourceRoot, + '.', + ], + { encoding: 'utf8' } + ); + assert.equal(oversizedMemberTarResult.error, undefined); + assert.equal( + oversizedMemberTarResult.status, + 0, + oversizedMemberTarResult.stderr + ); + assert.throws( + () => + sourceBindingHelper.inspectSourceArchive( + oversizedMemberArchivePath, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + } + ), + /member exceeds.*size limit/i + ); + fs.unlinkSync(oversizedMemberPath); + + const snapSourceRoot = path.join(temporaryRoot, 'snap-source'); + const appRoot = path.join(snapSourceRoot, 'usr', 'lib', 'iptvnator'); + const nativeRoot = path.join( + appRoot, + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeRoot, { recursive: true }); + const electronHeader = Buffer.alloc(20); + electronHeader.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + electronHeader.writeUInt16LE(62, 18); + fs.writeFileSync(path.join(appRoot, 'iptvnator.bin'), electronHeader); + const packagedManifest = { + ...fixture.snapPayloads['IPTVnator-amd64.snap'].manifest, + sourceArchive, + sourceRuntime, + }; + fs.writeFileSync( + path.join(nativeRoot, 'embedded-mpv-runtime.json'), + `${JSON.stringify(packagedManifest)}\n` + ); + const snapYamlPath = path.join(snapSourceRoot, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + const validSnapYaml = [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - shared-memory', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n'); + fs.writeFileSync(snapYamlPath, validSnapYaml); + const graphicsRoot = path.join(snapSourceRoot, 'graphics'); + fs.mkdirSync(graphicsRoot, { mode: 0o755 }); + fs.chmodSync(graphicsRoot, 0o755); + const asarSourceRoot = path.join(temporaryRoot, 'asar-source'); + const appAsarPath = path.join(appRoot, 'resources', 'app.asar'); + fs.mkdirSync(asarSourceRoot); + fs.writeFileSync( + path.join(asarSourceRoot, 'main.js'), + 'module.exports = {}' + ); + await createAsarPackage(asarSourceRoot, appAsarPath); + const cleanReleaseCheckoutRoot = path.join( + temporaryRoot, + 'clean-release-checkout' + ); + const cleanReleaseToolsRoot = path.join(cleanReleaseCheckoutRoot, 'tools'); + fs.mkdirSync(cleanReleaseToolsRoot, { recursive: true }); + for (const toolDirectory of ['packaging', 'embedded-mpv']) { + fs.cpSync( + path.join(workspaceRoot, 'tools', toolDirectory), + path.join(cleanReleaseToolsRoot, toolDirectory), + { recursive: true } + ); + } + const cleanReleaseBoundaryHelperPath = path.join( + cleanReleaseToolsRoot, + 'packaging', + 'validate-snap-release-boundary.mjs' + ); + const dependencyFreeBoundaryResult = childProcess.spawnSync( + process.execPath, + [cleanReleaseBoundaryHelperPath, snapSourceRoot], + { + encoding: 'utf8', + env: { + ...process.env, + NODE_OPTIONS: '', + NODE_PATH: '', + }, + } + ); + assert.equal( + dependencyFreeBoundaryResult.status, + 0, + dependencyFreeBoundaryResult.stderr + ); + assert.deepEqual(JSON.parse(dependencyFreeBoundaryResult.stdout), { + schemaVersion: 1, + errors: [], + }); + const snapPath = path.join(temporaryRoot, 'IPTVnator-amd64.snap'); + fs.writeFileSync(snapPath, 'synthetic Snap bytes'); + const staticValidationCalls = []; + const snapPayload = sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + assert.ok(destinationIndex > 0); + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: (resourceDirectory, options) => { + staticValidationCalls.push({ options, resourceDirectory }); + return []; + }, + } + ); + assert.deepEqual(snapPayload, { + architecture: 'x64', + assetName: 'IPTVnator-amd64.snap', + manifest: packagedManifest, + markerOnly: false, + }); + assert.equal(staticValidationCalls.length, 1); + assert.ok( + staticValidationCalls[0].resourceDirectory.endsWith( + ['payload', 'usr', 'lib', 'iptvnator', 'resources'].join(path.sep) + ) + ); + assert.deepEqual(staticValidationCalls[0].options, { + artifactFormat: 'snap', + executableName: 'iptvnator', + foreignArch: false, + hostPlatform: 'linux', + platform: 'linux', + profile: 'portable', + required: true, + targetArch: 'x64', + targetNames: ['appimage', 'snap'], + }); + + fs.writeFileSync( + snapYamlPath, + validSnapYaml.replace(' - shared-memory\n', '') + ); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [], + } + ), + /shared-memory plug/i + ); + fs.writeFileSync(snapYamlPath, validSnapYaml); + + fs.rmSync(asarSourceRoot, { recursive: true }); + const staleAsarNativeRoot = path.join( + asarSourceRoot, + 'electron-backend', + 'native' + ); + fs.mkdirSync(staleAsarNativeRoot, { recursive: true }); + fs.writeFileSync( + path.join(staleAsarNativeRoot, 'embedded-mpv-runtime.json'), + '{}\n' + ); + fs.rmSync(appAsarPath); + await createAsarPackage(asarSourceRoot, appAsarPath); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [], + } + ), + /app\.asar.*embedded MPV native payload/i + ); + fs.rmSync(asarSourceRoot, { recursive: true }); + fs.mkdirSync(asarSourceRoot); + fs.writeFileSync( + path.join(asarSourceRoot, 'main.js'), + 'module.exports = {}' + ); + fs.rmSync(appAsarPath); + await createAsarPackage(asarSourceRoot, appAsarPath); + + assert.equal( + sourceBindingHelper.verifySnapReleaseSourceBinding( + { + expectedRepositoryRevision: fixture.repositoryRevision, + sourceInspection, + snapPayloads: [snapPayload], + }, + { + expectedSourceSnapshotSha256: + fixture.expectedSourceSnapshotSha256, + validateRuntimeManifest: () => [], + } + ), + true + ); + + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => [ + 'Hidden inherited frame-copy artifact.', + ], + } + ), + /Hidden inherited frame-copy artifact/ + ); + + const decoySourceRoot = path.join(temporaryRoot, 'decoy-snap-source'); + const decoyAppRoot = path.join(decoySourceRoot, 'decoy'); + fs.cpSync(appRoot, decoyAppRoot, { recursive: true }); + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(decoySourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + throw new Error( + 'Static validation must not inspect a decoy root.' + ); + }, + } + ), + /canonical frame-copy manifest/i + ); + + const manifestPath = path.join(nativeRoot, 'embedded-mpv-runtime.json'); + fs.truncateSync(manifestPath, 16 * 1024 * 1024 + 1); + let oversizedStaticValidationCalls = 0; + assert.throws( + () => + sourceBindingHelper.inspectSnapPayload( + snapPath, + { id: 1, name: 'IPTVnator-amd64.snap' }, + { + runCommand: (command, args) => { + assert.equal(command, 'unsquashfs'); + if (args[0] === '-lln') { + return syntheticSquashfsListing(); + } + const destinationIndex = args.indexOf('-dest') + 1; + fs.cpSync(snapSourceRoot, args[destinationIndex], { + recursive: true, + }); + return ''; + }, + validatePackagedEmbeddedMpv: () => { + oversizedStaticValidationCalls += 1; + return []; + }, + } + ), + /invalid frame-copy manifest/i + ); + assert.equal(oversizedStaticValidationCalls, 0); +}); + +test('publish workflow installs the source verifier and binds the release tag revision', () => { + const workflow = fs.readFileSync(publishWorkflowPath, 'utf8'); + const parsedWorkflow = parse(workflow); + const verifyJob = parsedWorkflow.jobs['verify-snap']; + const publishJob = parsedWorkflow.jobs['publish-snap']; + const uploadStep = publishJob.steps.find( + (step) => step.name === 'Publish all public-release snaps to edge' + ); + const checkoutStep = verifyJob.steps.find( + (step) => step.name === 'Checkout released tooling' + ); + const snapcraftStep = publishJob.steps.find( + (step) => step.name === 'Install Snapcraft' + ); + const selectStep = verifyJob.steps.find( + (step) => step.name === 'Select exact public release assets' + ); + const downloadStep = verifyJob.steps.find( + (step) => step.name === 'Download exact public release assets' + ); + const verifyStep = verifyJob.steps.find( + (step) => step.name === 'Verify downloaded public release assets' + ); + const sealedVerifyStep = verifyJob.steps.find( + (step) => step.name === 'Reverify sealed public release assets' + ); + const transferStep = verifyJob.steps.find( + (step) => step.name === 'Transfer verified release assets' + ); + const artifactDownloadStep = publishJob.steps.find( + (step) => step.name === 'Download verified release assets' + ); + const transferredSealStep = publishJob.steps.find( + (step) => step.name === 'Seal transferred public release assets' + ); + assert.equal(verifyJob['timeout-minutes'], 45); + assert.equal(publishJob['timeout-minutes'], 20); + assert.equal(publishJob.needs, 'verify-snap'); + assert.match( + workflow, + /apt-get install[\s\S]*binutils[\s\S]*squashfs-tools[\s\S]*xz-utils/ + ); + assert.match( + workflow, + /release-snap-assets\.cjs verify[\s\S]*--repository-revision/ + ); + assert.ok( + workflow.indexOf('--repository-revision') < + workflow.indexOf('snapcraft upload --release=edge') + ); + assert.equal(JSON.stringify(verifyJob).includes('snapcraft_token'), false); + assert.equal( + checkoutStep.with?.['persist-credentials'], + false, + 'release checkout must not persist github.token for later steps' + ); + assert.equal( + checkoutStep.uses, + 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' + ); + assert.equal(snapcraftStep.uses, undefined); + assert.match( + snapcraftStep.run, + /sudo snap install snapcraft --classic --channel=stable/ + ); + assert.match(verifyStep.run, /--verified-directory/); + assert.match(verifyStep.run, /chown -R root:root/); + assert.match(verifyStep.run, /chmod 0555/); + assert.match(verifyStep.run, /chmod 0444/); + assert.match( + verifyStep.run, + /SEALED_ASSET_PARENT="\/var\/lib\/iptvnator-snap-release"/ + ); + assert.match( + verifyStep.run, + /sudo mv "\$\{VERIFIED_ASSET_STAGING\}" "\$\{SEALED_ASSET_DIRECTORY\}"/ + ); + assert.ok(sealedVerifyStep); + assert.equal(sealedVerifyStep.env, undefined); + assert.match( + sealedVerifyStep.run, + /release-snap-assets\.cjs verify-sealed/ + ); + assert.ok( + verifyJob.steps.indexOf(sealedVerifyStep) < + verifyJob.steps.indexOf(transferStep) + ); + assert.equal( + transferStep.uses, + 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' + ); + assert.equal( + artifactDownloadStep.uses, + 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093' + ); + assert.ok( + publishJob.steps.indexOf(artifactDownloadStep) < + publishJob.steps.indexOf(transferredSealStep) + ); + assert.ok( + publishJob.steps.indexOf(transferredSealStep) < + publishJob.steps.indexOf(snapcraftStep) + ); + assert.ok( + publishJob.steps.indexOf(snapcraftStep) < + publishJob.steps.indexOf(uploadStep) + ); + assert.match( + uploadStep.run, + /VERIFIED_ASSET_DIRECTORY="\/var\/lib\/iptvnator-snap-release\/assets"/ + ); + assert.doesNotMatch(uploadStep.run, /\bnode\b/); + assert.doesNotMatch(uploadStep.run, /release-snap-assets\.cjs/); + assert.doesNotMatch(uploadStep.run, /\bfind\b|\bsort\b/); + assert.match( + uploadStep.run, + /SNAP_FILES=\("\$\{VERIFIED_ASSET_DIRECTORY\}"\/\*\.snap\)/ + ); + assert.match( + uploadStep.run, + /SNAPCRAFT_STORE_CREDENTIALS="\$\{STORE_CREDENTIALS\}" \/snap\/bin\/snapcraft upload/ + ); + assert.doesNotMatch( + uploadStep.run, + /snap-release-downloads\/\$\{SNAP_NAME\}/ + ); + assert.equal( + Object.hasOwn(publishJob.env ?? {}, 'SNAPCRAFT_STORE_CREDENTIALS'), + false + ); + assert.equal(Object.hasOwn(publishJob.env ?? {}, 'GH_TOKEN'), false); + assert.deepEqual(selectStep.env, { + GH_TOKEN: '${{ github.token }}', + }); + assert.deepEqual(downloadStep.env, { + GH_TOKEN: '${{ github.token }}', + }); + assert.equal(verifyStep.env, undefined); + assert.deepEqual(uploadStep.env, { + SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}', + }); +}); diff --git a/tools/packaging/release-snap-source-binding.cjs b/tools/packaging/release-snap-source-binding.cjs new file mode 100644 index 000000000..966df433b --- /dev/null +++ b/tools/packaging/release-snap-source-binding.cjs @@ -0,0 +1,1765 @@ +'use strict'; + +const childProcess = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { + validateLinuxRuntimeManifest, +} = require('../embedded-mpv/linux-runtime-manifest.cjs'); +const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs'); +const { + SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + SOURCE_ARCHIVE_NAME, + sha256File, + validateLinuxSourceArchiveBinding, +} = require('../embedded-mpv/linux-source-archive-contract.cjs'); +const { + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + inventoryLinuxRuntimeSourceSnapshot, + validateLinuxRuntimeSourceSnapshot, +} = require('./prepare-linux-runtime-source-snapshot.cjs'); + +const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 16 * 1024 * 1024; +const SQUASHFS_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024; +const SOURCE_MEMBER_MAX_BUFFER_BYTES = 128 * 1024 * 1024; +const COMMAND_TIMEOUT_MS = 120_000; +const SOURCE_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; +const SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const SNAP_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024; +const SNAP_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024; +const SOURCE_INDEX_SCHEMA_VERSION = 3; +const SNAP_PAYLOAD_ENTRY_LIMIT = 250_000; +const SOURCE_ARCHIVE_ENTRY_LIMIT = 250_000; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +const FRAME_COPY_MANIFEST_NAME = 'embedded-mpv-runtime.json'; +const FRAME_COPY_UNAVAILABLE_MARKER_NAME = 'embedded-mpv-unavailable.txt'; +const SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION = 1; +const SNAP_RELEASE_BOUNDARY_HELPER_PATH = path.join( + __dirname, + 'validate-snap-release-boundary.mjs' +); +const NATIVE_PAYLOAD_SUFFIX = [ + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native', +].join('/'); +const SOURCE_TOOLING_FILES = Object.freeze([ + { + archivePath: 'tooling/build-linux-runtime.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'build-linux-runtime.cjs' + ), + }, + { + archivePath: 'tooling/build-linux-runtime.mjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'build-linux-runtime.mjs' + ), + }, + { + archivePath: 'tooling/generate-linux-runtime-notices.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'generate-linux-runtime-notices.cjs' + ), + }, + { + archivePath: 'tooling/linux-runtime-manifest.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'linux-runtime-manifest.cjs' + ), + }, + { + archivePath: 'tooling/linux-source-archive-contract.cjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'linux-source-archive-contract.cjs' + ), + }, + { + archivePath: 'tooling/stage-runtime.mjs', + checkoutPath: path.join( + __dirname, + '..', + 'embedded-mpv', + 'stage-runtime.mjs' + ), + }, + { + archivePath: 'tooling/prepare-linux-runtime-source-snapshot.cjs', + checkoutPath: path.join( + __dirname, + 'prepare-linux-runtime-source-snapshot.cjs' + ), + }, +]); + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function commandFailureDetails(result) { + const stderr = Buffer.isBuffer(result?.stderr) + ? result.stderr.toString('utf8') + : String(result?.stderr ?? ''); + return stderr.trim().slice(0, 2048); +} + +function defaultRunCommand( + command, + args, + { + encoding = 'utf8', + input, + maxBuffer = COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout = COMMAND_TIMEOUT_MS, + } = {} +) { + const spawnOptions = { + killSignal: 'SIGKILL', + maxBuffer, + stdio: 'pipe', + timeout, + windowsHide: true, + }; + if (input !== undefined) { + spawnOptions.input = input; + } + if (encoding !== null) { + spawnOptions.encoding = encoding; + } + const result = childProcess.spawnSync(command, args, spawnOptions); + if (result.error) { + throw new Error( + `Unable to run ${command}: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }` + ); + } + if (result.status !== 0) { + const details = commandFailureDetails(result); + throw new Error( + `${command} exited with status ${String(result.status)}${ + details ? `: ${details}` : '.' + }` + ); + } + return result.stdout ?? (encoding === null ? Buffer.alloc(0) : ''); +} + +function normalizeTarMember(rawName) { + if ( + typeof rawName !== 'string' || + rawName.length === 0 || + rawName.includes('\\') || + [...rawName].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error('Source archive contains an unsafe member name.'); + } + let memberName = rawName; + while (memberName.startsWith('./')) { + memberName = memberName.slice(2); + } + const isDirectory = memberName.endsWith('/'); + if (isDirectory) { + memberName = memberName.slice(0, -1); + } + if (memberName === '') { + return null; + } + const parts = memberName.split('/'); + if ( + path.posix.isAbsolute(memberName) || + parts.some( + (part) => + part === '' || + part === '.' || + part === '..' || + !SAFE_BASENAME_PATTERN.test(part) + ) + ) { + throw new Error( + `Source archive contains an unsafe member name: ${rawName}` + ); + } + return { + isDirectory, + name: parts.join('/'), + rawName, + }; +} + +function parseVerboseTarMembers(listing) { + if (typeof listing !== 'string') { + throw new Error('Unable to read source archive member types.'); + } + const memberTypes = new Map(); + for (const line of listing.split(/\r?\n/).filter(Boolean)) { + let type = line[0]; + if (!['-', 'd', 'h', 'l'].includes(type)) { + throw new Error( + 'Source archive contains an unsupported member type.' + ); + } + let identity = line.trimEnd(); + let linkTarget = null; + for (const marker of [' -> ', ' link to ']) { + const markerIndex = identity.indexOf(marker); + if (markerIndex !== -1) { + linkTarget = identity.slice(markerIndex + marker.length); + identity = identity.slice(0, markerIndex); + if (marker === ' link to ') { + type = 'h'; + } + break; + } + } + const trimmedIdentity = identity.trim(); + const rawName = trimmedIdentity.split(/\s+/).at(-1); + const rawNameOffset = trimmedIdentity.lastIndexOf(rawName); + const metadataTokens = trimmedIdentity + .slice(0, rawNameOffset) + .trim() + .split(/\s+/); + const dateTokenIndex = metadataTokens.findIndex( + (token) => + /^\d{4}-\d{2}-\d{2}$/.test(token) || + /^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/.test( + token + ) + ); + const sizeToken = + dateTokenIndex > 0 ? metadataTokens[dateTokenIndex - 1] : ''; + const size = Number(sizeToken); + if ( + !/^\d+$/.test(sizeToken) || + !Number.isSafeInteger(size) || + size < 0 + ) { + throw new Error( + 'Unable to read a bounded source archive member size.' + ); + } + const member = normalizeTarMember(rawName); + if (!member) { + continue; + } + if (memberTypes.has(member.name)) { + throw new Error( + `Source archive contains duplicate verbose member: ${member.name}` + ); + } + if (member.isDirectory !== (type === 'd')) { + throw new Error( + `Source archive member type does not match its name: ${member.name}` + ); + } + if (type === 'l' || type === 'h') { + if ( + typeof linkTarget !== 'string' || + linkTarget.length === 0 || + linkTarget.includes('\\') || + path.posix.isAbsolute(linkTarget) || + [...linkTarget].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error( + `Source archive contains an unsafe link target: ${member.name}` + ); + } + const resolvedTarget = + type === 'h' + ? path.posix.normalize(linkTarget) + : path.posix.normalize( + path.posix.join( + path.posix.dirname(member.name), + linkTarget + ) + ); + if (resolvedTarget === '..' || resolvedTarget.startsWith('../')) { + throw new Error( + `Source archive link target escapes its root: ${member.name}` + ); + } + linkTarget = resolvedTarget.replace(/^\.\/+/, ''); + } else if (linkTarget !== null) { + throw new Error( + `Source archive regular member has link metadata: ${member.name}` + ); + } + memberTypes.set(member.name, { linkTarget, size, type }); + } + for (const [memberName, member] of memberTypes) { + if ( + member.type === 'h' && + memberTypes.get(member.linkTarget)?.type !== '-' + ) { + throw new Error( + `Source archive hardlink target must be a regular member: ${memberName}` + ); + } + } + return memberTypes; +} + +function listTarMembers(archivePath, runCommand) { + const listing = runCommand( + 'tar', + ['--list', '--ignore-zeros', '--xz', '--file', archivePath], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ); + if (typeof listing !== 'string') { + throw new Error('Unable to read source archive member listing.'); + } + const verboseMembers = parseVerboseTarMembers( + runCommand( + 'tar', + [ + '--list', + '--verbose', + '--ignore-zeros', + '--xz', + '--file', + archivePath, + ], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ) + ); + const members = new Map(); + let extractedBytes = 0; + let entryCount = 0; + for (const rawName of listing.split(/\r?\n/).filter(Boolean)) { + const member = normalizeTarMember(rawName); + if (!member) { + continue; + } + if (members.has(member.name)) { + throw new Error( + `Source archive contains duplicate member: ${member.name}` + ); + } + const verboseMember = verboseMembers.get(member.name); + if (!verboseMember) { + throw new Error( + `Source archive member type is missing: ${member.name}` + ); + } + entryCount += 1; + if (entryCount > SOURCE_ARCHIVE_ENTRY_LIMIT) { + throw new Error( + 'Source archive exceeds the release-verifier entry limit.' + ); + } + if (verboseMember.type === '-') { + if (verboseMember.size > SOURCE_MEMBER_MAX_BUFFER_BYTES) { + throw new Error( + `Source archive member exceeds the release-verifier size limit: ${member.name}` + ); + } + extractedBytes += verboseMember.size; + if ( + !Number.isSafeInteger(extractedBytes) || + extractedBytes > SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES + ) { + throw new Error( + 'Source archive exceeds the release-verifier extracted-size limit.' + ); + } + } + members.set(member.name, { + ...member, + ...verboseMember, + }); + } + if (verboseMembers.size !== members.size) { + throw new Error( + 'Source archive member and type listings do not match.' + ); + } + return members; +} + +function readTarMember( + archivePath, + members, + memberName, + runCommand, + maxBuffer +) { + const member = members.get(memberName); + if (!member || member.type !== '-') { + throw new Error( + `Source archive required member must be a regular file: ${memberName}` + ); + } + const contents = runCommand( + 'tar', + [ + '--extract', + '--xz', + '--to-stdout', + '--file', + archivePath, + '--', + member.rawName, + ], + { + encoding: null, + maxBuffer, + } + ); + const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents); + if (buffer.length !== member.size) { + throw new Error( + `Source archive member size changed during inspection: ${memberName}` + ); + } + return buffer; +} + +function parseJsonMember(contents, memberName) { + try { + return JSON.parse(contents.toString('utf8')); + } catch { + throw new Error( + `Source archive member is not valid JSON: ${memberName}` + ); + } +} + +function sourceMemberRecord( + archivePath, + members, + memberName, + runCommand, + relativePath +) { + const contents = readTarMember( + archivePath, + members, + memberName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + return { + path: relativePath, + sha256: crypto.createHash('sha256').update(contents).digest('hex'), + size: contents.length, + }; +} + +function inspectLegalFiles( + archivePath, + members, + runCommand, + rootName, + rootFiles +) { + const rootPrefix = `${rootName}/`; + const licensePrefix = `${rootPrefix}licenses/`; + const allowedRootFiles = new Set( + rootFiles.map((name) => `${rootPrefix}${name}`) + ); + const records = []; + for (const member of members.values()) { + if (member.isDirectory || !member.name.startsWith(rootPrefix)) { + continue; + } + if ( + !member.name.startsWith(licensePrefix) && + !allowedRootFiles.has(member.name) + ) { + throw new Error( + `Source archive contains an undeclared ${rootName} file: ${member.name}` + ); + } + if (member.name.startsWith(licensePrefix)) { + records.push( + sourceMemberRecord( + archivePath, + members, + member.name, + runCommand, + member.name.slice(rootPrefix.length) + ) + ); + } + } + return records.sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); +} + +function inspectLibplaceboSourceSnapshot( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 +) { + const sourceRootName = 'git/libplacebo'; + const sourceRootMember = members.get(sourceRootName); + if (!sourceRootMember || sourceRootMember.type !== 'd') { + throw new Error( + 'Source archive must contain a regular libplacebo source directory.' + ); + } + const sourceMembers = [...members.values()].filter( + ({ name }) => + name === sourceRootName || name.startsWith(`${sourceRootName}/`) + ); + if (sourceMembers.length <= 1) { + throw new Error( + 'Source archive must contain the prepared libplacebo source snapshot.' + ); + } + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-libplacebo-source-verifier-') + ); + try { + const memberListPath = path.join( + temporaryRoot, + 'libplacebo-members.txt' + ); + fs.writeFileSync( + memberListPath, + `${sourceMembers.map(({ rawName }) => rawName).join('\n')}\n`, + { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + } + ); + runCommand( + 'tar', + [ + '--extract', + '--xz', + '--file', + archivePath, + '--directory', + temporaryRoot, + '--no-same-owner', + '--no-same-permissions', + '--no-recursion', + '-T', + memberListPath, + ], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + } + ); + const sourceSnapshot = inventoryLinuxRuntimeSourceSnapshot( + path.join(temporaryRoot, 'git', 'libplacebo') + ); + return validateLinuxRuntimeSourceSnapshot(sourceSnapshot, { + expectedSha256: expectedSourceSnapshotSha256, + }); + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function inspectSourceCompliance( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 +) { + if ([...members.keys()].some((name) => name.split('/').includes('.git'))) { + throw new Error( + 'Source archive must not contain VCS metadata directories.' + ); + } + const expectedToolingNames = SOURCE_TOOLING_FILES.map( + ({ archivePath: memberName }) => memberName + ).sort(); + const actualToolingNames = [...members.values()] + .filter( + ({ isDirectory, name }) => + !isDirectory && name.startsWith('tooling/') + ) + .map(({ name }) => name) + .sort(); + if (!isDeepStrictEqual(actualToolingNames, expectedToolingNames)) { + throw new Error( + 'Source archive must contain the exact released runtime tooling set.' + ); + } + for (const toolingFile of SOURCE_TOOLING_FILES) { + const archivedContents = readTarMember( + archivePath, + members, + toolingFile.archivePath, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + const checkoutContents = fs.readFileSync(toolingFile.checkoutPath); + if (!archivedContents.equals(checkoutContents)) { + throw new Error( + `Source archive tooling does not match the released tag: ${toolingFile.archivePath}` + ); + } + } + const libplaceboSourceSnapshot = inspectLibplaceboSourceSnapshot( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 + ); + const licenseInputManifestName = + 'license-inputs/linux-runtime-license-inputs.json'; + const noticeManifestName = 'notices/embedded-mpv-notices.json'; + const noticeFileName = 'notices/THIRD_PARTY_NOTICES.txt'; + const licenseInputs = parseJsonMember( + readTarMember( + archivePath, + members, + licenseInputManifestName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ), + licenseInputManifestName + ); + const notices = parseJsonMember( + readTarMember( + archivePath, + members, + noticeManifestName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ), + noticeManifestName + ); + const noticeFile = sourceMemberRecord( + archivePath, + members, + noticeFileName, + runCommand, + 'THIRD_PARTY_NOTICES.txt' + ); + return { + libplaceboSourceSnapshot, + licenseInputFiles: inspectLegalFiles( + archivePath, + members, + runCommand, + 'license-inputs', + ['linux-runtime-license-inputs.json'] + ), + licenseInputs, + noticeFile, + noticeLicenseFiles: inspectLegalFiles( + archivePath, + members, + runCommand, + 'notices', + ['embedded-mpv-notices.json', 'THIRD_PARTY_NOTICES.txt'] + ), + notices, + toolingValidated: true, + }; +} + +function parseArchiveChecksumRecords(contents) { + const text = contents.toString('utf8'); + if (!text.endsWith('\n') || text.includes('\r')) { + throw new Error( + 'Source archive checksum manifest must use canonical LF-terminated sha256sum records.' + ); + } + const lines = text.slice(0, -1).split('\n'); + const records = lines.map((line) => { + const match = /^([a-f0-9]{64}) {2}([A-Za-z0-9_+.-]+)$/.exec(line); + if (!match) { + throw new Error( + 'Source archive checksum manifest contains an invalid record.' + ); + } + return { + name: match[2], + sha256: match[1], + }; + }); + return normalizeArchiveRecords(records, 'Source archive checksum manifest'); +} + +function assertExactSourceArchiveLayout(members, archiveFiles, compliance) { + const expectedFiles = new Set([ + ...archiveFiles.map(({ name }) => `archives/${name}`), + ...SOURCE_TOOLING_FILES.map(({ archivePath }) => archivePath), + 'license-inputs/linux-runtime-license-inputs.json', + ...compliance.licenseInputFiles.map( + ({ path: relativePath }) => `license-inputs/${relativePath}` + ), + 'metadata/archive-sha256.txt', + 'metadata/iptvnator-git-revision.txt', + 'metadata/local-changes.patch', + 'metadata/runtime-manifest.json', + 'metadata/source-index.json', + 'notices/THIRD_PARTY_NOTICES.txt', + 'notices/embedded-mpv-notices.json', + ...compliance.noticeLicenseFiles.map( + ({ path: relativePath }) => `notices/${relativePath}` + ), + ]); + const expectedDirectories = new Set(['git']); + for (const fileName of expectedFiles) { + const parts = fileName.split('/'); + parts.pop(); + while (parts.length > 0) { + expectedDirectories.add(parts.join('/')); + parts.pop(); + } + } + + const sourceRootName = 'git/libplacebo'; + for (const member of members.values()) { + if ( + member.name === sourceRootName || + member.name.startsWith(`${sourceRootName}/`) + ) { + if ( + member.type === 'h' && + !member.linkTarget.startsWith(`${sourceRootName}/`) + ) { + throw new Error( + `Source archive hardlink leaves the libplacebo snapshot: ${member.name}` + ); + } + continue; + } + if (expectedFiles.has(member.name)) { + if (member.type !== '-') { + throw new Error( + `Canonical source archive file must be regular: ${member.name}` + ); + } + continue; + } + if (expectedDirectories.has(member.name)) { + if (member.type !== 'd') { + throw new Error( + `Canonical source archive directory has the wrong type: ${member.name}` + ); + } + continue; + } + throw new Error( + `Source archive contains an undeclared source archive member: ${member.name}` + ); + } + + for (const fileName of expectedFiles) { + if (members.get(fileName)?.type !== '-') { + throw new Error( + `Source archive is missing canonical regular file: ${fileName}` + ); + } + } + for (const directoryName of expectedDirectories) { + if (members.get(directoryName)?.type !== 'd') { + throw new Error( + `Source archive is missing canonical directory: ${directoryName}` + ); + } + } +} + +function inspectSourceArchive( + archivePath, + { + expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + runCommand = defaultRunCommand, + } = {} +) { + const archiveStat = fs.lstatSync(archivePath); + if ( + !archiveStat.isFile() || + archiveStat.isSymbolicLink() || + archiveStat.size === 0 || + archiveStat.size > SOURCE_ARCHIVE_MAX_BYTES + ) { + throw new Error( + 'Linux source archive must be a bounded non-empty regular file.' + ); + } + const members = listTarMembers(archivePath, runCommand); + const readMetadata = (memberName) => + readTarMember( + archivePath, + members, + memberName, + runCommand, + COMMAND_OUTPUT_MAX_BUFFER_BYTES + ); + const sourceRuntime = parseJsonMember( + readMetadata('metadata/runtime-manifest.json'), + 'metadata/runtime-manifest.json' + ); + const sourceIndex = parseJsonMember( + readMetadata('metadata/source-index.json'), + 'metadata/source-index.json' + ); + const repositoryRevision = readMetadata( + 'metadata/iptvnator-git-revision.txt' + ) + .toString('utf8') + .trim(); + const localChanges = readMetadata('metadata/local-changes.patch'); + const archiveMemberNames = [...members.values()] + .filter( + ({ isDirectory, name }) => + !isDirectory && + name.startsWith('archives/') && + name.split('/').length === 2 + ) + .map(({ name }) => name.slice('archives/'.length)) + .sort(); + const archiveFiles = archiveMemberNames.map((name) => ({ + name, + sha256: crypto + .createHash('sha256') + .update( + readTarMember( + archivePath, + members, + `archives/${name}`, + runCommand, + SOURCE_MEMBER_MAX_BUFFER_BYTES + ) + ) + .digest('hex'), + })); + const archiveChecksums = parseArchiveChecksumRecords( + readMetadata('metadata/archive-sha256.txt') + ); + if ( + !isDeepStrictEqual( + archiveChecksums, + normalizeArchiveRecords( + archiveFiles, + 'Source archive inspected archives' + ) + ) + ) { + throw new Error( + 'Source archive checksum manifest does not match the inspected archives.' + ); + } + const compliance = inspectSourceCompliance( + archivePath, + members, + runCommand, + expectedSourceSnapshotSha256 + ); + assertExactSourceArchiveLayout(members, archiveFiles, compliance); + return { + archiveSha256: sha256File(archivePath), + archiveFiles, + compliance, + localChanges, + repositoryRevision, + sourceIndex, + sourceRuntime, + }; +} + +function readElfArchitecture(binaryPath) { + const descriptor = fs.openSync(binaryPath, 'r'); + try { + const header = Buffer.alloc(20); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== header.length || + header[0] !== 0x7f || + header[1] !== 0x45 || + header[2] !== 0x4c || + header[3] !== 0x46 || + ![1, 2].includes(header[4]) || + header[5] !== 1 + ) { + throw new Error('Snap Electron executable is not a supported ELF.'); + } + const machine = header.readUInt16LE(18); + if (machine === 62 && header[4] === 2) { + return 'x64'; + } + if (machine === 183 && header[4] === 2) { + return 'arm64'; + } + if (machine === 40 && header[4] === 1) { + return 'arm'; + } + throw new Error( + `Snap Electron executable uses unsupported ELF machine ${machine}.` + ); + } finally { + fs.closeSync(descriptor); + } +} + +function collectSnapNativePayloads(extractionRoot) { + const candidates = []; + const pendingDirectories = [extractionRoot]; + let entryCount = 0; + while (pendingDirectories.length > 0) { + const directoryPath = pendingDirectories.pop(); + const entries = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ); + for (const entry of entries) { + entryCount += 1; + if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) { + throw new Error( + 'Extracted Snap exceeds the release-verifier entry limit.' + ); + } + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) { + pendingDirectories.push(entryPath); + continue; + } + if ( + !entry.isFile() || + ![ + FRAME_COPY_MANIFEST_NAME, + FRAME_COPY_UNAVAILABLE_MARKER_NAME, + ].includes(entry.name) + ) { + continue; + } + const relativePath = path + .relative(extractionRoot, entryPath) + .split(path.sep) + .join('/'); + const expectedSuffix = `${NATIVE_PAYLOAD_SUFFIX}/${entry.name}`; + if ( + relativePath === expectedSuffix || + relativePath.endsWith(`/${expectedSuffix}`) + ) { + candidates.push(entryPath); + } + } + } + return candidates; +} + +function inspectSquashfsListing(snapPath, runCommand) { + const listing = runCommand('unsquashfs', ['-lln', snapPath], { + encoding: 'utf8', + maxBuffer: SQUASHFS_LIST_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + }); + if (typeof listing !== 'string') { + throw new Error('Unable to inspect the Snap filesystem listing.'); + } + let entryCount = 0; + let extractedBytes = 0; + for (const line of listing.split(/\r?\n/).filter(Boolean)) { + const match = + /^([bcdlps-])\S*\s+\d+\/\d+\s+(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+.+$/.exec( + line + ); + if (!match || !['-', 'd', 'l'].includes(match[1])) { + throw new Error( + 'Snap filesystem listing contains an invalid entry.' + ); + } + entryCount += 1; + if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) { + throw new Error('Snap exceeds the release-verifier entry limit.'); + } + if (match[1] === '-') { + extractedBytes += Number(match[2]); + if ( + !Number.isSafeInteger(extractedBytes) || + extractedBytes > SNAP_EXTRACTED_MAX_BYTES + ) { + throw new Error( + 'Snap exceeds the release-verifier extracted-size limit.' + ); + } + } + } + if (entryCount === 0) { + throw new Error('Snap filesystem listing must not be empty.'); + } +} + +function validateSnapReleaseBoundary(extractionRoot) { + const output = defaultRunCommand( + process.execPath, + [SNAP_RELEASE_BOUNDARY_HELPER_PATH, extractionRoot], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + } + ); + if (typeof output !== 'string' || !/^[^\r\n]+\n$/.test(output)) { + throw new Error( + 'Snap release-boundary validator must emit exactly one newline-terminated JSON line.' + ); + } + let payload; + try { + payload = JSON.parse(output.slice(0, -1)); + } catch { + throw new Error( + 'Snap release-boundary validator emitted malformed JSON.' + ); + } + if ( + !isObject(payload) || + !isDeepStrictEqual(Object.keys(payload).sort(), [ + 'errors', + 'schemaVersion', + ]) || + payload.schemaVersion !== SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION || + !Array.isArray(payload.errors) || + payload.errors.some( + (error) => + typeof error !== 'string' || + error.length === 0 || + error.length > 4096 + ) + ) { + throw new Error( + 'Snap release-boundary validator emitted an invalid result.' + ); + } + return payload.errors; +} + +function inspectSnapPayload( + snapPath, + asset, + { + runCommand = defaultRunCommand, + validatePackagedEmbeddedMpv: + validatePackaged = validatePackagedEmbeddedMpv, + } = {} +) { + const snapStat = fs.lstatSync(snapPath); + if ( + !snapStat.isFile() || + snapStat.isSymbolicLink() || + snapStat.size === 0 || + snapStat.size > SNAP_ARCHIVE_MAX_BYTES + ) { + throw new Error( + `Snap ${asset.name} must be a bounded non-empty regular file.` + ); + } + inspectSquashfsListing(snapPath, runCommand); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-source-verifier-') + ); + try { + const extractionRoot = path.join(temporaryRoot, 'payload'); + runCommand( + 'unsquashfs', + ['-no-progress', '-dest', extractionRoot, snapPath], + { + encoding: 'utf8', + maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES, + timeout: COMMAND_TIMEOUT_MS, + } + ); + const payloads = collectSnapNativePayloads(extractionRoot); + const canonicalNativeDirectory = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const canonicalPayloads = [ + FRAME_COPY_MANIFEST_NAME, + FRAME_COPY_UNAVAILABLE_MARKER_NAME, + ] + .map((name) => path.join(canonicalNativeDirectory, name)) + .filter((candidate) => payloads.includes(candidate)); + if ( + payloads.length !== 1 || + canonicalPayloads.length !== 1 || + payloads[0] !== canonicalPayloads[0] + ) { + throw new Error( + `Snap ${asset.name} must contain exactly one canonical frame-copy manifest or unavailable marker.` + ); + } + const boundaryErrors = validateSnapReleaseBoundary(extractionRoot); + if (boundaryErrors.length > 0) { + throw new Error( + `Snap ${asset.name} failed public-release boundary validation: ${boundaryErrors.join( + '; ' + )}` + ); + } + const payloadPath = canonicalPayloads[0]; + const resourcesDirectory = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources' + ); + const electronPath = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'iptvnator.bin' + ); + const electronStat = fs.lstatSync(electronPath); + if ( + !electronStat.isFile() || + electronStat.isSymbolicLink() || + electronStat.size < 20 + ) { + throw new Error( + `Snap ${asset.name} is missing its regular Electron executable.` + ); + } + const architecture = readElfArchitecture(electronPath); + if (path.basename(payloadPath) === FRAME_COPY_MANIFEST_NAME) { + const manifestStat = fs.lstatSync(payloadPath); + if ( + !manifestStat.isFile() || + manifestStat.isSymbolicLink() || + manifestStat.size === 0 || + manifestStat.size > COMMAND_OUTPUT_MAX_BUFFER_BYTES + ) { + throw new Error( + `Snap ${asset.name} contains an invalid frame-copy manifest.` + ); + } + } + const staticErrors = validatePackaged(resourcesDirectory, { + artifactFormat: 'snap', + executableName: 'iptvnator', + foreignArch: architecture !== 'x64', + hostPlatform: 'linux', + platform: 'linux', + profile: 'portable', + required: true, + targetArch: architecture, + targetNames: ['appimage', 'snap'], + }); + if (!Array.isArray(staticErrors) || staticErrors.length > 0) { + throw new Error( + `Snap ${asset.name} failed static frame-copy validation${ + Array.isArray(staticErrors) && staticErrors.length > 0 + ? `: ${staticErrors.join('; ')}` + : '.' + }` + ); + } + if (path.basename(payloadPath) === FRAME_COPY_UNAVAILABLE_MARKER_NAME) { + return { + architecture, + assetName: asset.name, + manifest: null, + markerOnly: true, + }; + } + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(payloadPath, 'utf8')); + } catch { + throw new Error( + `Snap ${asset.name} contains malformed frame-copy manifest JSON.` + ); + } + return { + architecture, + assetName: asset.name, + manifest, + markerOnly: false, + }; + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function normalizeArchiveRecords(records, label) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error(`${label} must contain source archive checksums.`); + } + const normalized = records.map((record) => { + if ( + !isObject(record) || + !isDeepStrictEqual(Object.keys(record).sort(), [ + 'name', + 'sha256', + ]) || + typeof record.name !== 'string' || + !SAFE_BASENAME_PATTERN.test(record.name) || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error(`${label} contains an invalid archive record.`); + } + return { + name: record.name, + sha256: record.sha256, + }; + }); + const names = normalized.map(({ name }) => name); + const hashes = normalized.map(({ sha256 }) => sha256); + if ( + new Set(names).size !== names.length || + new Set(hashes).size !== hashes.length + ) { + throw new Error(`${label} contains duplicate archive records.`); + } + return normalized.sort(({ name: left }, { name: right }) => + left.localeCompare(right) + ); +} + +function hasExactFields(value, fields) { + return ( + isObject(value) && + isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()) + ); +} + +function safeRelativePath(value) { + return ( + typeof value === 'string' && + value.length > 0 && + !value.includes('\\') && + !path.posix.isAbsolute(value) && + value + .split('/') + .every( + (part) => + part !== '' && + part !== '.' && + part !== '..' && + SAFE_BASENAME_PATTERN.test(part) + ) + ); +} + +function normalizeLegalFileRecords(records, label) { + if (!Array.isArray(records) || records.length === 0) { + throw new Error(`${label} must contain legal files.`); + } + const normalized = records.map((record) => { + if ( + !hasExactFields(record, ['path', 'sha256', 'size']) || + !safeRelativePath(record.path) || + !record.path.startsWith('licenses/') || + !Number.isSafeInteger(record.size) || + record.size <= 0 || + typeof record.sha256 !== 'string' || + !SHA256_PATTERN.test(record.sha256) + ) { + throw new Error(`${label} contains an invalid legal file.`); + } + return { ...record }; + }); + const paths = normalized.map(({ path: filePath }) => filePath); + if (new Set(paths).size !== paths.length) { + throw new Error(`${label} contains duplicate legal files.`); + } + return normalized.sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); +} + +function sourcePackageLegalIdentity(sourcePackage) { + return Object.fromEntries( + [ + 'version', + 'sourceUrl', + 'sourceTag', + 'sourceSha256', + 'sourceGitCommit', + 'license', + ] + .filter((field) => Object.hasOwn(sourcePackage, field)) + .map((field) => [field, sourcePackage[field]]) + ); +} + +function verifySourceArchiveCompliance({ + compliance, + sourceIndex, + sourceRuntime, +}) { + if ( + !hasExactFields(compliance, [ + 'libplaceboSourceSnapshot', + 'licenseInputFiles', + 'licenseInputs', + 'noticeFile', + 'noticeLicenseFiles', + 'notices', + 'toolingValidated', + ]) || + compliance.toolingValidated !== true + ) { + throw new Error('Source archive compliance payload is incomplete.'); + } + const { licenseInputs, notices } = compliance; + if ( + !hasExactFields(licenseInputs, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'packages', + ]) || + licenseInputs.schemaVersion !== 1 || + licenseInputs.origin !== 'pinned-linux-runtime-license-inputs' || + licenseInputs.platform !== 'linux' || + licenseInputs.arch !== 'x64' || + !Array.isArray(licenseInputs.packages) || + !hasExactFields(notices, [ + 'schemaVersion', + 'origin', + 'platform', + 'arch', + 'noticeFile', + 'packages', + 'totalBytes', + ]) || + notices.schemaVersion !== 1 || + notices.origin !== 'pinned-linux-runtime-upstream-licenses' || + notices.platform !== 'linux' || + notices.arch !== 'x64' || + !Array.isArray(notices.packages) + ) { + throw new Error('Source archive compliance manifests are invalid.'); + } + const runtimePackages = sourceRuntime.packages; + const runtimePackageIds = Object.keys(runtimePackages).sort(); + const inputPackages = new Map( + licenseInputs.packages.map((record) => [record?.id, record]) + ); + const noticePackages = new Map( + notices.packages.map((record) => [record?.id, record]) + ); + if ( + inputPackages.size !== licenseInputs.packages.length || + noticePackages.size !== notices.packages.length || + !isDeepStrictEqual( + [...inputPackages.keys()].sort(), + runtimePackageIds + ) || + !isDeepStrictEqual([...noticePackages.keys()].sort(), runtimePackageIds) + ) { + throw new Error( + 'Source archive legal package set does not match the runtime.' + ); + } + const declaredLegalFiles = []; + for (const packageId of runtimePackageIds) { + if (!SAFE_BASENAME_PATTERN.test(packageId)) { + throw new Error( + 'Source archive runtime contains an unsafe package id.' + ); + } + const identity = sourcePackageLegalIdentity(runtimePackages[packageId]); + const inputPackage = inputPackages.get(packageId); + const noticePackage = noticePackages.get(packageId); + if ( + !hasExactFields(inputPackage, [ + 'id', + ...Object.keys(identity), + 'files', + ]) || + !hasExactFields(noticePackage, [ + 'id', + ...Object.keys(identity), + 'files', + ]) || + !Object.entries(identity).every( + ([field, value]) => + isDeepStrictEqual(inputPackage[field], value) && + isDeepStrictEqual(noticePackage[field], value) + ) || + !Array.isArray(inputPackage.files) || + !Array.isArray(noticePackage.files) || + inputPackage.files.length === 0 || + inputPackage.files.length !== noticePackage.files.length + ) { + throw new Error( + `Source archive legal identity is invalid for ${packageId}.` + ); + } + const inputFiles = inputPackage.files + .map((record) => { + if ( + !hasExactFields(record, [ + 'sourcePath', + 'path', + 'size', + 'sha256', + ]) || + !safeRelativePath(record.sourcePath) || + record.path !== `licenses/${packageId}/${record.sourcePath}` + ) { + throw new Error( + `Source archive license input is invalid for ${packageId}.` + ); + } + return { + path: record.path, + sha256: record.sha256, + size: record.size, + }; + }) + .sort(({ path: left }, { path: right }) => + left.localeCompare(right) + ); + const noticeFiles = normalizeLegalFileRecords( + noticePackage.files, + `Source archive notices for ${packageId}` + ); + const normalizedInputFiles = normalizeLegalFileRecords( + inputFiles, + `Source archive license inputs for ${packageId}` + ); + if (!isDeepStrictEqual(normalizedInputFiles, noticeFiles)) { + throw new Error( + `Source archive legal files diverge for ${packageId}.` + ); + } + declaredLegalFiles.push(...noticeFiles); + } + const normalizedDeclaredFiles = normalizeLegalFileRecords( + declaredLegalFiles, + 'Source archive declared legal files' + ); + if ( + !isDeepStrictEqual( + normalizeLegalFileRecords( + compliance.licenseInputFiles, + 'Source archive license input contents' + ), + normalizedDeclaredFiles + ) || + !isDeepStrictEqual( + normalizeLegalFileRecords( + compliance.noticeLicenseFiles, + 'Source archive notice contents' + ), + normalizedDeclaredFiles + ) + ) { + throw new Error( + 'Source archive legal contents do not match their manifests.' + ); + } + if ( + !hasExactFields(compliance.noticeFile, ['path', 'sha256', 'size']) || + compliance.noticeFile.path !== 'THIRD_PARTY_NOTICES.txt' || + !Number.isSafeInteger(compliance.noticeFile.size) || + compliance.noticeFile.size <= 0 || + !SHA256_PATTERN.test(compliance.noticeFile.sha256) || + !isDeepStrictEqual(notices.noticeFile, compliance.noticeFile) || + notices.totalBytes !== + compliance.noticeFile.size + + normalizedDeclaredFiles.reduce( + (total, file) => total + file.size, + 0 + ) + ) { + throw new Error('Source archive aggregate notices are invalid.'); + } + if ( + !isDeepStrictEqual(sourceIndex.legal, { + manifest: 'notices/embedded-mpv-notices.json', + noticeFile: notices.noticeFile, + packages: notices.packages, + }) + ) { + throw new Error( + 'Source archive legal index does not match its notices.' + ); + } +} + +function verifySnapReleaseSourceBinding( + { expectedRepositoryRevision, sourceInspection, snapPayloads }, + { + expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, + validateRuntimeManifest = validateLinuxRuntimeManifest, + } = {} +) { + if ( + typeof expectedRepositoryRevision !== 'string' || + !GIT_COMMIT_PATTERN.test(expectedRepositoryRevision) + ) { + throw new Error( + 'Expected release repository revision must be a full Git commit.' + ); + } + if (!isObject(sourceInspection)) { + throw new Error('Source archive inspection is missing.'); + } + const { + archiveFiles, + archiveSha256, + compliance, + localChanges, + repositoryRevision, + sourceIndex, + sourceRuntime, + } = sourceInspection; + const runtimeErrors = validateRuntimeManifest(sourceRuntime); + if (!Array.isArray(runtimeErrors) || runtimeErrors.length > 0) { + throw new Error( + `Source archive runtime manifest is invalid${ + Array.isArray(runtimeErrors) && runtimeErrors.length > 0 + ? `: ${runtimeErrors.join('; ')}` + : '.' + }` + ); + } + if ( + repositoryRevision !== expectedRepositoryRevision || + !isObject(sourceIndex) || + sourceIndex.repositoryRevision !== expectedRepositoryRevision + ) { + throw new Error( + 'Source archive repository revision does not match the released tag.' + ); + } + const expectedSourceArchiveBinding = { + schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION, + name: SOURCE_ARCHIVE_NAME, + sha256: archiveSha256, + repositoryRevision, + }; + const sourceArchiveBindingErrors = validateLinuxSourceArchiveBinding( + expectedSourceArchiveBinding, + { + expectedRepositoryRevision, + expectedSha256: archiveSha256, + } + ); + if (sourceArchiveBindingErrors.length > 0) { + throw new Error( + `Source archive byte binding is invalid: ${sourceArchiveBindingErrors.join( + '; ' + )}` + ); + } + const localChangesLength = + typeof localChanges === 'string' + ? Buffer.byteLength(localChanges) + : localChanges instanceof Uint8Array + ? localChanges.byteLength + : -1; + if (localChangesLength !== 0) { + throw new Error( + 'Source archive must describe a clean released repository revision.' + ); + } + if ( + sourceIndex.schemaVersion !== SOURCE_INDEX_SCHEMA_VERSION || + !isDeepStrictEqual(Object.keys(sourceIndex).sort(), [ + 'archives', + 'legal', + 'libplacebo', + 'repositoryRevision', + 'schemaVersion', + 'sourcePackages', + ]) || + !isObject(sourceIndex.legal) || + !isDeepStrictEqual(sourceIndex.sourcePackages, sourceRuntime.packages) + ) { + throw new Error( + 'Source archive index does not match its runtime manifest.' + ); + } + verifySourceArchiveCompliance({ + compliance, + sourceIndex, + sourceRuntime, + }); + const libplaceboPackage = sourceRuntime.packages?.libplacebo; + const sourceSnapshot = validateLinuxRuntimeSourceSnapshot( + compliance.libplaceboSourceSnapshot, + { + expectedSha256: expectedSourceSnapshotSha256, + } + ); + const expectedLibplaceboRecord = { + sourceGitCommit: libplaceboPackage?.sourceGitCommit, + sourceSubmodules: [...EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES], + sourceSnapshot, + }; + if ( + !isDeepStrictEqual( + libplaceboPackage?.sourceSubmodules, + EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES + ) + ) { + throw new Error( + 'Source archive libplacebo submodule records do not match the pinned source identity.' + ); + } + if ( + !GIT_COMMIT_PATTERN.test( + expectedLibplaceboRecord.sourceGitCommit ?? '' + ) || + !isDeepStrictEqual(sourceIndex.libplacebo, expectedLibplaceboRecord) + ) { + throw new Error( + 'Source archive libplacebo identity does not match its runtime manifest.' + ); + } + const indexedArchives = normalizeArchiveRecords( + sourceIndex.archives, + 'Source archive index' + ); + const inspectedArchives = normalizeArchiveRecords( + archiveFiles, + 'Source archive contents' + ); + if (!isDeepStrictEqual(inspectedArchives, indexedArchives)) { + throw new Error( + 'Source archive checksums do not match its source index.' + ); + } + const expectedArchiveHashes = Object.values(sourceRuntime.packages ?? {}) + .filter( + (sourcePackage) => + isObject(sourcePackage) && + Object.hasOwn(sourcePackage, 'sourceSha256') + ) + .map((sourcePackage) => sourcePackage.sourceSha256) + .sort(); + const indexedArchiveHashes = indexedArchives + .map(({ sha256 }) => sha256) + .sort(); + if ( + expectedArchiveHashes.length === 0 || + expectedArchiveHashes.some( + (sha256) => + typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256) + ) || + new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || + !isDeepStrictEqual(indexedArchiveHashes, expectedArchiveHashes) + ) { + throw new Error( + 'Source archive checksums do not match the pinned runtime packages.' + ); + } + if (!Array.isArray(snapPayloads) || snapPayloads.length === 0) { + throw new Error('Every selected Snap must be inspected.'); + } + const assetNames = new Set(); + let x64SnapCount = 0; + for (const payload of snapPayloads) { + if ( + !isObject(payload) || + typeof payload.assetName !== 'string' || + payload.assetName.length === 0 || + assetNames.has(payload.assetName) + ) { + throw new Error('Selected Snap inspections must be unique.'); + } + assetNames.add(payload.assetName); + if (payload.architecture === 'x64') { + x64SnapCount += 1; + if (payload.markerOnly !== false || !isObject(payload.manifest)) { + throw new Error( + `x64 Snap ${payload.assetName} must contain the frame-copy runtime.` + ); + } + const manifest = payload.manifest; + if ( + manifest.platform !== 'linux' || + manifest.arch !== 'x64' || + manifest.profile !== 'portable' || + manifest.runtimeMode !== 'bundled' || + !Array.isArray(manifest.targets) || + !manifest.targets.includes('snap') || + !isDeepStrictEqual( + manifest.sourceArchive, + expectedSourceArchiveBinding + ) + ) { + throw new Error( + `x64 Snap ${payload.assetName} has an invalid frame-copy source archive binding.` + ); + } + if (!isDeepStrictEqual(manifest.sourceRuntime, sourceRuntime)) { + throw new Error( + `Snap source runtime does not match source archive: ${payload.assetName}` + ); + } + continue; + } + if ( + !['arm', 'arm64'].includes(payload.architecture) || + payload.markerOnly !== true || + payload.manifest !== null + ) { + throw new Error( + `Non-x64 Snap ${payload.assetName} must remain marker-only.` + ); + } + } + if (x64SnapCount !== 1) { + throw new Error( + `Public release must contain exactly one x64 Snap; received ${x64SnapCount}.` + ); + } + return true; +} + +module.exports = { + inspectSnapPayload, + inspectSourceArchive, + verifySnapReleaseSourceBinding, +}; diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs new file mode 100644 index 000000000..d69a48b89 --- /dev/null +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -0,0 +1,564 @@ +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([ + PINNED_CHECKOUT_ACTION, + PINNED_DOWNLOAD_ARTIFACT_ACTION, + PINNED_UPLOAD_ARTIFACT_ACTION, +]); +const BUILD_ACTION_ALLOWLIST = Object.freeze([ + 'actions/cache/restore@v4', + 'actions/cache/save@v4', + 'actions/cache@v4', + 'actions/checkout@v4', + 'actions/download-artifact@v4', + 'actions/setup-node@v4', + 'actions/upload-artifact@v4', + 'pnpm/action-setup@v4', + 'softprops/action-gh-release@v2', +]); +const VERIFY_JOB_ID = 'verify-snap'; +const PUBLISH_JOB_ID = 'publish-snap'; +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_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', + '', + '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'), +}); + +function stripShellComment(line) { + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quote === "'") { + if (character === quote) { + quote = null; + } + continue; + } + if (character === '\\') { + index += 1; + continue; + } + if (quote === '"') { + if (character === quote) { + quote = null; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ( + character === '#' && + (index === 0 || /[\s;|&()]/.test(line[index - 1])) + ) { + return line.slice(0, index); + } + } + return line; +} + +function isRecord(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeRunSource(runSource) { + return runSource + .split('\n') + .map(stripShellComment) + .join('\n') + .replace(/\\\r?\n[ \t]*/g, ''); +} + +function collectDefaultShell(container, containerName, explicitShells) { + if (!Object.hasOwn(container, 'defaults')) { + return; + } + assert.ok( + isRecord(container.defaults), + `${containerName} defaults must be a mapping` + ); + if (!Object.hasOwn(container.defaults, 'run')) { + return; + } + assert.ok( + isRecord(container.defaults.run), + `${containerName} run defaults must be a mapping` + ); + if (!Object.hasOwn(container.defaults.run, 'shell')) { + return; + } + assert.equal( + typeof container.defaults.run.shell, + 'string', + `${containerName} default shell must resolve to a string` + ); + explicitShells.push(container.defaults.run.shell); +} + +function collectWorkflowPolicyInputs(workflowText) { + const workflow = parse(workflowText); + assert.ok(isRecord(workflow), 'workflow must be a YAML mapping'); + assert.ok(isRecord(workflow.jobs), 'workflow jobs must be a YAML mapping'); + + const actions = []; + const explicitShells = []; + const jobActions = []; + const jobsWithoutSteps = []; + const runSources = []; + collectDefaultShell(workflow, 'workflow', explicitShells); + for (const [jobName, job] of Object.entries(workflow.jobs)) { + assert.ok(isRecord(job), `workflow job "${jobName}" must be a mapping`); + collectDefaultShell(job, `workflow job "${jobName}"`, explicitShells); + if (Object.hasOwn(job, 'uses')) { + assert.equal( + typeof job.uses, + 'string', + 'reusable workflow identifiers must resolve to strings' + ); + jobActions.push(job.uses); + } + if (!Array.isArray(job.steps)) { + jobsWithoutSteps.push(jobName); + continue; + } + for (const step of job.steps) { + assert.ok(isRecord(step), 'workflow steps must be mappings'); + if (Object.hasOwn(step, 'uses')) { + assert.equal( + typeof step.uses, + 'string', + 'workflow action identifiers must resolve to strings' + ); + actions.push(step.uses); + } + if (Object.hasOwn(step, 'run')) { + assert.equal( + typeof step.run, + 'string', + 'workflow run commands must resolve to strings' + ); + runSources.push(normalizeRunSource(step.run)); + } + if (Object.hasOwn(step, 'shell')) { + assert.equal( + typeof step.shell, + 'string', + 'workflow step shells must resolve to strings' + ); + explicitShells.push(step.shell); + } + } + } + + return { + actions, + commandSource: runSources.join('\n'), + explicitShells, + jobActions, + jobsWithoutSteps, + workflow, + }; +} + +function assertWorkflowExecutionShape({ + explicitShells, + jobActions, + jobsWithoutSteps, +}) { + assert.deepEqual( + jobActions, + [], + 'job-level reusable workflow delegation is not allowed' + ); + assert.deepEqual( + jobsWithoutSteps, + [], + 'every workflow job must define a concrete steps sequence' + ); + assert.deepEqual( + explicitShells.filter((shell) => shell !== 'bash'), + [], + 'every explicit workflow shell must be exactly "bash"' + ); +} + +function literalSnapcraftTokens(commandSource) { + return commandSource.match(/\bsnapcraft\b/g) ?? []; +} + +export function assertPublishSnapWorkflowPolicy(workflowText) { + const policyInputs = collectWorkflowPolicyInputs(workflowText); + const { actions, commandSource, workflow } = policyInputs; + assertWorkflowExecutionShape(policyInputs); + assert.deepEqual( + workflow.on, + { 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 verified-release condition' + ); + assert.deepEqual( + 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, + 2, + 'the publish workflow must contain exactly the reviewed install and upload Snapcraft commands' + ); +} + +export function assertBuildSnapWorkflowPolicy(workflowText) { + const policyInputs = collectWorkflowPolicyInputs(workflowText); + const { actions, commandSource } = policyInputs; + assertWorkflowExecutionShape(policyInputs); + assert.deepEqual( + actions.filter((action) => !BUILD_ACTION_ALLOWLIST.includes(action)), + [], + 'the build workflow must use only allowlisted actions' + ); + assert.equal( + literalSnapcraftTokens(commandSource).length, + 0, + 'the build workflow must not contain a literal Snapcraft token' + ); +} diff --git a/tools/packaging/validate-snap-release-boundary.mjs b/tools/packaging/validate-snap-release-boundary.mjs new file mode 100644 index 000000000..2a98df1c8 --- /dev/null +++ b/tools/packaging/validate-snap-release-boundary.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + collectEmbeddedMpvNativeArchiveEntries, + listAsarPackageEntries, +} from './asar-dependency-closure.mjs'; +import { validateExtractedSnapMetadata } from './verify-linux-frame-copy-runtime.mjs'; + +const scriptPath = fileURLToPath(import.meta.url); +const SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION = 1; + +export function validateExtractedSnapReleaseBoundary( + extractionRoot, + { asarListPackage = listAsarPackageEntries } = {} +) { + const errors = [...validateExtractedSnapMetadata(extractionRoot)]; + const asarPath = path.join( + extractionRoot, + 'usr', + 'lib', + 'iptvnator', + 'resources', + 'app.asar' + ); + let asarStat; + try { + asarStat = fs.lstatSync(asarPath); + } catch { + errors.push( + `Public-release Snap must contain its canonical app.asar: ${asarPath}` + ); + return errors; + } + if ( + !asarStat.isFile() || + asarStat.isSymbolicLink() || + asarStat.size === 0 + ) { + errors.push( + `Public-release Snap app.asar must be a non-empty regular file: ${asarPath}` + ); + return errors; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + asarListPackage(asarPath) + ); + } catch (error) { + errors.push( + `Unable to inspect public-release Snap app.asar at ${asarPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return errors; + } + if (nativeEntries.length > 0) { + errors.push( + `Public-release Snap app.asar must not contain embedded MPV native payloads: ${nativeEntries.join( + ', ' + )}` + ); + } + return errors; +} + +function main() { + const args = process.argv.slice(2); + if (args.length !== 1 || args[0].length === 0) { + throw new Error( + 'Usage: validate-snap-release-boundary.mjs ' + ); + } + const errors = validateExtractedSnapReleaseBoundary(path.resolve(args[0])); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION, + errors, + })}\n` + ); +} + +function isMainModule() { + if (!process.argv[1]) { + return false; + } + try { + return fs.realpathSync(process.argv[1]) === fs.realpathSync(scriptPath); + } catch { + return false; + } +} + +if (isMainModule()) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index 05a0deded..ab94e048c 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -3,15 +3,21 @@ import { createRequire } from 'module'; import path from 'path'; import { buildElectronBuilderMetadata } from './generate-electron-builder-metadata.mjs'; -import { inspectPackagedDependencyClosure } from './asar-dependency-closure.mjs'; +import { + collectEmbeddedMpvNativeArchiveEntries, + inspectPackagedDependencyClosure, +} from './asar-dependency-closure.mjs'; const require = createRequire(import.meta.url); const { extractFile, listPackage } = require('@electron/asar'); const { - getEmbeddedMpvAddonArch, linuxUnpackedDirArch, + resolveConfiguredLinuxTargetNames, validatePackagedEmbeddedMpv, } = require('./embedded-mpv-packaging.cjs'); +const { + validateLinuxProfileTargets, +} = require('./linux-frame-copy-profile.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -53,6 +59,8 @@ const snapConfigInspection = loadSnapConfigInspection(); const embeddedMpvRequired = isTruthy( process.env.IPTVNATOR_REQUIRE_EMBEDDED_MPV ); +const linuxFrameCopyProfile = + process.env.IPTVNATOR_LINUX_FRAME_COPY_PROFILE?.trim() || undefined; const workerRelativeDir = path.join( 'dist', 'apps', @@ -601,7 +609,9 @@ function verifyPackagedDependencyClosure(resourceDir, errors) { } const details = missing - .map((entry) => `- ${entry.dependency} (required by ${entry.requiredBy})`) + .map( + (entry) => `- ${entry.dependency} (required by ${entry.requiredBy})` + ) .join('\n'); errors.push( @@ -614,9 +624,37 @@ function verifyPackagedDependencyClosure(resourceDir, errors) { ); } -// The embedded MPV addon is built once per CI host (x64), but electron-builder -// emits arm64/armv7l Linux output directories from the same dist tree. Those -// must carry the unavailable marker instead of a foreign-architecture addon. +function verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fileExists(asarPath)) { + // Missing archive is already reported by verifyPackagedPackageMetadata. + return; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + listPackage(asarPath) + ); + } catch (error) { + errors.push( + `Unable to inspect embedded MPV archive ownership in ${asarPath}: ${error.message}` + ); + return; + } + + if (nativeEntries.length > 0) { + errors.push( + [ + `Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`, + ...nativeEntries.map((entry) => `- ${entry}`), + ].join('\n') + ); + } +} + +// Official Linux frame-copy support is x64-only. Every arm64/armv7l output +// must carry the unavailable marker instead of native frame-copy artifacts. function isForeignArchLinuxResourceDir(resourceDir) { if (platform !== 'linux') { return false; @@ -625,7 +663,7 @@ function isForeignArchLinuxResourceDir(resourceDir) { const dirArch = linuxUnpackedDirArch( path.basename(path.dirname(resourceDir)) ); - return Boolean(dirArch) && dirArch !== getEmbeddedMpvAddonArch(); + return Boolean(dirArch) && dirArch !== 'x64'; } function verifyResourceDir(resourceDir) { @@ -642,9 +680,11 @@ function verifyResourceDir(resourceDir) { ); const errors = []; + let linuxTargetNames; verifyPackagedPackageMetadata(resourceDir, errors); verifyPackagedDependencyClosure(resourceDir, errors); + verifyNoEmbeddedMpvNativeArchiveEntries(resourceDir, errors); if (missingWorkers.length > 0) { errors.push( @@ -663,6 +703,28 @@ function verifyResourceDir(resourceDir) { } if (platform === 'linux') { + const resourceArch = + linuxUnpackedDirArch(path.basename(path.dirname(resourceDir))) || + arch; + try { + linuxTargetNames = resolveConfiguredLinuxTargetNames( + electronBuilderConfig.linux?.target, + resourceArch + ); + if (linuxFrameCopyProfile) { + errors.push( + ...validateLinuxProfileTargets( + linuxFrameCopyProfile, + linuxTargetNames + ) + ); + } + } catch (error) { + errors.push( + `Unable to resolve selected Linux package targets: ${error.message}` + ); + linuxTargetNames = []; + } if (!fileExists(flatpakMetainfoPath)) { errors.push( `Missing Flatpak metainfo file: ${flatpakMetainfoPath}` @@ -679,6 +741,9 @@ function verifyResourceDir(resourceDir) { platform, required: embeddedMpvRequired, foreignArch: isForeignArchLinuxResourceDir(resourceDir), + profile: linuxFrameCopyProfile, + targetNames: linuxTargetNames, + executableName: linuxExecutableName, }) ); diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs new file mode 100644 index 000000000..e7bfb7a3b --- /dev/null +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -0,0 +1,1744 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +import { + collectEmbeddedMpvNativeArchiveEntries, + listAsarPackageEntries, +} from './asar-dependency-closure.mjs'; + +const require = createRequire(import.meta.url); +const { + parseReadelfDynamic, +} = require('../embedded-mpv/build-linux-runtime.cjs'); +const { + listElectronShippedLinuxLibraries, + validatePackagedEmbeddedMpv, +} = require('./embedded-mpv-packaging.cjs'); +const { + LINUX_SYSTEM_PACKAGE_DEPENDENCIES, + resolveLinuxFrameCopyProfile, +} = require('./linux-frame-copy-profile.cjs'); +const { + RUNTIME_PROBE_MAX_BUFFER_BYTES, + RUNTIME_PROBE_TIMEOUT_MS, +} = require('../embedded-mpv/runtime-probe-contract.cjs'); + +const scriptPath = fileURLToPath(import.meta.url); +const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/; +const SNAP_METADATA_MAX_BYTES = 256 * 1024; +// Keep this set identical to the packaged helper sanitizer in +// embedded-mpv-frame-copy-runtime/helper-environment.ts. Feature/debug +// selectors such as LIBGL_ALWAYS_SOFTWARE remain intentionally available. +const UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES = [ + 'BASH_ENV', + 'ENV', + 'BASHOPTS', + 'SHELLOPTS', + 'PS4', + 'BASH_XTRACEFD', + 'CDPATH', + 'LD_AUDIT', + 'LD_LIBRARY_PATH', + 'LD_ORIGIN_PATH', + 'LD_PRELOAD', + '__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS', + '__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES', + '__EGL_VENDOR_LIBRARY_DIRS', + '__EGL_VENDOR_LIBRARY_FILENAMES', + 'GBM_BACKEND', + 'GBM_BACKENDS_PATH', + 'LIBGL_DRIVERS_PATH', + 'MESA_LOADER_DRIVER_OVERRIDE', + 'LIBVA_DRIVER_NAME', + 'LIBVA_DRIVERS_PATH', + 'VDPAU_DRIVER_PATH', + 'VK_DRIVER_FILES', + 'VK_ICD_FILENAMES', + 'VK_ADD_DRIVER_FILES', + 'VK_ADD_LAYER_PATH', + 'VK_IMPLICIT_LAYER_PATH', + 'VK_ADD_IMPLICIT_LAYER_PATH', + 'VK_LAYER_PATH', +]; + +export function runVerifierCommand(command, args, options = {}) { + const spawnOptions = { + cwd: options.cwd, + env: options.env, + encoding: 'utf8', + stdio: 'pipe', + timeout: options.timeout, + killSignal: options.killSignal, + windowsHide: true, + }; + if (options.maxBuffer !== undefined) { + spawnOptions.maxBuffer = options.maxBuffer; + } + return spawnSync(command, args, spawnOptions); +} + +function assertCommandSucceeded(command, args, result) { + if (result?.error) { + throw new Error( + `Unable to run ${command}: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }` + ); + } + if (result?.status !== 0) { + const details = [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim(); + throw new Error( + `${command} ${args.join(' ')} failed with status ${ + result?.status ?? 'unknown' + }.${details ? `\n${details}` : ''}` + ); + } + return result; +} + +export function detectLinuxArtifactFormat(artifactPath) { + const fileName = path.basename(artifactPath); + if (/\.AppImage$/i.test(fileName)) { + return 'appimage'; + } + if (/\.deb$/i.test(fileName)) { + return 'deb'; + } + if (/\.rpm$/i.test(fileName)) { + return 'rpm'; + } + if (/\.(?:pacman|pkg\.tar(?:\.[A-Za-z0-9]+)*)$/i.test(fileName)) { + return 'pacman'; + } + if (/\.snap$/i.test(fileName)) { + return 'snap'; + } + if (/\.flatpak$/i.test(fileName)) { + return 'flatpak'; + } + throw new Error(`Unsupported Linux package artifact: ${artifactPath}`); +} + +export function parseVerifierArguments(argv) { + const normalizedArgs = argv[0] === '--' ? argv.slice(1) : [...argv]; + let artifactValue; + let profileValue; + for (let index = 0; index < normalizedArgs.length; index += 1) { + const argument = normalizedArgs[index]; + if (argument === '--artifact') { + if (artifactValue !== undefined) { + throw new Error('Received duplicate --artifact argument.'); + } + artifactValue = normalizedArgs[++index]; + continue; + } + if (argument === '--profile') { + if (profileValue !== undefined) { + throw new Error('Received duplicate --profile argument.'); + } + profileValue = normalizedArgs[++index]; + continue; + } + throw new Error(`Unsupported verifier argument: ${argument}`); + } + if (!artifactValue) { + throw new Error('--artifact is required.'); + } + if (!profileValue) { + throw new Error('--profile is required.'); + } + const artifactPath = path.resolve(artifactValue); + const stat = fs.lstatSync(artifactPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Linux package artifact must be a regular file: ${artifactPath}` + ); + } + const profile = resolveLinuxFrameCopyProfile(profileValue); + detectLinuxArtifactFormat(artifactPath); + return { + artifactPath, + profileName: profile.name, + }; +} + +export function findAppImageSquashfsOffsets(artifactPath) { + const magic = Buffer.from('hsqs'); + const chunkSize = 1024 * 1024; + const descriptor = fs.openSync(artifactPath, 'r'); + const offsets = []; + let position = 0; + let overlap = Buffer.alloc(0); + try { + while (true) { + const chunk = Buffer.alloc(chunkSize); + const bytesRead = fs.readSync( + descriptor, + chunk, + 0, + chunk.length, + position + ); + if (bytesRead === 0) { + break; + } + const contents = Buffer.concat([ + overlap, + chunk.subarray(0, bytesRead), + ]); + const contentsStart = position - overlap.length; + let searchOffset = 0; + while (searchOffset <= contents.length - magic.length) { + const matchOffset = contents.indexOf(magic, searchOffset); + if (matchOffset === -1) { + break; + } + const absoluteOffset = contentsStart + matchOffset; + if (offsets.at(-1) !== absoluteOffset) { + offsets.push(absoluteOffset); + } + searchOffset = matchOffset + 1; + } + overlap = contents.subarray( + Math.max(0, contents.length - (magic.length - 1)) + ); + position += bytesRead; + } + } finally { + fs.closeSync(descriptor); + } + return offsets; +} + +export function extractLinuxArtifact({ + artifactPath, + format, + destination, + runCommand = runVerifierCommand, +}) { + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + const run = (command, args, options = {}) => + assertCommandSucceeded( + command, + args, + runCommand(command, args, options) + ); + + switch (format) { + case 'appimage': { + const squashfsOffsets = findAppImageSquashfsOffsets(artifactPath); + if (squashfsOffsets.length === 0) { + throw new Error( + `AppImage contains no SquashFS payload: ${artifactPath}` + ); + } + const failures = []; + for (const offset of squashfsOffsets) { + fs.rmSync(destination, { recursive: true, force: true }); + const args = [ + '-no-progress', + '-offset', + String(offset), + '-dest', + destination, + artifactPath, + ]; + const result = runCommand('unsquashfs', args); + if (!result?.error && result?.status === 0) { + return destination; + } + failures.push( + [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim() + ); + } + throw new Error( + [ + `Unable to extract the AppImage SquashFS payload at any candidate offset: ${artifactPath}`, + ...failures.filter(Boolean), + ].join('\n') + ); + } + case 'deb': + fs.mkdirSync(destination, { recursive: true }); + run('dpkg-deb', ['--extract', artifactPath, destination]); + return destination; + case 'rpm': + case 'pacman': + fs.mkdirSync(destination, { recursive: true }); + run('bsdtar', [ + '--extract', + '--file', + artifactPath, + '--directory', + destination, + ]); + return destination; + case 'snap': + run('unsquashfs', [ + '-no-progress', + '-dest', + destination, + artifactPath, + ]); + return destination; + case 'flatpak': { + fs.mkdirSync(destination, { recursive: true }); + const repository = path.join(destination, '.ostree-repository'); + const checkout = path.join(destination, 'checkout'); + fs.mkdirSync(repository, { recursive: true }); + run('ostree', [ + `--repo=${repository}`, + 'init', + '--mode=archive-z2', + ]); + run('flatpak', ['build-import-bundle', repository, artifactPath]); + const refsResult = run('ostree', ['refs', `--repo=${repository}`]); + const refs = String(refsResult.stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.startsWith('app/')); + if (refs.length !== 1) { + throw new Error( + `Flatpak bundle must import exactly one application ref; received ${ + refs.length > 0 ? refs.join(', ') : '' + }.` + ); + } + run('ostree', [ + 'checkout', + '-U', + `--repo=${repository}`, + refs[0], + checkout, + ]); + return checkout; + } + default: + throw new Error(`Unsupported Linux package format: ${format}`); + } +} + +export function findExtractedResourceDir(extractionRoot) { + const candidates = []; + + function visit(directoryPath) { + let entries; + try { + entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + } catch (error) { + throw new Error( + `Unable to inspect extracted package directory ${directoryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + for (const entry of entries) { + if (!entry.isDirectory() || entry.isSymbolicLink()) { + continue; + } + const entryPath = path.join(directoryPath, entry.name); + if (entry.name === 'resources') { + const nativeDir = path.join( + entryPath, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + let nativeStat; + try { + nativeStat = fs.lstatSync(nativeDir); + } catch { + nativeStat = null; + } + if (nativeStat?.isDirectory() && !nativeStat.isSymbolicLink()) { + candidates.push(entryPath); + continue; + } + } + visit(entryPath); + } + } + + visit(extractionRoot); + if (candidates.length !== 1) { + throw new Error( + `Expected exactly one embedded MPV native payload in ${extractionRoot}; found ${candidates.length}.` + ); + } + return candidates[0]; +} + +function stripYamlTrailingComment(value) { + let quote = null; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (quote === "'") { + if (character === "'" && value[index + 1] === "'") { + index += 1; + } else if (character === "'") { + quote = null; + } + continue; + } + if (quote === '"') { + if (character === '\\') { + index += 1; + } else if (character === '"') { + quote = null; + } + continue; + } + if (character === "'" || character === '"') { + quote = character; + continue; + } + if ( + character === '#' && + (index === 0 || /[ \t]/.test(value[index - 1])) + ) { + return value.slice(0, index).trimEnd(); + } + } + return value; +} + +function yamlMappingEntries(lines, key, indent, start = 0, end = lines.length) { + const prefix = `${' '.repeat(indent)}${key}:`; + return lines + .map((line, index) => ({ index, line })) + .filter( + ({ index, line }) => + index >= start && + index < end && + line.startsWith(prefix) && + line.slice(prefix.length).match(/^(?:\s|$)/) && + line.length - line.trimStart().length === indent + ) + .map(({ index, line }) => { + let blockEnd = end; + for ( + let candidateIndex = index + 1; + candidateIndex < end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if ( + !candidate.trim() || + candidate.trimStart().startsWith('#') + ) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + return { + index, + end: blockEnd, + value: stripYamlTrailingComment( + line.slice(prefix.length).trim() + ), + }; + }); +} + +function singleYamlMappingEntry(lines, key, indent, start, end) { + const entries = yamlMappingEntries(lines, key, indent, start, end); + return entries.length === 1 ? entries[0] : null; +} + +function directYamlMappingEntries(lines, parent, indent) { + const entries = []; + for (let index = parent.index + 1; index < parent.end; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trimStart().startsWith('#')) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== indent) { + continue; + } + const match = line + .slice(indent) + .match(/^([A-Za-z0-9][A-Za-z0-9_-]*):(?:\s*(.*))?$/); + if (!match) { + return null; + } + let blockEnd = parent.end; + for ( + let candidateIndex = index + 1; + candidateIndex < parent.end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if (!candidate.trim() || candidate.trimStart().startsWith('#')) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + entries.push({ + key: match[1], + value: stripYamlTrailingComment(match[2] ?? ''), + index, + end: blockEnd, + }); + } + return entries; +} + +function directYamlAbsolutePathMappingEntries(lines, parent, indent) { + const entries = []; + for (let index = parent.index + 1; index < parent.end; index += 1) { + const line = lines[index]; + if (!line.trim() || line.trimStart().startsWith('#')) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== indent) { + continue; + } + const match = line + .slice(indent) + .match(/^(\/[A-Za-z0-9._/-]+):(?:\s*(.*))?$/); + if (!match) { + return null; + } + let blockEnd = parent.end; + for ( + let candidateIndex = index + 1; + candidateIndex < parent.end; + candidateIndex += 1 + ) { + const candidate = lines[candidateIndex]; + if (!candidate.trim() || candidate.trimStart().startsWith('#')) { + continue; + } + const candidateIndent = + candidate.length - candidate.trimStart().length; + if (candidateIndent <= indent) { + blockEnd = candidateIndex; + break; + } + } + entries.push({ + key: match[1], + value: stripYamlTrailingComment(match[2] ?? ''), + index, + end: blockEnd, + }); + } + return entries; +} + +function yamlScalarEquals(value, expected) { + return ( + value === expected || + value === JSON.stringify(expected) || + value === `'${expected.replaceAll("'", "''")}'` + ); +} + +function yamlSequenceIncludes(lines, entry, expected) { + if (entry.value) { + if (!entry.value.startsWith('[') || !entry.value.endsWith(']')) { + return false; + } + const values = entry.value + .slice(1, -1) + .split(',') + .map((value) => value.trim()); + return ( + values.length > 0 && + values.every( + (value) => + value.length > 0 && + !/[:[\]{}&*]/.test(value) && + !value.startsWith('-') + ) && + values.some((value) => yamlScalarEquals(value, expected)) + ); + } + const sequenceLines = lines + .slice(entry.index + 1, entry.end) + .filter((line) => line.trim() && !line.trimStart().startsWith('#')); + const itemIndent = 6; + const values = sequenceLines.map((line) => { + const lineIndent = line.length - line.trimStart().length; + if (lineIndent !== itemIndent) { + return null; + } + const match = line.trim().match(/^-\s+([^:[\]{}&*]+)$/); + return match ? stripYamlTrailingComment(match[1].trim()) : null; + }); + return ( + values.length > 0 && + values.every((value) => value !== null) && + values.some((value) => yamlScalarEquals(value, expected)) + ); +} + +function yamlSyntaxOutsideQuotedScalars(line) { + let result = ''; + let quote = null; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (quote === "'") { + if (character === "'" && line[index + 1] === "'") { + result += ' '; + index += 1; + } else { + result += ' '; + if (character === "'") { + quote = null; + } + } + continue; + } + if (quote === '"') { + result += ' '; + if (character === '\\') { + result += ' '; + index += 1; + } else if (character === '"') { + quote = null; + } + continue; + } + if (character === '#') { + break; + } + if (character === "'" || character === '"') { + quote = character; + result += ' '; + continue; + } + result += character; + } + return result; +} + +function containsUnsupportedYamlSemantics(lines) { + let blockScalarParentIndent = null; + for (const line of lines) { + if (!line.trim()) { + continue; + } + const lineIndent = line.length - line.trimStart().length; + if ( + blockScalarParentIndent !== null && + lineIndent > blockScalarParentIndent + ) { + continue; + } + blockScalarParentIndent = null; + const syntax = yamlSyntaxOutsideQuotedScalars(line); + if ( + /(?:^|[\s:[\]{},])(?:[&*][^\s,[\]{}]+|![^\s,[\]{}]+)(?=$|[\s,\]}])/.test( + syntax + ) || + /(?:^|\s)<<\s*:/.test(syntax) + ) { + return true; + } + if (/:\s*[>|][+-]?\d?\s*$/.test(syntax)) { + blockScalarParentIndent = lineIndent; + } + } + return false; +} + +export function validateExtractedSnapMetadata(extractionRoot) { + const snapYamlPath = path.join(extractionRoot, 'meta', 'snap.yaml'); + let stat; + try { + stat = fs.lstatSync(snapYamlPath); + } catch { + return [`Missing extracted Snap metadata: ${snapYamlPath}`]; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + return [ + `Extracted Snap metadata must be a regular file: ${snapYamlPath}`, + ]; + } + if (stat.size > SNAP_METADATA_MAX_BYTES) { + return [ + `Extracted Snap metadata exceeds the ${String( + SNAP_METADATA_MAX_BYTES + )}-byte size limit: ${snapYamlPath}`, + ]; + } + + let contents; + try { + contents = fs.readFileSync(snapYamlPath, 'utf8'); + } catch (error) { + return [ + `Unable to read extracted Snap metadata at ${snapYamlPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if (contents.includes('\t')) { + return [ + `Extracted Snap metadata must use space indentation: ${snapYamlPath}`, + ]; + } + const lines = contents.split(/\r?\n/); + if (containsUnsupportedYamlSemantics(lines)) { + return [ + 'Extracted Snap metadata must not use YAML anchors, aliases, merge keys, or custom tags.', + ]; + } + const errors = []; + const graphicsMountPath = path.join(extractionRoot, 'graphics'); + let graphicsMountStat; + try { + graphicsMountStat = fs.lstatSync(graphicsMountPath); + } catch { + errors.push( + `Extracted Snap must contain an empty graphics content mount directory: ${graphicsMountPath}` + ); + } + if ( + graphicsMountStat && + (!graphicsMountStat.isDirectory() || graphicsMountStat.isSymbolicLink()) + ) { + errors.push( + `Extracted Snap graphics content mount must be a real directory: ${graphicsMountPath}` + ); + } else if (graphicsMountStat) { + if ((graphicsMountStat.mode & 0o777) !== 0o755) { + errors.push( + `Extracted Snap graphics content mount directory must have mode 0755: ${graphicsMountPath}` + ); + } + try { + if (fs.readdirSync(graphicsMountPath).length > 0) { + errors.push( + `Extracted Snap graphics content mount directory must be empty: ${graphicsMountPath}` + ); + } + } catch (error) { + errors.push( + `Unable to inspect extracted Snap graphics content mount at ${graphicsMountPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + const base = singleYamlMappingEntry(lines, 'base', 0, 0, lines.length); + if (!base || !yamlScalarEquals(base.value, 'core22')) { + errors.push('Extracted Snap metadata must declare base: core22.'); + } + const confinement = singleYamlMappingEntry( + lines, + 'confinement', + 0, + 0, + lines.length + ); + if (!confinement || !yamlScalarEquals(confinement.value, 'strict')) { + errors.push( + 'Extracted Snap metadata must declare confinement: strict.' + ); + } + const layout = singleYamlMappingEntry(lines, 'layout', 0, 0, lines.length); + if (!layout || layout.value) { + errors.push( + 'Extracted Snap metadata must declare the exact Snap graphics layout contract.' + ); + } else { + const layoutEntries = directYamlAbsolutePathMappingEntries( + lines, + layout, + 2 + ); + const expectedLayouts = [ + { + path: '/usr/share/libdrm', + field: 'bind', + target: '$SNAP/graphics/libdrm', + label: 'libdrm', + }, + { + path: '/usr/share/drirc.d', + field: 'symlink', + target: '$SNAP/graphics/drirc.d', + label: 'drirc.d', + }, + ]; + if ( + layoutEntries && + new Set(layoutEntries.map(({ key }) => key)).size !== + layoutEntries.length + ) { + errors.push('Extracted Snap layout paths must be unique.'); + } + if ( + !layoutEntries || + layoutEntries.length !== expectedLayouts.length || + expectedLayouts.some( + ({ path: expectedPath }) => + layoutEntries.filter(({ key }) => key === expectedPath) + .length !== 1 + ) + ) { + errors.push( + 'Extracted Snap graphics layout must contain exactly the libdrm and drirc.d entries.' + ); + } + for (const expected of expectedLayouts) { + const entry = layoutEntries?.find( + ({ key }) => key === expected.path + ); + const fields = + entry && !entry.value + ? directYamlMappingEntries(lines, entry, 4) + : null; + if ( + !fields || + fields.length !== 1 || + fields[0].key !== expected.field + ) { + errors.push( + `Extracted Snap ${expected.label} layout must contain exactly ${expected.field}.` + ); + } + if ( + !fields || + fields.filter( + ({ key, value }) => + key === expected.field && + yamlScalarEquals(value, expected.target) + ).length !== 1 + ) { + errors.push( + `Extracted Snap ${expected.label} layout must declare ${expected.field}: ${expected.target}.` + ); + } + } + } + const plugs = singleYamlMappingEntry(lines, 'plugs', 0, 0, lines.length); + const sharedMemory = + plugs && + singleYamlMappingEntry( + lines, + 'shared-memory', + 2, + plugs.index + 1, + plugs.end + ); + const graphicsCore22 = + plugs && + singleYamlMappingEntry( + lines, + 'graphics-core22', + 2, + plugs.index + 1, + plugs.end + ); + if (!plugs || plugs.value || !sharedMemory || sharedMemory.value) { + errors.push( + 'Extracted Snap metadata must declare exactly one top-level shared-memory plug.' + ); + } else { + const fields = directYamlMappingEntries(lines, sharedMemory, 4); + const interfaceEntries = + fields?.filter(({ key }) => key === 'interface') ?? []; + const privateEntries = + fields?.filter(({ key }) => key === 'private') ?? []; + const interfaceEntry = interfaceEntries[0]; + const privateEntry = privateEntries[0]; + if ( + !fields || + fields.length !== 2 || + interfaceEntries.length !== 1 || + privateEntries.length !== 1 + ) { + errors.push( + 'Extracted Snap private shared-memory plug must contain exactly interface and private.' + ); + } + if ( + !interfaceEntry || + !yamlScalarEquals(interfaceEntry.value, 'shared-memory') + ) { + errors.push( + 'Extracted Snap top-level shared-memory plug must declare interface: shared-memory.' + ); + } + if (!privateEntry || privateEntry.value !== 'true') { + errors.push( + 'Extracted Snap top-level shared-memory plug must declare private: true.' + ); + } + + const plugEntries = directYamlMappingEntries(lines, plugs, 2); + if (!plugEntries || plugEntries.some(({ value }) => value.length > 0)) { + errors.push( + 'Extracted Snap plug declarations must use block mappings.' + ); + } + if ( + plugEntries && + new Set(plugEntries.map(({ key }) => key)).size !== + plugEntries.length + ) { + errors.push('Extracted Snap direct plug keys must be unique.'); + } + const sharedMemoryInterfaces = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + const interfaceFields = + plugFields?.filter(({ key }) => key === 'interface') ?? []; + return ( + interfaceFields.length === 1 && + yamlScalarEquals(interfaceFields[0].value, 'shared-memory') + ); + }) ?? []; + if ( + !plugEntries || + sharedMemoryInterfaces.length !== 1 || + sharedMemoryInterfaces[0].key !== 'shared-memory' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with the shared-memory interface.' + ); + } + } + + if (!plugs || plugs.value || !graphicsCore22 || graphicsCore22.value) { + errors.push( + 'Extracted Snap metadata must declare exactly one top-level graphics-core22 plug.' + ); + } else { + const fields = directYamlMappingEntries(lines, graphicsCore22, 4); + const interfaceEntries = + fields?.filter(({ key }) => key === 'interface') ?? []; + const targetEntries = + fields?.filter(({ key }) => key === 'target') ?? []; + const providerEntries = + fields?.filter(({ key }) => key === 'default-provider') ?? []; + const interfaceEntry = interfaceEntries[0]; + const targetEntry = targetEntries[0]; + const providerEntry = providerEntries[0]; + if ( + !fields || + fields.length !== 3 || + interfaceEntries.length !== 1 || + targetEntries.length !== 1 || + providerEntries.length !== 1 + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must contain exactly interface, target, and default-provider.' + ); + } + if ( + !interfaceEntry || + !yamlScalarEquals(interfaceEntry.value, 'content') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare interface: content.' + ); + } + if ( + !targetEntry || + !yamlScalarEquals(targetEntry.value, '$SNAP/graphics') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare target: $SNAP/graphics.' + ); + } + if ( + !providerEntry || + !yamlScalarEquals(providerEntry.value, 'mesa-core22') + ) { + errors.push( + 'Extracted Snap graphics-core22 plug must declare default-provider: mesa-core22.' + ); + } + + const plugEntries = directYamlMappingEntries(lines, plugs, 2); + const graphicsContracts = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + if (!plugFields || plugFields.length !== 3) { + return false; + } + return [ + ['interface', 'content'], + ['target', '$SNAP/graphics'], + ['default-provider', 'mesa-core22'], + ].every( + ([key, expected]) => + plugFields.filter( + ({ key: fieldKey, value }) => + fieldKey === key && + yamlScalarEquals(value, expected) + ).length === 1 + ); + }) ?? []; + if ( + !plugEntries || + graphicsContracts.length !== 1 || + graphicsContracts[0].key !== 'graphics-core22' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with the graphics-core22 contract.' + ); + } + const graphicsTargets = + plugEntries?.filter((plugEntry) => { + const plugFields = directYamlMappingEntries( + lines, + plugEntry, + 4 + ); + return ( + plugFields?.some( + ({ key, value }) => + key === 'target' && + yamlScalarEquals(value, '$SNAP/graphics') + ) ?? false + ); + }) ?? []; + if ( + !plugEntries || + graphicsTargets.length !== 1 || + graphicsTargets[0].key !== 'graphics-core22' + ) { + errors.push( + 'Extracted Snap metadata must declare exactly one plug with target $SNAP/graphics.' + ); + } + } + + const slotsEntries = yamlMappingEntries(lines, 'slots', 0, 0, lines.length); + let hasSharedMemorySlot = slotsEntries.length > 1; + let invalidSlotsShape = slotsEntries.some(({ value }) => value.length > 0); + for (const slots of slotsEntries) { + const slotEntries = directYamlMappingEntries(lines, slots, 2); + if (!slotEntries) { + invalidSlotsShape = true; + continue; + } + invalidSlotsShape ||= slotEntries.some(({ value }) => value.length > 0); + hasSharedMemorySlot ||= slotEntries.some((slotEntry) => { + if (slotEntry.key === 'shared-memory') { + return true; + } + const slotFields = directYamlMappingEntries(lines, slotEntry, 4); + return ( + slotFields?.some( + ({ key, value }) => + key === 'interface' && + yamlScalarEquals(value, 'shared-memory') + ) ?? false + ); + }); + } + if (invalidSlotsShape) { + errors.push( + 'Extracted Snap slots must use block mappings with scalar fields.' + ); + } + if (hasSharedMemorySlot) { + errors.push( + 'Extracted Snap metadata must not declare a shared-memory slot.' + ); + } + + const apps = singleYamlMappingEntry(lines, 'apps', 0, 0, lines.length); + const app = + apps && + singleYamlMappingEntry(lines, 'iptvnator', 2, apps.index + 1, apps.end); + const appPlugs = + app && + singleYamlMappingEntry(lines, 'plugs', 4, app.index + 1, app.end); + const appEnvironment = + app && + singleYamlMappingEntry(lines, 'environment', 4, app.index + 1, app.end); + const snapDesktopRuntime = + appEnvironment && + singleYamlMappingEntry( + lines, + 'SNAP_DESKTOP_RUNTIME', + 6, + appEnvironment.index + 1, + appEnvironment.end + ); + if ( + !apps || + apps.value || + !app || + app.value || + !appPlugs || + !yamlSequenceIncludes(lines, appPlugs, 'shared-memory') + ) { + errors.push( + 'Extracted Snap iptvnator app scalar sequence must contain the shared-memory plug.' + ); + } + if ( + !apps || + apps.value || + !app || + app.value || + !appPlugs || + !yamlSequenceIncludes(lines, appPlugs, 'graphics-core22') + ) { + errors.push( + 'Extracted Snap iptvnator app scalar sequence must contain the graphics-core22 plug.' + ); + } + if ( + !appEnvironment || + appEnvironment.value || + !snapDesktopRuntime || + !yamlScalarEquals(snapDesktopRuntime.value, '$SNAP/gnome-platform') + ) { + errors.push( + 'Extracted Snap iptvnator app environment must declare SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform.' + ); + } + return errors; +} + +export function readElfArchitecture(binaryPath) { + const descriptor = fs.openSync(binaryPath, 'r'); + try { + const header = Buffer.alloc(20); + const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0); + if ( + bytesRead !== header.length || + !header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) + ) { + throw new Error(`Not an ELF binary: ${binaryPath}`); + } + const byteOrder = header[5]; + const machine = + byteOrder === 1 + ? header.readUInt16LE(18) + : byteOrder === 2 + ? header.readUInt16BE(18) + : null; + const architecture = new Map([ + [62, 'x64'], + [183, 'arm64'], + [40, 'armv7l'], + ]).get(machine); + if (!architecture) { + throw new Error( + `Unsupported ELF machine ${String(machine)} in ${binaryPath}.` + ); + } + return architecture; + } finally { + fs.closeSync(descriptor); + } +} + +function normalizePackageArchitecture(value) { + const normalized = String(value ?? '') + .trim() + .toLowerCase(); + const architecture = { + amd64: 'x64', + x86_64: 'x64', + arm64: 'arm64', + aarch64: 'arm64', + armhf: 'armv7l', + armv7h: 'armv7l', + armv7hl: 'armv7l', + }[normalized]; + if (!architecture) { + throw new Error( + `Unsupported Linux package architecture: ${ + normalized || '' + }.` + ); + } + return architecture; +} + +function splitNonEmptyLines(value) { + return String(value ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function findPackageInfoFiles(extractionRoot) { + const packageInfoPaths = []; + + function visit(directoryPath) { + for (const entry of fs.readdirSync(directoryPath, { + withFileTypes: true, + })) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory() && !entry.isSymbolicLink()) { + visit(entryPath); + } else if (entry.isFile() && entry.name === '.PKGINFO') { + packageInfoPaths.push(entryPath); + } + } + } + + visit(extractionRoot); + return packageInfoPaths; +} + +export function readLinuxArtifactMetadata({ + artifactPath, + format, + extractionRoot, + runCommand = runVerifierCommand, +}) { + const run = (command, args) => + assertCommandSucceeded(command, args, runCommand(command, args)); + if (format === 'deb') { + const architectureResult = run('dpkg-deb', [ + '--field', + artifactPath, + 'Architecture', + ]); + const dependencyResult = run('dpkg-deb', [ + '--field', + artifactPath, + 'Depends', + ]); + return { + declaredArch: normalizePackageArchitecture( + architectureResult.stdout + ), + dependencies: String(dependencyResult.stdout ?? '') + .split(',') + .map((dependency) => dependency.trim()) + .filter(Boolean), + }; + } + if (format === 'rpm') { + const architectureResult = run('rpm', [ + '-qp', + '--queryformat', + '%{ARCH}\\n', + artifactPath, + ]); + const dependencyResult = run('rpm', [ + '-qp', + '--requires', + artifactPath, + ]); + return { + declaredArch: normalizePackageArchitecture( + architectureResult.stdout + ), + dependencies: splitNonEmptyLines(dependencyResult.stdout), + }; + } + if (format === 'pacman') { + const packageInfoPaths = findPackageInfoFiles(extractionRoot); + if (packageInfoPaths.length !== 1) { + throw new Error( + `Pacman payload must contain exactly one .PKGINFO; found ${packageInfoPaths.length}.` + ); + } + const fields = fs + .readFileSync(packageInfoPaths[0], 'utf8') + .split(/\r?\n/) + .map((line) => line.match(/^([^=]+?)\s*=\s*(.*)$/)) + .filter(Boolean) + .map((match) => ({ + name: match[1].trim(), + value: match[2].trim(), + })); + const architectures = fields + .filter(({ name }) => name === 'arch') + .map(({ value }) => value); + if (architectures.length !== 1) { + throw new Error( + `Pacman .PKGINFO must declare exactly one architecture; found ${architectures.length}.` + ); + } + return { + declaredArch: normalizePackageArchitecture(architectures[0]), + dependencies: fields + .filter(({ name }) => name === 'depend') + .map(({ value }) => value), + }; + } + return { + declaredArch: null, + dependencies: [], + }; +} + +function dependencyMatches(dependency, expected) { + const escapedExpected = expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^${escapedExpected}(?:$|\\s|[<>=])`).test( + dependency.trim() + ); +} + +export function validateSystemPackageDependencies(format, dependencies) { + const expectedDependencies = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format]; + if (!expectedDependencies) { + return []; + } + if (!Array.isArray(dependencies)) { + return [ + `Linux ${format} package dependency metadata must be an array.`, + ]; + } + return expectedDependencies.flatMap((expectedDependency) => + dependencies.some((dependency) => + dependencyMatches(String(dependency), expectedDependency) + ) + ? [] + : [ + `Linux x64 ${format} package must declare ${expectedDependency}.`, + ] + ); +} + +function validateForeignPackageDependencies(format, dependencies) { + const forbiddenDependencies = + LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? []; + return forbiddenDependencies.flatMap((forbiddenDependency) => + dependencies.some((dependency) => + dependencyMatches(String(dependency), forbiddenDependency) + ) + ? [ + `Linux foreign-architecture ${format} package must not declare frame-copy dependency ${forbiddenDependency}.`, + ] + : [] + ); +} + +function dependencyFileName(dependencyName) { + return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? ''; +} + +function validateElectronIsolation(resourceDir, artifactFormat, elfInspector) { + const errors = []; + const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + const binaries = [ + { label: 'Electron binary', binaryPath: electronPath }, + ...listElectronShippedLinuxLibraries(resourceDir, { + artifactFormat, + }).map((binaryPath) => ({ + label: 'Electron library', + binaryPath, + })), + ]; + for (const { label, binaryPath } of binaries) { + let stat; + try { + stat = fs.lstatSync(binaryPath); + } catch { + errors.push(`Missing ${label}: ${binaryPath}`); + continue; + } + if (!stat.isFile() || stat.isSymbolicLink()) { + errors.push(`${label} must be a regular file: ${binaryPath}`); + continue; + } + let dynamic; + try { + dynamic = elfInspector(binaryPath); + } catch (error) { + errors.push( + `Unable to inspect ${label} at ${binaryPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + if (!dynamic || !Array.isArray(dynamic.needed)) { + errors.push( + `ELF inspection for ${label} must provide a needed array: ${binaryPath}` + ); + continue; + } + const libmpvDependencies = dynamic.needed.filter((dependencyName) => + LIBMPV_DEPENDENCY_PATTERN.test(dependencyFileName(dependencyName)) + ); + if (libmpvDependencies.length > 0) { + errors.push( + `${label} must not link libmpv; found ${libmpvDependencies.join( + ', ' + )} in ${binaryPath}.` + ); + } + } + return errors; +} + +function defaultElfInspector(binaryPath) { + const result = assertCommandSucceeded( + 'readelf', + ['-d', binaryPath], + runVerifierCommand('readelf', ['-d', binaryPath]) + ); + return parseReadelfDynamic(result.stdout); +} + +function validateProbeResult(result) { + if (result?.error) { + return [ + `Unable to execute Linux frame-copy runtime probe: ${ + result.error instanceof Error + ? result.error.message + : String(result.error) + }`, + ]; + } + if (result?.signal) { + return [ + `Linux frame-copy runtime probe terminated by signal ${result.signal}.`, + ]; + } + if (result?.status !== 0) { + const details = [result?.stdout, result?.stderr] + .filter(Boolean) + .join('\n') + .trim(); + return [ + `Linux frame-copy runtime probe failed with status ${ + result?.status ?? 'unknown' + }.${details ? `\n${details}` : ''}`, + ]; + } + const stdout = result.stdout; + if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) { + return [ + 'Linux frame-copy runtime probe must emit exactly one newline-terminated JSON line.', + ]; + } + let payload; + try { + payload = JSON.parse(stdout.slice(0, -1)); + } catch (error) { + return [ + `Linux frame-copy runtime probe emitted invalid JSON: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if ( + !payload || + typeof payload !== 'object' || + Array.isArray(payload) || + JSON.stringify(Object.keys(payload).sort()) !== + JSON.stringify(['libmpv', 'protocol', 'renderApi', 'usable']) || + payload.protocol !== 1 || + payload.usable !== true || + typeof payload.libmpv !== 'string' || + payload.libmpv.trim() === '' || + payload.renderApi !== 'egl' + ) { + return [ + 'Linux frame-copy runtime probe did not return protocol 1 usable EGL capability.', + ]; + } + return []; +} + +export function createRuntimeProbeEnvironment({ + environment, + nativeDir, + runtimeMode, +}) { + const probeEnvironment = { ...(environment ?? {}) }; + for (const variableName of UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES) { + delete probeEnvironment[variableName]; + } + for (const variableName of Object.keys(probeEnvironment)) { + if (variableName.startsWith('BASH_FUNC_')) { + delete probeEnvironment[variableName]; + } + } + if (runtimeMode === 'bundled') { + probeEnvironment.LD_LIBRARY_PATH = path.join(nativeDir, 'lib'); + } + return probeEnvironment; +} + +function validateNoEmbeddedMpvNativeArchiveEntries( + resourceDir, + asarListPackage +) { + const asarPath = path.join(resourceDir, 'app.asar'); + if (!fs.existsSync(asarPath)) { + return []; + } + + let nativeEntries; + try { + nativeEntries = collectEmbeddedMpvNativeArchiveEntries( + asarListPackage(asarPath) + ); + } catch (error) { + return [ + `Unable to inspect embedded MPV archive ownership in ${asarPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]; + } + if (nativeEntries.length === 0) { + return []; + } + return [ + [ + `Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`, + ...nativeEntries.map((entry) => `- ${entry}`), + ].join('\n'), + ]; +} + +export function verifyExtractedLinuxFrameCopyRuntime({ + resourceDir, + artifactFormat, + profileName, + packageDependencies = [], + declaredArch = null, + elfInspector = defaultElfInspector, + probeRunner = runVerifierCommand, + environment = process.env, + asarListPackage = listAsarPackageEntries, +}) { + const errors = []; + let profile; + try { + profile = resolveLinuxFrameCopyProfile(profileName); + } catch (error) { + return [error instanceof Error ? error.message : String(error)]; + } + if (!profile.targets.includes(artifactFormat)) { + return [ + `Linux frame-copy profile "${profile.name}" does not include target "${artifactFormat}".`, + ]; + } + errors.push( + ...validateNoEmbeddedMpvNativeArchiveEntries( + resourceDir, + asarListPackage + ) + ); + + const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); + let packageArch; + try { + packageArch = readElfArchitecture(electronPath); + } catch (error) { + return [ + ...errors, + error instanceof Error ? error.message : String(error), + ]; + } + const foreignArch = packageArch !== 'x64'; + if (declaredArch && declaredArch !== packageArch) { + errors.push( + `Package metadata architecture ${declaredArch} does not match Electron ELF architecture ${packageArch}.` + ); + } + if (profile.runtimeMode === 'system') { + errors.push( + ...(foreignArch + ? validateForeignPackageDependencies( + artifactFormat, + packageDependencies + ) + : validateSystemPackageDependencies( + artifactFormat, + packageDependencies + )) + ); + } + + errors.push( + ...validatePackagedEmbeddedMpv(resourceDir, { + platform: 'linux', + required: true, + foreignArch, + targetArch: packageArch, + profile: profile.name, + targetNames: profile.targets, + artifactFormat, + hostPlatform: 'linux', + executableName: 'iptvnator', + elfInspector, + }) + ); + errors.push( + ...validateElectronIsolation(resourceDir, artifactFormat, elfInspector) + ); + if (foreignArch || errors.length > 0) { + return errors; + } + + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + const probeEnvironment = createRuntimeProbeEnvironment({ + environment, + nativeDir, + runtimeMode: profile.runtimeMode, + }); + const probeResult = probeRunner( + path.join(nativeDir, 'iptvnator_mpv_helper'), + ['--runtime-probe'], + { + encoding: 'utf8', + env: probeEnvironment, + killSignal: 'SIGKILL', + maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES, + timeout: RUNTIME_PROBE_TIMEOUT_MS, + windowsHide: true, + } + ); + errors.push(...validateProbeResult(probeResult)); + return errors; +} + +export function verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName, + runCommand = runVerifierCommand, + extractArtifact = extractLinuxArtifact, + metadataReader = readLinuxArtifactMetadata, + payloadVerifier = verifyExtractedLinuxFrameCopyRuntime, + elfInspector = defaultElfInspector, + probeRunner = runVerifierCommand, + environment = process.env, +}) { + const resolvedArtifactPath = path.resolve(artifactPath); + const artifactStat = fs.lstatSync(resolvedArtifactPath); + if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) { + throw new Error( + `Linux package artifact must be a regular file: ${resolvedArtifactPath}` + ); + } + const profile = resolveLinuxFrameCopyProfile(profileName); + const format = detectLinuxArtifactFormat(resolvedArtifactPath); + if (!profile.targets.includes(format)) { + throw new Error( + `Linux frame-copy profile "${profile.name}" does not include target "${format}".` + ); + } + + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-package-verifier-') + ); + try { + const extractionDestination = path.join(temporaryRoot, 'payload'); + const extractionRoot = extractArtifact({ + artifactPath: resolvedArtifactPath, + format, + destination: extractionDestination, + runCommand, + }); + if (format === 'snap') { + const snapMetadataErrors = + validateExtractedSnapMetadata(extractionRoot); + if (snapMetadataErrors.length > 0) { + throw new Error( + [ + `Linux frame-copy package verification failed for ${resolvedArtifactPath}:`, + ...snapMetadataErrors.map((error) => `- ${error}`), + ].join('\n') + ); + } + } + const resourceDir = findExtractedResourceDir(extractionRoot); + const metadata = metadataReader({ + artifactPath: resolvedArtifactPath, + format, + extractionRoot, + runCommand, + }); + const errors = payloadVerifier({ + resourceDir, + artifactFormat: format, + profileName: profile.name, + packageDependencies: metadata.dependencies, + declaredArch: metadata.declaredArch, + elfInspector, + probeRunner, + environment, + }); + if (errors.length > 0) { + throw new Error( + [ + `Linux frame-copy package verification failed for ${resolvedArtifactPath}:`, + ...errors.map((error) => `- ${error}`), + ].join('\n') + ); + } + const electronPath = path.join( + path.dirname(resourceDir), + 'iptvnator.bin' + ); + return { + artifactPath: resolvedArtifactPath, + format, + profileName: profile.name, + architecture: readElfArchitecture(electronPath), + }; + } finally { + fs.rmSync(temporaryRoot, { recursive: true, force: true }); + } +} + +function main() { + const options = parseVerifierArguments(process.argv.slice(2)); + const result = verifyLinuxFrameCopyArtifact(options); + process.stdout.write( + `Verified ${result.format} ${result.architecture} Linux frame-copy package (${result.profileName}): ${result.artifactPath}\n` + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n` + ); + process.exitCode = 1; + } +} diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs new file mode 100644 index 000000000..ad2b7154e --- /dev/null +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -0,0 +1,1860 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + createRuntimeProbeEnvironment, + detectLinuxArtifactFormat, + extractLinuxArtifact, + findAppImageSquashfsOffsets, + findExtractedResourceDir, + parseVerifierArguments, + readLinuxArtifactMetadata, + readElfArchitecture, + runVerifierCommand, + validateSystemPackageDependencies, + validateExtractedSnapMetadata, + verifyExtractedLinuxFrameCopyRuntime, + verifyLinuxFrameCopyArtifact, +} from './verify-linux-frame-copy-runtime.mjs'; + +const runtimeProbeContractUrl = new URL( + '../embedded-mpv/runtime-probe-contract.cjs', + import.meta.url +); +const runtimeProbeContractTypesUrl = new URL( + '../embedded-mpv/runtime-probe-contract.d.cts', + import.meta.url +); +const verifierUrl = new URL( + './verify-linux-frame-copy-runtime.mjs', + import.meta.url +); +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 +); +const SYSTEM_TARGETS = ['deb', 'pacman', 'rpm']; +const SYSTEM_MANIFEST = { + schemaVersion: 1, + origin: 'system-libmpv-frame-copy', + generatedAt: '2026-07-17T00:00:00.000Z', + platform: 'linux', + arch: 'x64', + profile: 'system', + runtimeMode: 'system', + targets: SYSTEM_TARGETS, + artifacts: { + addon: { + name: 'embedded_mpv.node', + regularFile: true, + readable: true, + }, + frameReader: { + name: 'embedded_mpv_frame_reader.node', + regularFile: true, + readable: true, + }, + helper: { + name: 'iptvnator_mpv_helper', + regularFile: true, + readable: true, + executable: true, + }, + }, + processIsolation: { + addonLoadsLibmpv: false, + readerLoadsLibmpv: false, + electronLoadsLibmpv: false, + helperLinksLibmpv: true, + helperRunpath: ['$ORIGIN/lib'], + }, + nativeViewFallback: 'process-isolated mpv --wid', + libmpvSoname: 'libmpv.so.2', + packageDependencies: { + deb: ['libmpv2', 'libegl1', 'libgl1', 'libgbm1'], + rpm: ['mpv-libs', 'libglvnd-egl', 'libglvnd-glx', 'mesa-libgbm'], + pacman: ['mpv', 'libglvnd', 'mesa'], + }, + runtimeFiles: [], + runtimeTotalBytes: 0, +}; +const DEB_SYSTEM_PACKAGE_DEPENDENCIES = [ + 'libmpv2', + 'libegl1', + 'libgl1', + 'libgbm1', +]; + +test('uses the shared frozen runtime probe resource contract', async () => { + assert.equal( + fs.existsSync(runtimeProbeContractUrl), + true, + 'the shared runtime probe contract must exist' + ); + assert.equal( + fs.existsSync(runtimeProbeContractTypesUrl), + true, + 'the shared runtime probe contract types must exist' + ); + + const { default: runtimeProbeContract } = await import( + runtimeProbeContractUrl.href + ); + assert.equal(Object.isFrozen(runtimeProbeContract), true); + assert.equal(runtimeProbeContract.RUNTIME_PROBE_TIMEOUT_MS, 3000); + assert.equal( + runtimeProbeContract.RUNTIME_PROBE_MAX_BUFFER_BYTES, + 16 * 1024 * 1024 + ); + assert.match( + fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + /readonly RUNTIME_PROBE_TIMEOUT_MS: 3000/ + ); + assert.match( + fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + /readonly RUNTIME_PROBE_MAX_BUFFER_BYTES: 16777216/ + ); + + const verifierSource = fs.readFileSync(verifierUrl, 'utf8'); + assert.match( + verifierSource, + /require\(['"]\.\.\/embedded-mpv\/runtime-probe-contract\.cjs['"]\)/ + ); + assert.doesNotMatch( + verifierSource, + /const RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/ + ); + + const backendContractsSource = fs.readFileSync( + backendRuntimeContractsUrl, + 'utf8' + ); + assert.match( + backendContractsSource, + /import runtimeProbeContract = require\(['"][^'"]*\/runtime-probe-contract\.cjs['"]\)/ + ); + assert.match( + backendContractsSource, + /export const \{\s*RUNTIME_PROBE_MAX_BUFFER_BYTES,\s*RUNTIME_PROBE_TIMEOUT_MS,?\s*\}\s*=\s*runtimeProbeContract/ + ); + assert.doesNotMatch( + 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', () => { + const backendProject = JSON.parse( + fs.readFileSync(electronBackendProjectUrl, 'utf8') + ); + const contractInputs = [ + '{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.cjs', + '{workspaceRoot}/tools/embedded-mpv/runtime-probe-contract.d.cts', + ]; + + for (const targetName of ['build', 'build-e2e', 'test', 'lint']) { + const inputs = backendProject.targets[targetName]?.inputs ?? []; + for (const contractInput of contractInputs) { + assert.ok( + inputs.includes(contractInput), + `${targetName} must track ${contractInput}` + ); + } + } + + assert.match( + backendProject.targets.lint.command, + /embedded-mpv-frame-copy-runtime\.ts/ + ); + assert.match( + backendProject.targets.lint.command, + /embedded-mpv-frame-copy-runtime\/\*\*\/\*\.ts/ + ); +}); + +function elfHeader(architecture) { + const machines = { + x64: 62, + arm64: 183, + armv7l: 40, + }; + const image = Buffer.alloc(64); + image.set([0x7f, 0x45, 0x4c, 0x46, 2, 1], 0); + image.writeUInt16LE(machines[architecture], 18); + return image; +} + +function createSystemPayload({ architecture = 'x64' } = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-layout-') + ); + const appDir = path.join(root, 'opt', 'IPTVnator'); + const resourceDir = path.join(appDir, 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + fs.writeFileSync( + path.join(appDir, 'iptvnator.bin'), + elfHeader(architecture) + ); + + if (architecture === 'x64') { + fs.writeFileSync(path.join(nativeDir, 'embedded_mpv.node'), 'addon', { + mode: 0o644, + }); + fs.writeFileSync( + path.join(nativeDir, 'embedded_mpv_frame_reader.node'), + 'reader', + { mode: 0o644 } + ); + fs.writeFileSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 'helper', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-runtime.json'), + `${JSON.stringify(SYSTEM_MANIFEST, null, 2)}\n`, + { mode: 0o644 } + ); + } else { + fs.writeFileSync( + path.join(nativeDir, 'embedded-mpv-unavailable.txt'), + `Unavailable for ${architecture}\n` + ); + } + + return { root, appDir, resourceDir, nativeDir }; +} + +function validElfInspector(binaryPath) { + const name = path.basename(binaryPath); + if (name === 'iptvnator_mpv_helper') { + return { + soname: null, + needed: ['libc.so.6', 'libmpv.so.2'], + rpath: [], + runpath: ['$ORIGIN/lib'], + }; + } + return { + soname: null, + needed: ['libc.so.6'], + rpath: [], + runpath: [], + }; +} + +function successfulProbeRunner() { + return { + status: 0, + signal: null, + stdout: '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}\n', + stderr: '', + }; +} + +test('detects every supported Linux package payload format', () => { + assert.equal(detectLinuxArtifactFormat('IPTVnator.AppImage'), 'appimage'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.deb'), 'deb'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.rpm'), 'rpm'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.pacman'), 'pacman'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.pkg.tar.zst'), 'pacman'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.snap'), 'snap'); + assert.equal(detectLinuxArtifactFormat('IPTVnator.flatpak'), 'flatpak'); + assert.throws( + () => detectLinuxArtifactFormat('IPTVnator.tar.gz'), + /Unsupported Linux package artifact/ + ); +}); + +test('parses the required artifact and profile arguments without evaluating paths', () => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-args-') + ); + const artifactPath = path.join(directory, 'package $(touch owned).deb'); + fs.writeFileSync(artifactPath, 'fixture'); + + try { + assert.deepEqual( + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'system', + ]), + { + artifactPath: path.resolve(artifactPath), + profileName: 'system', + } + ); + assert.throws( + () => parseVerifierArguments(['--artifact', artifactPath]), + /--profile/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'standard', + ]), + /Unsupported Linux frame-copy profile/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--artifact', + artifactPath, + '--profile', + 'system', + ]), + /duplicate --artifact/ + ); + assert.throws( + () => + parseVerifierArguments([ + '--artifact', + artifactPath, + '--profile', + 'system', + '--profile', + 'system', + ]), + /duplicate --profile/ + ); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +test('extracts every payload format with argument arrays and no shell', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-extract-') + ); + const invocations = []; + const runCommand = (command, args, options = {}) => { + invocations.push({ command, args: [...args], cwd: options.cwd }); + if (command === 'ostree' && args[0] === 'refs') { + return { + status: 0, + stdout: 'app/com.fourgray.iptvnator/x86_64/stable\n', + stderr: '', + }; + } + return { status: 0, stdout: '', stderr: '' }; + }; + + try { + for (const [format, fileName] of [ + ['appimage', 'IPTVnator.AppImage'], + ['deb', 'IPTVnator.deb'], + ['rpm', 'IPTVnator.rpm'], + ['pacman', 'IPTVnator.pacman'], + ['snap', 'IPTVnator.snap'], + ['flatpak', 'IPTVnator.flatpak'], + ]) { + const artifactPath = path.join(root, fileName); + const destination = path.join(root, `${format}-payload`); + fs.writeFileSync( + artifactPath, + format === 'appimage' + ? Buffer.concat([Buffer.alloc(4096), Buffer.from('hsqs')]) + : 'fixture' + ); + fs.mkdirSync(destination); + extractLinuxArtifact({ + artifactPath, + format, + destination, + runCommand, + }); + } + + assert.deepEqual( + invocations.map(({ command }) => command), + [ + 'unsquashfs', + 'dpkg-deb', + 'bsdtar', + 'bsdtar', + 'unsquashfs', + 'ostree', + 'flatpak', + 'ostree', + 'ostree', + ] + ); + assert.equal( + invocations.some( + ({ command, args }) => + ['sh', 'bash'].includes(command) || args.includes('-c') + ), + false + ); + assert.deepEqual(invocations[1].args.slice(0, 2), [ + '--extract', + path.join(root, 'IPTVnator.deb'), + ]); + assert.deepEqual(invocations[2].args.slice(0, 2), [ + '--extract', + '--file', + ]); + assert.deepEqual(invocations[4].args.slice(0, 2), [ + '-no-progress', + '-dest', + ]); + assert.equal(invocations[5].args[1], 'init'); + assert.ok(invocations[5].args.includes('--mode=archive-z2')); + assert.equal(invocations[8].args[0], 'checkout'); + assert.ok(invocations[8].args.includes('-U')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('gives unsquashfs a fresh destination for every AppImage and Snap extraction', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-unsquashfs-') + ); + const appImagePath = path.join(root, 'IPTVnator.AppImage'); + const snapPath = path.join(root, 'IPTVnator.snap'); + const appImageDestination = path.join(root, 'appimage-payload'); + const snapDestination = path.join(root, 'snap-payload'); + fs.writeFileSync( + appImagePath, + Buffer.concat([ + Buffer.alloc(128), + Buffer.from('hsqs'), + Buffer.alloc(64), + Buffer.from('hsqs'), + ]) + ); + fs.writeFileSync(snapPath, 'snap fixture'); + fs.mkdirSync(appImageDestination); + fs.mkdirSync(snapDestination); + let appImageAttempt = 0; + + try { + extractLinuxArtifact({ + artifactPath: appImagePath, + format: 'appimage', + destination: appImageDestination, + runCommand: (command) => { + assert.equal(command, 'unsquashfs'); + assert.equal(fs.existsSync(appImageDestination), false); + appImageAttempt += 1; + fs.mkdirSync(appImageDestination); + return { + status: appImageAttempt === 1 ? 1 : 0, + stdout: '', + stderr: '', + }; + }, + }); + assert.equal(appImageAttempt, 2); + + extractLinuxArtifact({ + artifactPath: snapPath, + format: 'snap', + destination: snapDestination, + runCommand: (command) => { + assert.equal(command, 'unsquashfs'); + assert.equal(fs.existsSync(snapDestination), false); + fs.mkdirSync(snapDestination); + return { status: 0, stdout: '', stderr: '' }; + }, + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initializes a user-checkout OSTree repository before importing Flatpak bundles', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-flatpak-repo-') + ); + const artifactPath = path.join(root, 'IPTVnator.flatpak'); + const destination = path.join(root, 'flatpak-payload'); + const invocations = []; + let initializedRepository = null; + fs.writeFileSync(artifactPath, 'flatpak fixture'); + + try { + extractLinuxArtifact({ + artifactPath, + format: 'flatpak', + destination, + runCommand: (command, args) => { + invocations.push([command, ...args]); + if (command === 'ostree' && args.includes('init')) { + initializedRepository = args + .find((argument) => argument.startsWith('--repo=')) + ?.slice('--repo='.length); + assert.ok(args.includes('--mode=archive-z2')); + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'flatpak') { + assert.equal(args[0], 'build-import-bundle'); + assert.equal(args[1], initializedRepository); + if (!initializedRepository) { + return { + status: 1, + stdout: '', + stderr: 'error: opening repo: opendir(objects): No such file or directory', + }; + } + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'ostree' && args[0] === 'refs') { + return { + status: 0, + stdout: 'app/com.fourgray.iptvnator/x86_64/stable\n', + stderr: '', + }; + } + if (command === 'ostree' && args[0] === 'checkout') { + assert.ok(args.includes('-U')); + return { status: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected command: ${command}`); + }, + }); + + assert.deepEqual( + invocations.map(([command, ...args]) => [ + command, + args.find((argument) => + [ + 'init', + 'build-import-bundle', + 'refs', + 'checkout', + ].includes(argument) + ), + ]), + [ + ['ostree', 'init'], + ['flatpak', 'build-import-bundle'], + ['ostree', 'refs'], + ['ostree', 'checkout'], + ] + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('locates AppImage SquashFS payloads without executing a foreign-arch runtime', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-appimage-') + ); + const artifactPath = path.join(root, 'arm64.AppImage'); + fs.writeFileSync( + artifactPath, + Buffer.concat([ + Buffer.alloc(128, 0x41), + Buffer.from('hsqs'), + Buffer.alloc(64), + Buffer.from('hsqs'), + ]) + ); + try { + assert.deepEqual(findAppImageSquashfsOffsets(artifactPath), [128, 196]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('finds one packaged native payload under arbitrary format roots', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-payload-') + ); + const resourceDir = path.join(root, 'opt', 'IPTVnator', 'resources'); + const nativeDir = path.join( + resourceDir, + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(nativeDir, { recursive: true }); + + try { + assert.equal(findExtractedResourceDir(root), resourceDir); + const duplicateNativeDir = path.join( + root, + 'duplicate', + 'resources', + 'app.asar.unpacked', + 'electron-backend', + 'native' + ); + fs.mkdirSync(duplicateNativeDir, { recursive: true }); + assert.throws( + () => findExtractedResourceDir(root), + /exactly one embedded MPV native payload/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('reads x64, arm64, and armv7 ELF architectures without host execution', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-elf-') + ); + try { + for (const architecture of ['x64', 'arm64', 'armv7l']) { + const binaryPath = path.join(root, architecture); + fs.writeFileSync(binaryPath, elfHeader(architecture)); + assert.equal(readElfArchitecture(binaryPath), architecture); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('requires every direct helper runtime dependency for DEB, RPM, and Pacman', () => { + assert.deepEqual( + validateSystemPackageDependencies('deb', [ + 'libmpv2 (>= 0.35)', + 'libegl1', + 'libgl1', + 'libgbm1', + 'libc6', + ]), + [] + ); + assert.deepEqual( + validateSystemPackageDependencies('rpm', [ + 'mpv-libs', + 'libglvnd-egl', + 'libglvnd-glx', + 'mesa-libgbm', + 'glibc', + ]), + [] + ); + assert.deepEqual( + validateSystemPackageDependencies('pacman', [ + 'mpv>=0.35', + 'libglvnd', + 'mesa', + 'glibc', + ]), + [] + ); + assert.match( + validateSystemPackageDependencies('deb', [ + 'libmpv2', + 'libegl1', + 'libgbm1', + ])[0], + /libgl1/ + ); +}); + +test('reads DEB and RPM metadata without shell pipelines', () => { + const invocations = []; + const runCommand = (command, args) => { + invocations.push({ command, args: [...args] }); + if (command === 'dpkg-deb' && args.at(-1) === 'Architecture') { + return { status: 0, stdout: 'amd64\n', stderr: '' }; + } + if (command === 'dpkg-deb') { + return { + status: 0, + stdout: 'libmpv2 (>= 0.35), libc6\n', + stderr: '', + }; + } + if (command === 'rpm' && args.includes('%{ARCH}\\n')) { + return { status: 0, stdout: 'x86_64\n', stderr: '' }; + } + return { + status: 0, + stdout: 'mpv-libs\nglibc\n', + stderr: '', + }; + }; + + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package name.deb', + format: 'deb', + extractionRoot: '/tmp/payload', + runCommand, + }), + { + declaredArch: 'x64', + dependencies: ['libmpv2 (>= 0.35)', 'libc6'], + } + ); + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package name.rpm', + format: 'rpm', + extractionRoot: '/tmp/payload', + runCommand, + }), + { + declaredArch: 'x64', + dependencies: ['mpv-libs', 'glibc'], + } + ); + assert.equal( + invocations.some( + ({ command, args }) => + ['sh', 'bash'].includes(command) || args.includes('-c') + ), + false + ); +}); + +test('reads Pacman architecture and dependencies from the extracted .PKGINFO', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-pacman-') + ); + fs.writeFileSync( + path.join(root, '.PKGINFO'), + [ + 'pkgname = iptvnator', + 'arch = x86_64', + 'depend = mpv>=0.35', + 'depend = glibc', + '', + ].join('\n') + ); + try { + assert.deepEqual( + readLinuxArtifactMetadata({ + artifactPath: '/tmp/package.pacman', + format: 'pacman', + extractionRoot: root, + }), + { + declaredArch: 'x64', + dependencies: ['mpv>=0.35', 'glibc'], + } + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('validates an x64 system payload and executes one bounded helper probe', () => { + const fixture = createSystemPayload(); + const probeCalls = []; + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: [ + 'libmpv2 (>= 0.35)', + 'libegl1', + 'libgl1', + 'libgbm1', + ], + elfInspector: validElfInspector, + probeRunner(command, args, options) { + probeCalls.push({ command, args, options }); + return successfulProbeRunner(); + }, + environment: { + PATH: '/usr/bin', + LD_AUDIT: '/host/can-inject-audit.so', + LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_PRELOAD: '/host/can-inject.so', + }, + }); + assert.deepEqual(errors, []); + assert.equal(probeCalls.length, 1); + assert.equal( + probeCalls[0].command, + path.join(fixture.nativeDir, 'iptvnator_mpv_helper') + ); + assert.deepEqual(probeCalls[0].args, ['--runtime-probe']); + assert.deepEqual(probeCalls[0].options, { + encoding: 'utf8', + env: { PATH: '/usr/bin' }, + killSignal: 'SIGKILL', + maxBuffer: 16 * 1024 * 1024, + timeout: 3000, + windowsHide: true, + }); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('rejects any stale embedded MPV native payload hidden inside app.asar', () => { + const fixture = createSystemPayload(); + fs.writeFileSync(path.join(fixture.resourceDir, 'app.asar'), 'fixture'); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + asarListPackage: () => [ + '/electron-backend/main.js', + '/electron-backend/native/iptvnator_mpv_helper', + '/electron-backend/native/lib/libmpv.so.2', + ], + }); + assert.match( + errors.join('\n'), + /app\.asar must not contain embedded MPV native payloads.*iptvnator_mpv_helper.*libmpv\.so\.2/s + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('excludes only Snap template library roots from Electron isolation checks', () => { + const fixture = createSystemPayload({ architecture: 'arm64' }); + const packageLibraryDirs = [ + path.join(fixture.appDir, 'lib', 'x86_64-linux-gnu'), + path.join(fixture.appDir, 'usr', 'lib', 'x86_64-linux-gnu'), + ]; + const electronLibrary = path.join(fixture.appDir, 'libelectron-extra.so'); + for (const [index, packageLibraryDir] of packageLibraryDirs.entries()) { + const packageLibraryTarget = path.join( + packageLibraryDir, + `libpackage-${index}.so.0.12.2` + ); + fs.mkdirSync(packageLibraryDir, { recursive: true }); + fs.writeFileSync(packageLibraryTarget, 'package-managed library'); + fs.symlinkSync( + path.basename(packageLibraryTarget), + path.join(packageLibraryDir, `libpackage-${index}.so.0`) + ); + } + fs.writeFileSync(electronLibrary, 'electron library'); + + const inspectedPaths = []; + const isPackageLibraryPath = (binaryPath) => + packageLibraryDirs.some((packageLibraryDir) => + path + .resolve(binaryPath) + .startsWith(`${path.resolve(packageLibraryDir)}${path.sep}`) + ); + const ownershipScopedElfInspector = (binaryPath) => { + inspectedPaths.push(binaryPath); + if (isPackageLibraryPath(binaryPath)) { + throw new Error( + 'package-manager library tree crossed the Electron ownership boundary' + ); + } + return validElfInspector(binaryPath); + }; + + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector: ownershipScopedElfInspector, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }), + [] + ); + assert.ok(inspectedPaths.includes(electronLibrary)); + assert.equal(inspectedPaths.some(isPackageLibraryPath), false); + + const isolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (binaryPath === electronLibrary) { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return ownershipScopedElfInspector(binaryPath); + }, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + isolationErrors.join('\n'), + /Electron library must not link libmpv.*libelectron-extra\.so/ + ); + + const nestedElectronLibrary = path.join( + fixture.appDir, + 'future-electron-runtime', + 'libfuture-electron.so' + ); + fs.mkdirSync(path.dirname(nestedElectronLibrary), { + recursive: true, + }); + fs.writeFileSync(nestedElectronLibrary, 'nested electron library'); + const nestedIsolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (binaryPath === nestedElectronLibrary) { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return ownershipScopedElfInspector(binaryPath); + }, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + nestedIsolationErrors.join('\n'), + /Electron library must not link libmpv.*libfuture-electron\.so/ + ); + + fs.symlinkSync( + path.basename(electronLibrary), + path.join(fixture.appDir, 'libEGL.so') + ); + const symlinkErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector: ownershipScopedElfInspector, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + symlinkErrors.join('\n'), + /Electron library must be a regular file: .*libEGL\.so/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('rejects helper probes terminated by a signal or hard timeout', () => { + for (const probeResult of [ + { + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + { + error: Object.assign(new Error('spawnSync helper ETIMEDOUT'), { + code: 'ETIMEDOUT', + }), + status: null, + signal: 'SIGKILL', + stdout: '', + stderr: '', + }, + ]) { + const fixture = createSystemPayload(); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner() { + return probeResult; + }, + }); + assert.match( + errors.join('\n'), + /(?:runtime probe terminated by signal SIGKILL|Unable to execute .*ETIMEDOUT)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('requires exact Snap graphics layouts and plugs used by the app', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-snap-metadata-') + ); + const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(root, 'graphics')); + + const validSnapYaml = [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'summary: "*literal &anchor !tag <<: is quoted"', + '# *commented-alias &commented-anchor !commented-tag', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - shared-memory', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n'); + + try { + fs.writeFileSync(snapYamlPath, validSnapYaml); + assert.deepEqual(validateExtractedSnapMetadata(root), []); + + fs.truncateSync(snapYamlPath, 256 * 1024 + 1); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /Snap metadata exceeds.*size limit/i + ); + fs.writeFileSync(snapYamlPath, validSnapYaml); + + fs.rmSync(path.join(root, 'graphics'), { recursive: true }); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /empty graphics content mount directory/i + ); + fs.mkdirSync(path.join(root, 'graphics')); + fs.writeFileSync(path.join(root, 'graphics', 'unexpected'), 'data'); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount directory must be empty/i + ); + fs.rmSync(path.join(root, 'graphics'), { recursive: true }); + const outsideGraphics = path.join(root, 'outside-graphics'); + fs.mkdirSync(outsideGraphics); + fs.symlinkSync(outsideGraphics, path.join(root, 'graphics'), 'dir'); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount.*real directory/i + ); + fs.rmSync(path.join(root, 'graphics')); + fs.mkdirSync(path.join(root, 'graphics')); + fs.chmodSync(path.join(root, 'graphics'), 0o700); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /graphics content mount directory must have mode 0755/i + ); + fs.chmodSync(path.join(root, 'graphics'), 0o755); + + for (const [mutate, expected] of [ + [ + (contents) => contents.replace('base: core22', 'base: core20'), + /base: core22/i, + ], + [ + (contents) => + contents.replace( + 'confinement: strict', + 'confinement: classic' + ), + /confinement: strict/i, + ], + [ + (contents) => + contents.replace( + 'layout:\n /usr/share/libdrm:\n bind: $SNAP/graphics/libdrm\n /usr/share/drirc.d:\n symlink: $SNAP/graphics/drirc.d\n', + '' + ), + /exact Snap graphics layout contract/i, + ], + [ + (contents) => + contents.replace( + ' /usr/share/drirc.d:\n', + ' /usr/share/extra:\n bind: $SNAP/graphics/extra\n /usr/share/drirc.d:\n' + ), + /exactly the libdrm and drirc.d entries/i, + ], + [ + (contents) => + contents.replace( + ' /usr/share/drirc.d:\n', + ' /usr/share/libdrm:\n bind: $SNAP/graphics/libdrm\n /usr/share/drirc.d:\n' + ), + /layout paths must be unique/i, + ], + [ + (contents) => + contents.replace( + ' bind: $SNAP/graphics/libdrm', + ' bind: $SNAP/graphics/wrong-libdrm' + ), + /libdrm.*bind: \$SNAP\/graphics\/libdrm/i, + ], + [ + (contents) => + contents.replace( + ' symlink: $SNAP/graphics/drirc.d', + ' symlink: $SNAP/graphics/wrong-drirc.d' + ), + /drirc.d.*symlink: \$SNAP\/graphics\/drirc.d/i, + ], + [ + (contents) => + contents.replace( + ' bind: $SNAP/graphics/libdrm\n', + ' bind: $SNAP/graphics/libdrm\n type: directory\n' + ), + /libdrm layout.*exactly bind/i, + ], + [ + (contents) => + contents.replace( + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform\n', + '' + ), + /SNAP_DESKTOP_RUNTIME.*\$SNAP\/gnome-platform/i, + ], + [ + (contents) => + contents.replace( + '$SNAP/gnome-platform', + '$SNAP/hostile-platform' + ), + /SNAP_DESKTOP_RUNTIME.*\$SNAP\/gnome-platform/i, + ], + [ + (contents) => + contents.replace(' private: true', ' private: false'), + /private: true/, + ], + [ + (contents) => contents.replace(' - shared-memory\n', ''), + /app.*shared-memory plug/i, + ], + [ + (contents) => contents.replace(' - graphics-core22\n', ''), + /app.*graphics-core22 plug/i, + ], + [ + (contents) => + contents.replace( + ' shared-memory:\n interface: shared-memory\n private: true\n', + '' + ), + /top-level shared-memory plug/i, + ], + [ + (contents) => + contents.replace( + ' graphics-core22:\n interface: content\n target: $SNAP/graphics\n default-provider: mesa-core22\n', + '' + ), + /top-level graphics-core22 plug/i, + ], + [ + (contents) => + contents.replace( + ' interface: content', + ' interface: opengl' + ), + /graphics-core22.*interface: content/i, + ], + [ + (contents) => + contents.replace( + ' target: $SNAP/graphics', + ' target: $SNAP/wrong-graphics' + ), + /graphics-core22.*target: \$SNAP\/graphics/i, + ], + [ + (contents) => + contents.replace( + ' default-provider: mesa-core22', + ' default-provider: wrong-provider' + ), + /graphics-core22.*default-provider: mesa-core22/i, + ], + [ + (contents) => + contents.replace( + ' default-provider: mesa-core22\n', + ' default-provider: mesa-core22\n source: graphics-core22\n' + ), + /graphics-core22 plug.*exactly interface, target, and default-provider/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' duplicate-graphics:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + '', + ].join('\n') + ), + /exactly one plug.*graphics-core22 contract/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' competing-graphics:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: hostile-provider', + ' content: alternate-graphics', + '', + ].join('\n') + ), + /exactly one plug.*target \$SNAP\/graphics/i, + ], + [ + (contents) => + contents.replace( + ' private: true\n', + ' private: true\n shared-memory: named-area\n' + ), + /private shared-memory plug.*exactly interface and private/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' second-shared-memory:', + ' interface: shared-memory', + ' private: true', + '', + ].join('\n') + ), + /exactly one plug.*shared-memory interface/i, + ], + [ + (contents) => + `${contents}slots:\n shared-memory:\n interface: shared-memory\n`, + /must not declare.*shared-memory slot/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + '\nplugs:\n other: { interface: shared-memory }\n' + ), + /plug declarations.*block mappings/i, + ], + [ + (contents) => + `${contents}slots: { leak: { interface: shared-memory } }\n`, + /slots.*block mappings/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + '\nplugs:\n network:\n interface: network\n network:\n interface: network\n' + ), + /plug keys.*unique/i, + ], + [ + (contents) => + [ + 'shared-interface: &shared.interface shared-memory', + contents.replace( + '\nplugs:\n', + '\nplugs:\n alias-plug:\n interface: *shared.interface\n' + ), + ].join('\n'), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + [ + 'shared-interface: &sharedInterface shared-memory', + `${contents}slots:\n alias-slot:\n interface: *sharedInterface\n`, + ].join('\n'), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + contents.replace( + '\nplugs:\n', + '\nplugs:\n tagged:\n interface: !shared-memory shared-memory\n' + ), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + [ + 'shared-plug: &sharedPlug', + ' interface: network', + contents.replace( + '\nplugs:\n', + '\nplugs:\n merged:\n <<: *sharedPlug\n' + ), + ].join('\n'), + /anchors, aliases, merge keys, or custom tags/i, + ], + [ + (contents) => + contents.replace( + [ + ' plugs:', + ' - desktop', + ' - shared-memory', + ].join('\n'), + [ + ' plugs:', + ' nested:', + ' - shared-memory', + ].join('\n') + ), + /scalar sequence.*shared-memory/i, + ], + ]) { + fs.writeFileSync(snapYamlPath, mutate(validSnapYaml)); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + expected + ); + } + + fs.rmSync(snapYamlPath); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /Missing extracted Snap metadata/ + ); + + fs.writeFileSync(path.join(root, 'outside.yaml'), validSnapYaml); + fs.symlinkSync( + path.join(root, 'outside.yaml'), + snapYamlPath, + process.platform === 'win32' ? 'file' : undefined + ); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + /regular file/ + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +const SNAP_METADATA_WITH_LITERAL_HASHES = [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'summary: "quoted # is scalar data"', + 'description: | # block scalar header comment', + ' Block scalar # stays literal.', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - shared-memory', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', +].join('\n'); + +for (const [kind, mutate, expected] of [ + [ + 'plug', + (contents) => + contents.replace( + '\nplugs:\n', + [ + '', + 'plugs:', + ' hidden-extra-plug:', + ' interface: shared-memory # trailing comment', + '', + ].join('\n') + ), + /exactly one plug.*shared-memory interface/i, + ], + [ + 'slot', + (contents) => + `${contents}slots:\n hidden-slot:\n interface: shared-memory # trailing comment\n`, + /must not declare.*shared-memory slot/i, + ], +]) { + test(`rejects an extra Snap shared-memory ${kind} with a trailing comment`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-snap-comments-') + ); + const snapYamlPath = path.join(root, 'meta', 'snap.yaml'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(root, 'graphics')); + + try { + fs.writeFileSync(snapYamlPath, SNAP_METADATA_WITH_LITERAL_HASHES); + assert.deepEqual(validateExtractedSnapMetadata(root), []); + + fs.writeFileSync( + snapYamlPath, + mutate(SNAP_METADATA_WITH_LITERAL_HASHES) + ); + assert.match( + validateExtractedSnapMetadata(root).join('\n'), + expected + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('artifact verification enforces Snap metadata for x64 and ARM payloads', () => { + for (const architecture of ['x64', 'arm64', 'armv7l']) { + const fixture = createSystemPayload({ architecture }); + const artifactPath = path.join(fixture.root, 'package.snap'); + const snapYamlPath = path.join(fixture.root, 'meta', 'snap.yaml'); + fs.writeFileSync(artifactPath, 'fixture'); + fs.mkdirSync(path.dirname(snapYamlPath), { recursive: true }); + fs.mkdirSync(path.join(fixture.root, 'graphics')); + fs.writeFileSync( + snapYamlPath, + [ + 'name: iptvnator', + 'base: core22', + 'confinement: strict', + 'apps:', + ' iptvnator:', + ' command: iptvnator', + ' environment:', + ' SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform', + ' plugs:', + ' - desktop', + ' - graphics-core22', + 'plugs:', + ' shared-memory:', + ' interface: shared-memory', + ' private: true', + ' graphics-core22:', + ' interface: content', + ' target: $SNAP/graphics', + ' default-provider: mesa-core22', + 'layout:', + ' /usr/share/libdrm:', + ' bind: $SNAP/graphics/libdrm', + ' /usr/share/drirc.d:', + ' symlink: $SNAP/graphics/drirc.d', + '', + ].join('\n') + ); + let payloadVerifierCalls = 0; + + const verify = () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'portable', + extractArtifact() { + return fixture.root; + }, + metadataReader() { + return { declaredArch: architecture, dependencies: [] }; + }, + payloadVerifier() { + payloadVerifierCalls += 1; + return []; + }, + }); + + try { + assert.throws(verify, /app.*shared-memory plug/i); + assert.equal(payloadVerifierCalls, 0); + + fs.writeFileSync( + snapYamlPath, + fs + .readFileSync(snapYamlPath, 'utf8') + .replace(' - desktop\n', ' - shared-memory\n') + ); + assert.deepEqual(verify(), { + artifactPath, + format: 'snap', + profileName: 'portable', + architecture, + }); + assert.equal(payloadVerifierCalls, 1); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('bundled probes remove ambient loader paths and use only packaged libraries', () => { + assert.deepEqual( + createRuntimeProbeEnvironment({ + environment: { + PATH: '/usr/bin', + BASH_ENV: '/host/hostile-bash-env', + ENV: '/host/hostile-shell-env', + BASHOPTS: 'extdebug', + SHELLOPTS: 'xtrace', + PS4: '$(/host/hostile-trace-hook)', + BASH_XTRACEFD: '9', + CDPATH: '/host/hostile-cdpath', + 'BASH_FUNC_dirname%%': + '() { printf /host/hostile-provider-root; }', + LD_AUDIT: '/host/can-inject-audit.so', + LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', + LD_ORIGIN_PATH: '/host/can-change-origin', + LD_PRELOAD: '/host/can-inject.so', + __EGL_EXTERNAL_PLATFORM_CONFIG_DIRS: + '/host/egl/external-platform', + __EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES: + '/host/egl/external-platform.json', + __EGL_VENDOR_LIBRARY_DIRS: '/host/egl/vendor', + __EGL_VENDOR_LIBRARY_FILENAMES: '/host/egl/vendor/host.json', + GBM_BACKEND: 'host-gbm', + GBM_BACKENDS_PATH: '/host/gbm', + LIBGL_DRIVERS_PATH: '/host/dri', + MESA_LOADER_DRIVER_OVERRIDE: 'host-dri', + LIBVA_DRIVER_NAME: 'host-va', + LIBVA_DRIVERS_PATH: '/host/va', + VDPAU_DRIVER_PATH: '/host/vdpau', + VK_DRIVER_FILES: '/host/vulkan/driver.json', + VK_ICD_FILENAMES: '/host/vulkan/icd.json', + VK_ADD_DRIVER_FILES: '/host/vulkan/add-driver.json', + VK_ADD_LAYER_PATH: '/host/vulkan/add-layer', + VK_IMPLICIT_LAYER_PATH: '/host/vulkan/implicit-layer', + VK_ADD_IMPLICIT_LAYER_PATH: '/host/vulkan/add-implicit-layer', + VK_LAYER_PATH: '/host/vulkan/layer', + LIBGL_ALWAYS_SOFTWARE: '1', + }, + nativeDir: '/package/resources/native', + runtimeMode: 'bundled', + }), + { + PATH: '/usr/bin', + LIBGL_ALWAYS_SOFTWARE: '1', + LD_LIBRARY_PATH: '/package/resources/native/lib', + } + ); +}); + +test('rejects helper probe framing and fields that runtime capability rejects', () => { + const validPayload = + '{"protocol":1,"usable":true,"libmpv":"0.41.0","renderApi":"egl"}'; + for (const stdout of [ + validPayload, + `${validPayload}\n\n`, + `${validPayload.slice(0, -1)},"extra":true}\n`, + ]) { + const fixture = createSystemPayload(); + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner() { + return { + status: 0, + signal: null, + stdout, + stderr: '', + }; + }, + }); + assert.match( + errors.join('\n'), + /runtime probe (?:must emit|did not return)/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('reports missing helper, wrong mode, wrong profile, isolation, and loader failures', () => { + const cases = [ + { + mutate({ nativeDir }) { + fs.rmSync(path.join(nativeDir, 'iptvnator_mpv_helper')); + }, + expected: /Missing embedded MPV frame-copy helper/, + }, + { + mutate({ nativeDir }) { + fs.chmodSync( + path.join(nativeDir, 'iptvnator_mpv_helper'), + 0o644 + ); + }, + expected: /must have mode 0755/, + }, + { + profileName: 'portable', + expected: /does not include target "deb"/, + }, + { + elfInspector(binaryPath) { + if (path.basename(binaryPath) === 'embedded_mpv.node') { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return validElfInspector(binaryPath); + }, + expected: /addon must not link libmpv/, + }, + { + probeRunner() { + return { + status: 127, + signal: null, + stdout: '', + stderr: 'error while loading shared libraries: libmpv.so.2', + }; + }, + expected: /runtime probe failed with status 127.*libmpv\.so\.2/s, + }, + ]; + + for (const testCase of cases) { + const fixture = createSystemPayload(); + try { + testCase.mutate?.(fixture); + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: testCase.profileName ?? 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: testCase.elfInspector ?? validElfInspector, + probeRunner: testCase.probeRunner ?? successfulProbeRunner, + }); + assert.match(errors.join('\n'), testCase.expected); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } + } +}); + +test('rejects an x64 package manifest produced from a target subset', () => { + const fixture = createSystemPayload(); + const manifestPath = path.join( + fixture.nativeDir, + 'embedded-mpv-runtime.json' + ); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + manifest.targets = ['deb']; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + + try { + const errors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: DEB_SYSTEM_PACKAGE_DEPENDENCIES, + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + errors.join('\n'), + /manifest targets.*must equal \["deb","pacman","rpm"\]/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('requires marker-only foreign packages, scans Electron, and never probes', () => { + const fixture = createSystemPayload({ architecture: 'arm64' }); + let probeCalls = 0; + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'appimage', + profileName: 'portable', + packageDependencies: [], + elfInspector: validElfInspector, + probeRunner() { + probeCalls += 1; + return successfulProbeRunner(); + }, + }), + [] + ); + assert.equal(probeCalls, 0); + + const isolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'appimage', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (path.basename(binaryPath) === 'iptvnator.bin') { + return { + soname: null, + needed: ['/tmp/libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return validElfInspector(binaryPath); + }, + probeRunner() { + probeCalls += 1; + return successfulProbeRunner(); + }, + }); + assert.match( + isolationErrors.join('\n'), + /Electron binary must not link libmpv/ + ); + assert.equal(probeCalls, 0); + + for (const forbiddenDependency of DEB_SYSTEM_PACKAGE_DEPENDENCIES) { + const dependencyErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'deb', + profileName: 'system', + packageDependencies: [forbiddenDependency, 'libc6'], + declaredArch: 'arm64', + elfInspector: validElfInspector, + probeRunner: successfulProbeRunner, + }); + assert.match( + dependencyErrors.join('\n'), + new RegExp( + `must not declare frame-copy dependency ${forbiddenDependency}` + ) + ); + } + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + +test('always removes its temporary extraction root after a verifier failure', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-cleanup-parent-') + ); + const artifactPath = path.join(root, 'package.deb'); + fs.writeFileSync(artifactPath, 'fixture'); + let extractionDestination; + + try { + assert.throws( + () => + verifyLinuxFrameCopyArtifact({ + artifactPath, + profileName: 'system', + extractArtifact({ destination }) { + extractionDestination = destination; + fs.writeFileSync( + path.join(path.dirname(destination), 'sentinel'), + 'temporary' + ); + throw new Error('intentional extraction failure'); + }, + }), + /intentional extraction failure/ + ); + assert.equal( + fs.existsSync(path.dirname(extractionDestination)), + false, + 'temporary verifier root must be removed' + ); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/vendor/embedded-mpv/README.md b/vendor/embedded-mpv/README.md index 568b2cdb4..6943b7936 100644 --- a/vendor/embedded-mpv/README.md +++ b/vendor/embedded-mpv/README.md @@ -11,10 +11,37 @@ Generated architecture folders are expected at: - `vendor/embedded-mpv/linux-x64/` Each generated folder must contain `include/mpv/client.h` and -`runtime-manifest.json`. macOS and Windows folders also contain platform -runtime/build inputs under `lib/` or `bin/`. The binary runtime directories are -ignored by git by default; generate, stage, or restore them in release packaging -jobs before building the Electron backend. +`runtime-manifest.json`. Platform runtime/build inputs live under `lib/` or +`bin/`. In particular, `linux-x64/lib/` contains the pinned, dynamically linked +LGPL-compatible libmpv closure used to link the out-of-process frame-copy +helper. `linux-x64/notices/` contains the generated +`embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and exact +`licenses//**` tree. The generated runtime directories are ignored by +git; generate, stage, or restore them before building the Electron backend. -Linux uses this directory for MPV headers and build metadata only. Linux -packages launch the system `mpv` executable and must not bundle `libmpv.so`. +Linux package profiles consume that one staged x64 source runtime differently: + +- DEB/RPM/Pacman remove the private closure and declare the system libmpv + dependency. +- AppImage/Snap/Flatpak retain the manifest-declared closure under + `app.asar.unpacked/electron-backend/native/lib/` and flatten the validated + notice manifest, aggregate notice, and `licenses/**` tree beside it. +- The strict Snap resolves the helper's remaining graphics interfaces through + the external `mesa-core22` content provider at `$SNAP/graphics`; that shared + provider is not staged below `vendor/embedded-mpv/`, bundled into IPTVnator, + or included in IPTVnator's source/notices archive. +- DEB/RPM/Pacman and marker-only packages do not retain bundled-runtime + notices or license files. +- Non-x64 Linux packages retain no native artifacts and ship only the + unavailable marker. +- Electron Builder excludes the staged native tree from `app.asar`; only + `afterPack` writes the profile-specific unpacked payload, and package + verification rejects archived native entries. + +The DEB dependency is specifically `libmpv2` (verified on Ubuntu 24.04+). +Ubuntu 22.04 provides `libmpv1`; use the x64 AppImage there. + +Only `iptvnator_mpv_helper` may link libmpv. Electron, +`embedded_mpv.node`, and `embedded_mpv_frame_reader.node` must remain free of +direct libmpv dependencies. See `tools/embedded-mpv/README.md` and +`docs/architecture/embedded-mpv-native.md`.