diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml index 6f6fa9f4e..5c16e6ef2 100644 --- a/.github/workflows/publish-snap.yaml +++ b/.github/workflows/publish-snap.yaml @@ -80,5 +80,7 @@ jobs: while IFS= read -r SNAP_NAME; do SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}" echo "Publishing public release asset: ${SNAP_NAME}" + # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke. + # GitHub Actions never promotes automatically. snapcraft upload --release=edge "${SNAP_FILE}" done diff --git a/AGENTS.md b/AGENTS.md index 21fb59b4b..af8006709 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,8 +252,10 @@ Key files: `linux-frame-copy-runtime-sources.tar.xz` for the current checkout on every run while retaining exact commit/submodule records. Automated Snap Store publication is allowed only after a public `v*` GitHub release contains - both the Snap assets and exactly one matching source archive. Canonical - maintenance docs: + both the Snap assets and exactly one matching source archive. The workflow + uploads only to the Store's edge channel. Candidate/stable promotion is + manual after installed-Snap frame-copy and missing-runtime fallback smoke; + GitHub Actions never promotes automatically. Canonical maintenance docs: `docs/architecture/embedded-mpv-native.md` and `tools/embedded-mpv/README.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 28560ba62..918fc8851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -645,9 +645,11 @@ engine` (restart required) or VCS-metadata-free `linux-frame-copy-runtime-sources.tar.xz` for the current checkout while preserving exact commit/submodule records. Snap Store publication runs only from a public `v*` GitHub release that already - contains the Snap assets and exactly one source archive. On Windows, package - validation requires the exact MPV DLL named by the helper's PE import table - beside the executable. + contains the Snap assets and exactly one source archive. The workflow uploads + only to the Store's edge channel. Candidate/stable promotion is manual after + installed-Snap frame-copy and missing-runtime fallback smoke; GitHub Actions + never promotes automatically. On Windows, package validation requires the + exact MPV DLL named by the helper's PE import table beside the executable. Backend adapter: `apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.ts`; shared-controls adapter: diff --git a/apps/electron-backend/project.json b/apps/electron-backend/project.json index fc23eea78..617fd098d 100644 --- a/apps/electron-backend/project.json +++ b/apps/electron-backend/project.json @@ -36,7 +36,9 @@ "inputs": [ "production", "^production", - "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + "{workspaceRoot}/apps/electron-backend/native/build/Release/**", + "{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 +100,9 @@ "inputs": [ "production", "^production", - "{workspaceRoot}/apps/electron-backend/native/build/Release/**" + "{workspaceRoot}/apps/electron-backend/native/build/Release/**", + "{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 +197,23 @@ } }, "lint": { - "command": "eslint apps/electron-backend/**/*.ts" + "inputs": [ + "default", + "{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/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/services/embedded-mpv-frame-copy-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts deleted file mode 100644 index 679ad27c1..000000000 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts +++ /dev/null @@ -1,1396 +0,0 @@ -import { spawnSync } from 'child_process'; -import { createHash } from 'crypto'; -import { - accessSync, - chmodSync, - constants as fsConstants, - lstatSync, - mkdirSync, - mkdtempSync, - readFileSync, - readdirSync, - rmSync, - symlinkSync, - unlinkSync, - writeFileSync, -} from 'fs'; -import { tmpdir } from 'os'; -import path from 'path'; -import { - createLinuxFrameCopyHelperEnvironment, - createEmbeddedMpvFrameCopyRuntimeProbe, - EmbeddedMpvFrameCopyRuntimeDependencies, -} from './embedded-mpv-frame-copy-runtime'; - -const SUCCESS_OUTPUT = - '{"protocol":1,"usable":true,"libmpv":"2.3","renderApi":"egl"}\n'; - -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.', - }, -]; - -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', - 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', - }, -}; - -interface RuntimeFile { - name: string; - size: number; - sha256: string; -} - -interface RuntimeFixture { - nativeDir: string; - helperPath: string; - manifestPath: string; - manifest: Record; - runtimeContents: Record; -} - -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); - ( - packages.libplacebo as typeof packages.libplacebo & { - sourceSubmodules: string[]; - } - ).sourceSubmodules = [`${'a'.repeat(40)} 3rdparty/example`]; - 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), - }; -} - -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 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', - rpm: 'mpv-libs', - pacman: 'mpv', - }, - runtimeFiles, - runtimeTotalBytes: runtimeFiles.reduce( - (total, runtimeFile) => total + runtimeFile.size, - 0 - ), - ...(bundled - ? { - runtimeDependencyClosure, - externalSystemLibraries, - 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, - }; -} - -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, - sourceRuntime, - }; - fixture.manifest = manifest; - writeManifest(fixture.manifestPath, manifest); - return fixture; -} - -function probeDevelopmentRuntime( - probeRuntime: ReturnType, - helperPath: string -) { - return ( - probeRuntime as unknown as ( - path: string, - contract: 'development' - ) => ReturnType - )(helperPath, 'development'); -} - -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 - ); -} - -function writeManifest( - manifestPath: string, - manifest: Record -): void { - writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`, { - mode: 0o644, - }); - chmodSync(manifestPath, 0o644); -} - -function cloneManifest(manifest: T): T { - return JSON.parse(JSON.stringify(manifest)) as T; -} - -describe('createLinuxFrameCopyHelperEnvironment', () => { - it('removes ambient loader overrides for system packages', () => { - expect( - createLinuxFrameCopyHelperEnvironment( - { - PATH: '/usr/bin', - HOME: '/home/user', - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_PRELOAD: '/tmp/inject.so', - }, - '/opt/iptvnator/native', - 'system' - ) - ).toEqual({ - PATH: '/usr/bin', - HOME: '/home/user', - }); - }); - - it('keeps trusted Snap GL roots ahead of generic Snap 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(':'), - LD_LIBRARY_PATH: '/tmp/hostile-libs', - LD_PRELOAD: '/tmp/inject.so', - }, - nativeDir, - 'bundled' - ) - ).toEqual({ - 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(':'), - LD_LIBRARY_PATH: [ - path.join(nativeDir, 'lib'), - '/var/lib/snapd/lib/gl', - '/var/lib/snapd/lib/gl/nvidia', - 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('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_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', - }); - }); -}); - -describe('embedded-mpv-frame-copy-runtime', () => { - let rootDir: string; - let spawnRuntimeProbe: jest.Mock; - let fileSystem: EmbeddedMpvFrameCopyRuntimeDependencies['fileSystem']; - - beforeEach(() => { - rootDir = mkdtempSync(path.join(tmpdir(), 'iptvnator-fc-runtime-')); - spawnRuntimeProbe = jest.fn(() => ({ - status: 0, - signal: null, - stdout: SUCCESS_OUTPUT, - stderr: '', - })); - 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)), - }; - }); - - afterEach(() => { - rmSync(rootDir, { recursive: true, force: true }); - }); - - function createProbe( - overrides: Partial = {} - ) { - return createEmbeddedMpvFrameCopyRuntimeProbe({ - platform: 'linux', - arch: 'x64', - env: { - PATH: '/usr/bin', - LD_LIBRARY_PATH: '/ambient/libs', - LD_PRELOAD: '/tmp/inject.so', - }, - fileSystem, - spawnSync: spawnRuntimeProbe as typeof spawnSync, - ...overrides, - }); - } - - it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => { - const fixture = createFixture(rootDir); - const probeRuntime = createProbe(); - - expect(probeRuntime(fixture.helperPath)).toEqual({ - usable: true, - profile: 'system', - runtimeMode: 'system', - libmpv: '2.3', - renderApi: 'egl', - }); - expect(probeRuntime(fixture.helperPath).usable).toBe(true); - expect(spawnRuntimeProbe).toHaveBeenCalledTimes(1); - expect(spawnRuntimeProbe).toHaveBeenCalledWith( - fixture.helperPath, - ['--runtime-probe'], - { - encoding: 'utf8', - timeout: 3000, - killSignal: 'SIGKILL', - windowsHide: true, - env: { - PATH: '/usr/bin', - }, - } - ); - expect(fileSystem?.readFileSync).toHaveBeenCalled(); - }); - - it('invalidates a cached result when helper or manifest bytes change under identical stats', () => { - const fixture = createFixture(rootDir); - const fixedStats = new Map([ - [fixture.helperPath, lstatSync(fixture.helperPath)], - [fixture.manifestPath, lstatSync(fixture.manifestPath)], - ]); - fileSystem = { - ...fileSystem, - lstatSync: jest.fn( - (filePath: string) => - fixedStats.get(filePath) ?? lstatSync(filePath) - ), - }; - const probeRuntime = 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(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(rootDir, profile); - const probeRuntime = createProbe(); - - expect(probeRuntime(fixture.helperPath)).toEqual( - expect.objectContaining({ - usable: true, - profile, - runtimeMode: 'bundled', - }) - ); - expect(spawnRuntimeProbe).toHaveBeenCalledWith( - fixture.helperPath, - ['--runtime-probe'], - expect.objectContaining({ - env: { - PATH: '/usr/bin', - LD_LIBRARY_PATH: path.join(fixture.nativeDir, 'lib'), - }, - }) - ); - } - ); - - 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(rootDir, buildInputMode); - - expect( - probeDevelopmentRuntime(createProbe(), fixture.helperPath) - ).toEqual( - expect.objectContaining({ - usable: true, - runtimeMode: - buildInputMode === 'bundled-runtime' - ? 'bundled' - : 'system', - }) - ); - expect(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(rootDir, 'bundled-runtime'); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.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(rootDir, 'system-dev'); - const manifest = cloneManifest(fixture.manifest); - mutate(manifest); - writeManifest(fixture.manifestPath, manifest); - - expect( - probeDevelopmentRuntime(createProbe(), fixture.helperPath) - ).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - } - ); - - it('rejects a system development manifest with a private runtime directory', () => { - const fixture = createDevelopmentFixture(rootDir, 'system-dev'); - mkdirSync(path.join(fixture.nativeDir, 'lib')); - - expect( - probeDevelopmentRuntime(createProbe(), fixture.helperPath) - ).toEqual({ - usable: false, - reason: 'runtime-library-directory-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - }); - - it('reprobes when the helper identity changes', () => { - const fixture = createFixture(rootDir); - const probeRuntime = 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(spawnRuntimeProbe).toHaveBeenCalledTimes(2); - }); - - it('reprobes when the manifest identity changes', () => { - const fixture = createFixture(rootDir); - const probeRuntime = 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(spawnRuntimeProbe).toHaveBeenCalledTimes(2); - }); - - it('converts a thrown spawn failure into a stable result', () => { - const fixture = createFixture(rootDir); - spawnRuntimeProbe.mockImplementation(() => { - throw new Error('spawn exploded'); - }); - - expect(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(rootDir); - spawnRuntimeProbe.mockReturnValue(spawnResult); - - expect(createProbe()(fixture.helperPath)).toEqual( - expect.objectContaining({ usable: false, reason }) - ); - }); - - 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(rootDir); - - expect(createProbe({ platform, arch })(fixture.helperPath)).toEqual( - { - usable: false, - reason, - } - ); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - } - ); - - it('rejects a missing or malformed manifest without throwing', () => { - const missing = createFixture(rootDir); - unlinkSync(missing.manifestPath); - expect(createProbe()(missing.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-missing', - }); - - const malformed = createFixture(rootDir, 'portable'); - writeFileSync(malformed.manifestPath, '{broken\n', { mode: 0o644 }); - expect(createProbe()(malformed.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - }); - - it.each([ - ['origin', 'system-libmpv-frame-copy'], - ['arch', 'arm64'], - ['runtimeMode', 'system'], - ['targets', ['deb']], - ['unexpectedField', true], - ])('rejects a bundled profile mismatch in %s', (field, value) => { - const fixture = createFixture(rootDir, 'portable'); - const manifest = cloneManifest(fixture.manifest); - manifest[field] = value; - writeManifest(fixture.manifestPath, manifest); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(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(rootDir, profile); - const manifest = cloneManifest(fixture.manifest); - manifest.targets = targets; - writeManifest(fixture.manifestPath, manifest); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - } - ); - - it('rejects a bundled closure dependency outside the deterministic system allowlist', () => { - const fixture = createFixture(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(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - }); - - it('requires externalDependencies to exactly equal the sorted external closure', () => { - const fixture = createFixture(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(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - }); - - it('accepts only the exact deterministic external-system library declaration', () => { - const fixture = createFixture(rootDir, 'portable'); - const manifest = cloneManifest(fixture.manifest); - (manifest.externalSystemLibraries as unknown[]).pop(); - mirrorBundledManifestFields(manifest); - writeManifest(fixture.manifestPath, manifest); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - }); - - it('accepts bundled dependencies on declared system interfaces and the glibc toolchain', () => { - const fixture = createFixture(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(createProbe()(fixture.helperPath)).toEqual( - expect.objectContaining({ usable: true, runtimeMode: 'bundled' }) - ); - expect(spawnRuntimeProbe).toHaveBeenCalledTimes(1); - }); - - 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: '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(rootDir, 'portable'); - const manifest = cloneManifest(fixture.manifest); - mutate(manifest.sourceRuntime as Record); - writeManifest(fixture.manifestPath, manifest); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - }); - - it('rejects system manifests with a private library directory', () => { - const fixture = createFixture(rootDir); - mkdirSync(path.join(fixture.nativeDir, 'lib')); - - expect(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: '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(rootDir); - mutate(fixture); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason, - }); - expect(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(rootDir, 'portable'); - mutate(fixture); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason, - }); - expect(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(rootDir, 'portable'); - const manifest = cloneManifest(fixture.manifest); - const runtimeFiles = manifest.runtimeFiles as RuntimeFile[]; - runtimeFiles[0].name = unsafeName; - writeManifest(fixture.manifestPath, manifest); - - expect(createProbe()(fixture.helperPath)).toEqual({ - usable: false, - reason: 'runtime-manifest-invalid', - }); - expect(spawnRuntimeProbe).not.toHaveBeenCalled(); - } - ); - - it('uses read and execute access checks for declared artifacts', () => { - const fixture = createFixture(rootDir); - const probeRuntime = createProbe(); - expect(probeRuntime(fixture.helperPath).usable).toBe(true); - - const accessMock = 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.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.ts index 5722fdc79..d84826589 100644 --- 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 @@ -1,1608 +1,13 @@ -import { spawnSync as nodeSpawnSync } from 'child_process'; -import { createHash } from 'crypto'; -import * as nodeFileSystem from 'fs'; -import { isDeepStrictEqual } from 'util'; -import path from 'path'; - -const RUNTIME_MANIFEST_NAME = 'embedded-mpv-runtime.json'; -const FRAME_COPY_ADDON_NAME = 'embedded_mpv.node'; -const FRAME_COPY_READER_NAME = 'embedded_mpv_frame_reader.node'; -const FRAME_COPY_HELPER_NAME = 'iptvnator_mpv_helper'; -const RUNTIME_PROBE_PROTOCOL = 1; -const RUNTIME_PROBE_TIMEOUT_MS = 3000; -const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; -const SAFE_RUNTIME_NAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; -const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; -const SHA256_PATTERN = /^[a-f0-9]{64}$/; -const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; -const SUBMODULE_RECORD_PATTERN = - /^[a-f0-9]{40,64}\s+([A-Za-z0-9_+./-]+)(?:\s+\(.+\))?$/; -const VERSION_PATTERN = /^\d+(?:\.\d+)+$/; - -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; - -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; - -const ALLOWED_EXTERNAL_LIBRARY_NAMES = new Set([ - ...GLIBC_TOOLCHAIN_ALLOWLIST, - ...EXPECTED_EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name), -]); - -const PORTABLE_ABI_BASELINE = { - distribution: 'Ubuntu 22.04', - glibcMaximum: '2.35', - glibcxxMaximum: '3.4.30', -} as const; - -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', - 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; - -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, - }, -}; - -const EXPECTED_PROCESS_ISOLATION = { - addonLoadsLibmpv: false, - readerLoadsLibmpv: false, - electronLoadsLibmpv: false, - helperLinksLibmpv: true, - helperRunpath: ['$ORIGIN/lib'], -}; - -const EXPECTED_DEVELOPMENT_ARTIFACTS = { - addon: FRAME_COPY_ADDON_NAME, - frameReader: FRAME_COPY_READER_NAME, - helper: FRAME_COPY_HELPER_NAME, -}; - -const EXPECTED_DEVELOPMENT_PROCESS_ISOLATION = { - addonLoadsLibmpv: false, - helperLinksLibmpv: true, - helperRunpath: ['$ORIGIN/lib'], -}; - -const DEVELOPMENT_MANIFEST_FIELDS = [ - 'allowedPackageRuntimeModes', - 'arch', - 'artifacts', - 'buildInputMode', - 'generatedAt', - 'libmpvSoname', - 'nativeViewFallback', - 'origin', - 'packageRuntimeAvailability', - 'platform', - 'processIsolation', - 'runtimeFiles', - 'runtimeTotalBytes', - 'schemaVersion', - 'sourceRuntime', - 'sourceRuntimeValidated', -] as const; - -const SYSTEM_PACKAGE_DEPENDENCIES = { - deb: 'libmpv2', - rpm: 'mpv-libs', - pacman: 'mpv', -}; - -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; - -const BASE_MANIFEST_FIELDS = [ - 'arch', - 'artifacts', - 'generatedAt', - 'libmpvSoname', - 'nativeViewFallback', - 'origin', - 'packageDependencies', - 'platform', - 'processIsolation', - 'profile', - 'runtimeFiles', - 'runtimeMode', - 'runtimeTotalBytes', - 'schemaVersion', - 'targets', -] as const; - -const BUNDLED_MANIFEST_FIELDS = [ - ...BASE_MANIFEST_FIELDS, - 'externalSystemLibraries', - 'runtimeDependencyClosure', - 'sourceRuntime', -] as const; - -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' - | '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'; - -type RuntimeProfile = keyof typeof PROFILE_CONTRACTS; -type RuntimeProbeProfile = RuntimeProfile | 'development'; - -export type EmbeddedMpvFrameCopyRuntimeResult = - | { - usable: true; - profile: RuntimeProbeProfile; - runtimeMode: 'system' | 'bundled'; - libmpv: string; - renderApi: 'egl'; - } - | { - usable: false; - reason: EmbeddedMpvFrameCopyRuntimeFailureReason; - }; - -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; -} - -interface RuntimeFile { - name: string; - size: number; - sha256: string; -} - -interface ValidManifest { - profile: RuntimeProbeProfile; - runtimeMode: EmbeddedMpvFrameCopyRuntimeMode; - runtimeFiles: RuntimeFile[]; -} - -interface ValidatedPackage { - manifest: ValidManifest; - helperPath: string; - nativeDir: string; -} - -interface ValidationSuccess { - value: T; -} - -interface ValidationFailure { - reason: EmbeddedMpvFrameCopyRuntimeFailureReason; -} - -type ValidationResult = ValidationSuccess | ValidationFailure; - -function failure( - reason: EmbeddedMpvFrameCopyRuntimeFailureReason -): EmbeddedMpvFrameCopyRuntimeResult { - return { usable: false, reason }; -} - -function validationFailure( - reason: EmbeddedMpvFrameCopyRuntimeFailureReason -): ValidationFailure { - return { reason }; -} - -function isValidationFailure( - result: ValidationResult -): result is ValidationFailure { - return 'reason' in result; -} - -function isObject(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function hasExactFields( - value: Record, - fields: readonly string[] -): boolean { - return isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort()); -} - -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) - ); -} - -function isMissingFileError(error: unknown): boolean { - return ( - isObject(error) && - typeof error.code === 'string' && - (error.code === 'ENOENT' || error.code === 'ENOTDIR') - ); -} - -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(':'); -} - -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'); - } -} - -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); -} - -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; -} - -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 - ); -} - -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 ( - candidate.sourceGitCommit === expected.sourceGitCommit && - GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) && - candidate.sourceSha256 === undefined && - validatesGitSubmoduleRecords(candidate.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. - */ -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; -} - -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 - ) || - !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; - }); -} - -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 || - !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) || - !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, - }, - }; -} - -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 & 0o777) !== 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 }; -} - -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, - }, - }; -} - -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, - }; -} - -const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; -const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl'; - -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 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; -} - -function getTrustedSnapLibraryPaths( - environment: NodeJS.ProcessEnv, - snapRoot: string -): string[] { - const snapLibraryPaths = (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) - ); - - return [ - ...snapLibraryPaths, - 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'), - ]; -} - -/** - * 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 may add only immutable-looking Snap runtime/GL roots. - */ -export function createLinuxFrameCopyHelperEnvironment( - environment: NodeJS.ProcessEnv, - nativeDir: string, - runtimeMode: EmbeddedMpvFrameCopyRuntimeMode -): NodeJS.ProcessEnv { - const helperEnvironment = { ...environment }; - delete helperEnvironment.LD_LIBRARY_PATH; - delete helperEnvironment.LD_PRELOAD; - - if (runtimeMode === 'system') { - return helperEnvironment; - } - - const libraryPaths = [path.join(nativeDir, 'lib')]; - const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir); - if (trustedSnapRoot) { - libraryPaths.push( - ...getTrustedSnapLibraryPaths(environment, trustedSnapRoot) - ); - } - helperEnvironment.LD_LIBRARY_PATH = [...new Set(libraryPaths)].join(':'); - return helperEnvironment; -} - -function runHelperProbe( - runtimePackage: ValidatedPackage, - dependencies: EmbeddedMpvFrameCopyRuntimeDependencies -): EmbeddedMpvFrameCopyRuntimeResult { - const probeEnvironment = createLinuxFrameCopyHelperEnvironment( - dependencies.env, - runtimePackage.nativeDir, - runtimePackage.manifest.runtimeMode - ); - - let result: ReturnType; - try { - result = dependencies.spawnSync( - runtimePackage.helperPath, - ['--runtime-probe'], - { - encoding: 'utf8', - timeout: RUNTIME_PROBE_TIMEOUT_MS, - killSignal: 'SIGKILL', - windowsHide: true, - env: probeEnvironment, - } - ); - } catch { - return failure('helper-probe-spawn-error'); - } - - 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) { - return 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, - ...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); -} +export { createLinuxFrameCopyHelperEnvironment } from './embedded-mpv-frame-copy-runtime/helper-environment'; +export { + createEmbeddedMpvFrameCopyRuntimeProbe, + probeEmbeddedMpvFrameCopyRuntime, +} from './embedded-mpv-frame-copy-runtime/probe'; +export type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeDependencies, + EmbeddedMpvFrameCopyRuntimeFailureReason, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeMode, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime/types'; 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..4ea7a7e69 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/contracts.ts @@ -0,0 +1,307 @@ +import runtimeProbeContract = require('../../../../../../tools/embedded-mpv/runtime-probe-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_TIMEOUT_MS } = runtimeProbeContract; +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 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_+./-]+)(?:\s+\(.+\))?$/; +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', + 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', + 'sourceRuntime', + 'sourceRuntimeValidated', +] as const; + +export const SYSTEM_PACKAGE_DEPENDENCIES = { + deb: 'libmpv2', + rpm: 'mpv-libs', + pacman: 'mpv', +}; + +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', + '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..ed776ec80 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/development-manifest.spec.ts @@ -0,0 +1,152 @@ +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.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/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..20dd20af0 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.spec.ts @@ -0,0 +1,91 @@ +import path from 'path'; +import { createLinuxFrameCopyHelperEnvironment } from '../embedded-mpv-frame-copy-runtime'; + +describe('createLinuxFrameCopyHelperEnvironment', () => { + it('removes ambient loader overrides for system packages', () => { + expect( + createLinuxFrameCopyHelperEnvironment( + { + PATH: '/usr/bin', + HOME: '/home/user', + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + '/opt/iptvnator/native', + 'system' + ) + ).toEqual({ + PATH: '/usr/bin', + HOME: '/home/user', + }); + }); + + it('keeps trusted Snap GL roots ahead of generic Snap 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(':'), + LD_LIBRARY_PATH: '/tmp/hostile-libs', + LD_PRELOAD: '/tmp/inject.so', + }, + nativeDir, + 'bundled' + ) + ).toEqual({ + 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(':'), + LD_LIBRARY_PATH: [ + path.join(nativeDir, 'lib'), + '/var/lib/snapd/lib/gl', + '/var/lib/snapd/lib/gl/nvidia', + 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('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_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..9889f4cb4 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-environment.ts @@ -0,0 +1,105 @@ +import path from 'path'; +import type { EmbeddedMpvFrameCopyRuntimeMode } from './types'; + +const TRUSTED_SNAP_MOUNT_ROOTS = ['/snap', '/var/lib/snapd/snap'] as const; +const TRUSTED_SNAP_GL_ROOT = '/var/lib/snapd/lib/gl'; + +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 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; +} + +function getTrustedSnapLibraryPaths( + environment: NodeJS.ProcessEnv, + snapRoot: string +): string[] { + const snapLibraryPaths = (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) + ); + + return [ + ...snapLibraryPaths, + 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'), + ]; +} + +/** + * 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 may add only immutable-looking Snap runtime/GL roots. + */ +export function createLinuxFrameCopyHelperEnvironment( + environment: NodeJS.ProcessEnv, + nativeDir: string, + runtimeMode: EmbeddedMpvFrameCopyRuntimeMode +): NodeJS.ProcessEnv { + const helperEnvironment = { ...environment }; + delete helperEnvironment.LD_LIBRARY_PATH; + delete helperEnvironment.LD_PRELOAD; + + if (runtimeMode === 'system') { + return helperEnvironment; + } + + const libraryPaths = [path.join(nativeDir, 'lib')]; + const trustedSnapRoot = resolveTrustedSnapRoot(environment, nativeDir); + if (trustedSnapRoot) { + libraryPaths.push( + ...getTrustedSnapLibraryPaths(environment, trustedSnapRoot) + ); + } + 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..a7842b6cf --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/helper-failures.spec.ts @@ -0,0 +1,126 @@ +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 }) + ); + }); +}); 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..c374668d1 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-policy.spec.ts @@ -0,0 +1,148 @@ +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']], + ['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..efee26946 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/manifest-validator.ts @@ -0,0 +1,260 @@ +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, + 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 + ) || + !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 || + !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) || + !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..a96fdc5ff --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/package-integrity.spec.ts @@ -0,0 +1,225 @@ +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: '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..830b0eb72 --- /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 & 0o777) !== 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..3cc19e611 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe-orchestration.spec.ts @@ -0,0 +1,151 @@ +import { chmodSync, lstatSync, readFileSync, 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(); + + 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, + env: { + PATH: '/usr/bin', + }, + } + ); + 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('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..23b7c9a9a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/probe.ts @@ -0,0 +1,236 @@ +import { spawnSync as nodeSpawnSync } from 'child_process'; +import * as nodeFileSystem from 'fs'; +import path from 'path'; +import { + RUNTIME_MANIFEST_NAME, + RUNTIME_PROBE_PROTOCOL, + RUNTIME_PROBE_TIMEOUT_MS, +} from './contracts'; +import { createLinuxFrameCopyHelperEnvironment } from './helper-environment'; +import { validatePackage } from './package-validator'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeDependencies, + EmbeddedMpvFrameCopyRuntimeFileSystem, + EmbeddedMpvFrameCopyRuntimeResult, + ValidManifest, + ValidatedPackage, +} from './types'; +import { + failure, + fileIdentity, + hasExactFields, + isMissingFileError, + isObject, + isValidationFailure, +} from './validation-primitives'; + +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 runHelperProbe( + runtimePackage: ValidatedPackage, + dependencies: EmbeddedMpvFrameCopyRuntimeDependencies +): EmbeddedMpvFrameCopyRuntimeResult { + const probeEnvironment = createLinuxFrameCopyHelperEnvironment( + dependencies.env, + runtimePackage.nativeDir, + runtimePackage.manifest.runtimeMode + ); + + let result: ReturnType; + try { + result = dependencies.spawnSync( + runtimePackage.helperPath, + ['--runtime-probe'], + { + encoding: 'utf8', + timeout: RUNTIME_PROBE_TIMEOUT_MS, + killSignal: 'SIGKILL', + windowsHide: true, + env: probeEnvironment, + } + ); + } catch { + return failure('helper-probe-spawn-error'); + } + + 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) { + return 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, + ...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..7bec5b101 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-fixtures.test-helpers.ts @@ -0,0 +1,296 @@ +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); + ( + packages.libplacebo as typeof packages.libplacebo & { + sourceSubmodules: string[]; + } + ).sourceSubmodules = [`${'a'.repeat(40)} 3rdparty/example`]; + 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 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', + rpm: 'mpv-libs', + pacman: 'mpv', + }, + runtimeFiles, + runtimeTotalBytes: runtimeFiles.reduce( + (total, runtimeFile) => total + runtimeFile.size, + 0 + ), + ...(bundled + ? { + runtimeDependencyClosure, + externalSystemLibraries, + 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, + 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..03f5ec53d --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime-harness.test-helpers.ts @@ -0,0 +1,69 @@ +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; + 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 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, + fileSystem, + createProbe( + overrides: Partial = {} + ) { + return createEmbeddedMpvFrameCopyRuntimeProbe({ + platform: 'linux', + arch: 'x64', + env: { + PATH: '/usr/bin', + LD_LIBRARY_PATH: '/ambient/libs', + LD_PRELOAD: '/tmp/inject.so', + }, + fileSystem: context.fileSystem, + spawnSync: context.spawnRuntimeProbe as typeof spawnSync, + ...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..dc1df90a5 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/runtime.spec-data.ts @@ -0,0 +1,171 @@ +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', + 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..3a80a5c9e --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-policy.spec.ts @@ -0,0 +1,137 @@ +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: '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..824198446 --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/source-runtime-validator.ts @@ -0,0 +1,245 @@ +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 ( + candidate.sourceGitCommit === expected.sourceGitCommit && + GIT_COMMIT_PATTERN.test(candidate.sourceGitCommit) && + candidate.sourceSha256 === undefined && + validatesGitSubmoduleRecords(candidate.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/types.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts new file mode 100644 index 000000000..e7172af3a --- /dev/null +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime/types.ts @@ -0,0 +1,86 @@ +import type { spawnSync as nodeSpawnSync } from 'child_process'; +import type * as nodeFileSystem from 'fs'; + +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' + | '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; + }; + +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; +} + +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/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index fb49efb18..a5dea08c4 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -779,7 +779,10 @@ 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. +`linux-frame-copy-runtime-sources.tar.xz` in that public release. The workflow +uploads only to the Store's edge channel. Candidate/stable promotion is manual +after installed-Snap frame-copy and missing-runtime fallback smoke; GitHub +Actions never promotes automatically. During temporary artifact tests, CI may also set `IPTVNATOR_REQUIRE_EMBEDDED_MPV=1` for PR and `master` push jobs where a runtime is known to exist. After the artifacts are manually validated, remove temporary conditions so ordinary development builds leave `IPTVNATOR_REQUIRE_EMBEDDED_MPV` unset or `0`. This keeps the native feature in-tree without making every non-release build depend on runtime artifacts. diff --git a/package.json b/package.json index 16a8f807c..76f64db8a 100644 --- a/package.json +++ b/package.json @@ -212,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 a15e6e264..8450284e4 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -209,8 +209,10 @@ or hash-mismatched license files. 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 any Snap to the -Store's edge channel. +`linux-frame-copy-runtime-sources.tar.xz` before uploading anything. The +workflow uploads only to the Store's edge channel. +Candidate/stable promotion is manual after installed-Snap frame-copy and +missing-runtime fallback smoke; GitHub Actions never promotes automatically. Windows CI stages a checksum-pinned x64 LGPL archive. The DLL basename encoded in its import library is preserved and must be present beside diff --git a/tools/embedded-mpv/build-linux-runtime.mjs b/tools/embedded-mpv/build-linux-runtime.mjs index 215b8a399..942d036c9 100644 --- a/tools/embedded-mpv/build-linux-runtime.mjs +++ b/tools/embedded-mpv/build-linux-runtime.mjs @@ -382,7 +382,7 @@ function pathInsideDestdir(destdir, absolutePath) { ); } -function copyDirectoryContents(sourceDirectory, destinationDirectory) { +export function copyDirectoryContents(sourceDirectory, destinationDirectory) { if (!fs.existsSync(sourceDirectory)) { return; } @@ -394,6 +394,7 @@ function copyDirectoryContents(sourceDirectory, destinationDirectory) { { recursive: true, force: true, + verbatimSymlinks: true, } ); } diff --git a/tools/embedded-mpv/build-linux-runtime.test.mjs b/tools/embedded-mpv/build-linux-runtime.test.mjs index af0f1aac4..ff3fd8ae0 100644 --- a/tools/embedded-mpv/build-linux-runtime.test.mjs +++ b/tools/embedded-mpv/build-linux-runtime.test.mjs @@ -810,6 +810,42 @@ test('uses DESTDIR with standard fontconfig and OpenSSL runtime paths', () => { ); }); +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', @@ -1557,4 +1593,16 @@ test('registers the builder script and focused test with package and Nx', () => 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/runtime-probe-contract.cjs b/tools/embedded-mpv/runtime-probe-contract.cjs new file mode 100644 index 000000000..22667c10b --- /dev/null +++ b/tools/embedded-mpv/runtime-probe-contract.cjs @@ -0,0 +1,5 @@ +const RUNTIME_PROBE_CONTRACT = Object.freeze({ + 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..a4601b29e --- /dev/null +++ b/tools/embedded-mpv/runtime-probe-contract.d.cts @@ -0,0 +1,5 @@ +declare const RUNTIME_PROBE_CONTRACT: Readonly<{ + readonly RUNTIME_PROBE_TIMEOUT_MS: 3000; +}>; + +export = RUNTIME_PROBE_CONTRACT; diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 16b430425..fb4b75ee2 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -39,6 +39,8 @@ "{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/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" ], @@ -55,9 +57,13 @@ "{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.cjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs", + "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs", + "{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/generate-linux-runtime-notices.{cjs,test.mjs}\"" + "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/build-linux-runtime.{mjs,test.mjs}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\" \"tools/embedded-mpv/runtime-probe-contract.cjs\"" } }, "tags": ["scope:tools", "domain:packaging", "type:tool"] diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs index 3b9ae70c6..cfac2535c 100644 --- a/tools/packaging/publish-snap-workflow.test.mjs +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -4,6 +4,11 @@ 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)), @@ -36,6 +41,64 @@ async function loadReleaseAssetHelper() { 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), @@ -43,10 +106,6 @@ test('publishes Snap only after a public v-tag release contains binary and sourc 'the release-published Snap workflow must exist' ); const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); - assert.match( - workflowText, - /^on:\n {4}release:\n {8}types:\n {12}- published$/m - ); assert.match(workflowText, /^permissions:\n {4}contents: read$/m); assert.match( workflowText, @@ -73,11 +132,183 @@ test('publishes Snap only after a public v-tag release contains binary and sourc ); assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/); assert.match(workflowText, /release-snap-assets\.cjs verify/); - assert.match(workflowText, /snapcraft upload --release=edge/); + 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); - assert.doesNotMatch(buildWorkflow, /snapcraft upload/); + assertBuildSnapWorkflowPolicy(buildWorkflow); +}); + +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 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 () => { 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..c9c250a6a --- /dev/null +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import { parse } from 'yaml'; + +const PUBLISH_ACTION_ALLOWLIST = Object.freeze([ + 'actions/checkout@v4', + 'samuelmeuli/action-snapcraft@v3', +]); +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 PUBLISH_JOB_ID = 'publish-snap'; +const PUBLISH_JOB_CONDITION = + "${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; +const PUBLISH_STEP_NAME = 'Publish all public-release snaps to edge'; +const PUBLISH_STEP_CONTRACT = Object.freeze({ + name: PUBLISH_STEP_NAME, + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'node -e \\', + ` "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of selected.snapAssets) console.log(asset.name);" \\`, + ' "${RUNNER_TEMP}/selected-snap-release-assets.json" |', + ' while IFS= read -r SNAP_NAME; do', + ' SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}"', + ' echo "Publishing public release asset: ${SNAP_NAME}"', + ' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.', + ' # GitHub Actions never promotes automatically.', + ' snapcraft upload --release=edge "${SNAP_FILE}"', + ' done', + '', + ].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( + [...actions].sort(), + [...PUBLISH_ACTION_ALLOWLIST].sort(), + 'the publish workflow must use exactly the allowlisted actions' + ); + const publishJob = workflow.jobs[PUBLISH_JOB_ID]; + assert.ok(isRecord(publishJob), 'the canonical publish job must exist'); + assert.equal( + publishJob.if, + PUBLISH_JOB_CONDITION, + 'the publish job must retain its exact release condition' + ); + assert.deepEqual( + publishJob.steps.filter((step) => step.name === PUBLISH_STEP_NAME), + [PUBLISH_STEP_CONTRACT], + 'the publish workflow must retain the exact reviewed publication step' + ); + assert.equal( + literalSnapcraftTokens(commandSource).length, + 1, + 'the publish workflow must contain exactly one literal Snapcraft command' + ); +} + +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/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index fb9d93c7d..34186550a 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -16,9 +16,11 @@ const { LINUX_SYSTEM_PACKAGE_DEPENDENCIES, resolveLinuxFrameCopyProfile, } = require('./linux-frame-copy-profile.cjs'); +const { + RUNTIME_PROBE_TIMEOUT_MS, +} = require('../embedded-mpv/runtime-probe-contract.cjs'); const scriptPath = fileURLToPath(import.meta.url); -const RUNTIME_PROBE_TIMEOUT_MS = 3000; const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/; function defaultRunCommand(command, args, options = {}) { diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index 65d73b6ff..fb3421714 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -19,6 +19,26 @@ import { 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 electronBackendProjectUrl = new URL( + '../../apps/electron-backend/project.json', + import.meta.url +); const SYSTEM_TARGETS = ['deb', 'pacman', 'rpm']; const SYSTEM_MANIFEST = { schemaVersion: 1, @@ -65,6 +85,85 @@ const SYSTEM_MANIFEST = { runtimeTotalBytes: 0, }; +test('uses the shared frozen 3000 ms runtime probe timeout 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.match( + fs.readFileSync(runtimeProbeContractTypesUrl, 'utf8'), + /readonly RUNTIME_PROBE_TIMEOUT_MS: 3000/ + ); + + 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 \{ RUNTIME_PROBE_TIMEOUT_MS \} = runtimeProbeContract/ + ); + assert.doesNotMatch( + backendContractsSource, + /RUNTIME_PROBE_TIMEOUT_MS\s*=\s*3000/ + ); +}); + +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,