diff --git a/apps/electron-backend/src/app/app.spec.ts b/apps/electron-backend/src/app/app.spec.ts index 9658f5607..639d86d48 100644 --- a/apps/electron-backend/src/app/app.spec.ts +++ b/apps/electron-backend/src/app/app.spec.ts @@ -133,8 +133,34 @@ describe('Electron app security helpers', () => { it('keeps the renderer sandboxed when frame-copy is requested without a usable runtime', () => { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, '0'])( + 'does not probe frame-copy runtime for an inactive %s opt-in', + (explicitFrameCopy) => { + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + if (explicitFrameCopy === undefined) { + delete process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY; + } else { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = + explicitFrameCopy; + } + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).not.toHaveBeenCalled(); + } + ); + + it('probes frame-copy runtime for an explicit opt-in', () => { + process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1'; + mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(true); + + expect(getMainWindowWebPreferences()?.sandbox).toBe(true); + expect(mockIsFrameCopyRuntimeUsable).toHaveBeenCalledTimes(1); }); it('keeps the renderer sandboxed when frame-copy is requested but embedded MPV is disabled', () => { @@ -215,9 +241,9 @@ describe('Electron app security helpers', () => { expect(mainWindow.loadFile).toHaveBeenCalledWith( expect.stringContaining('index.html') ); - expect( - mockClearStorageData.mock.invocationCallOrder[0] - ).toBeLessThan(mainWindow.loadFile.mock.invocationCallOrder[0]); + expect(mockClearStorageData.mock.invocationCallOrder[0]).toBeLessThan( + mainWindow.loadFile.mock.invocationCallOrder[0] + ); }); it('continues packaged renderer loading when Electron service worker cleanup fails', async () => { diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts index 27abce4c7..7981de7fc 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.spec.ts @@ -14,9 +14,12 @@ import { isFrameCopyPlatformSupported, isFrameCopyRuntimeUsable, resolveFrameCopyHelperPath, + shouldPromotePersistedFrameCopyOptIn, } from './embedded-mpv-frame-copy-platform.util'; -import * as frameCopyPlatform from './embedded-mpv-frame-copy-platform.util'; -import type { EmbeddedMpvFrameCopyRuntimeResult } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime'; describe('embedded-mpv-frame-copy-platform.util', () => { describe('isFrameCopyPlatformSupported', () => { @@ -161,11 +164,16 @@ describe('embedded-mpv-frame-copy-platform.util', () => { const originalPlatform = process.platform; const originalArch = process.arch; + beforeEach(() => { + mockElectronApp.isPackaged = false; + }); + afterEach(() => { Object.defineProperty(process, 'platform', { value: originalPlatform, }); Object.defineProperty(process, 'arch', { value: originalArch }); + mockElectronApp.isPackaged = false; }); it('requires a successful Linux x64 runtime probe', () => { @@ -174,7 +182,7 @@ describe('embedded-mpv-frame-copy-platform.util', () => { const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); const probeRuntime = jest.fn< EmbeddedMpvFrameCopyRuntimeResult, - [string] + [string, EmbeddedMpvFrameCopyManifestContract] >(() => ({ usable: true, profile: 'system', @@ -187,7 +195,8 @@ describe('embedded-mpv-frame-copy-platform.util', () => { true ); expect(probeRuntime).toHaveBeenCalledWith( - '/native/iptvnator_mpv_helper' + '/native/iptvnator_mpv_helper', + 'development' ); probeRuntime.mockReturnValueOnce({ @@ -202,6 +211,31 @@ describe('embedded-mpv-frame-copy-platform.util', () => { }); }); + it('selects the packaged manifest contract only from app.isPackaged', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + mockElectronApp.isPackaged = true; + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn< + EmbeddedMpvFrameCopyRuntimeResult, + [string, EmbeddedMpvFrameCopyManifestContract] + >(() => ({ + usable: true, + profile: 'system', + runtimeMode: 'system', + libmpv: '2.3', + renderApi: 'egl', + })); + + expect(isFrameCopyRuntimeUsable(resolveHelper, probeRuntime)).toBe( + true + ); + expect(probeRuntime).toHaveBeenCalledWith( + '/native/iptvnator_mpv_helper', + 'packaged' + ); + }); + it.each<[NodeJS.Platform, string]>([ ['darwin', 'arm64'], ['win32', 'x64'], @@ -236,24 +270,50 @@ describe('embedded-mpv-frame-copy-platform.util', () => { expect(resolveHelper).not.toHaveBeenCalled(); expect(probeRuntime).not.toHaveBeenCalled(); }); + + it('reports unsupported architecture for Intel macOS', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + Object.defineProperty(process, 'arch', { value: 'x64' }); + const resolveHelper = jest.fn(() => '/native/iptvnator_mpv_helper'); + const probeRuntime = jest.fn(); + + expect( + getFrameCopyRuntimeAvailability(resolveHelper, probeRuntime) + ).toEqual({ + usable: false, + reason: 'unsupported-architecture', + }); + expect(resolveHelper).not.toHaveBeenCalled(); + expect(probeRuntime).not.toHaveBeenCalled(); + }); }); - it('promotes a stored opt-in only without an explicit env override and with a usable runtime', () => { - const shouldPromote = ( - frameCopyPlatform as typeof frameCopyPlatform & { - shouldPromotePersistedFrameCopyOptIn?: ( - storedEnabled: boolean, - explicitEnv: string | undefined, - runtimeUsable: boolean - ) => boolean; - } - ).shouldPromotePersistedFrameCopyOptIn; + it('lazily probes only a stored opt-in without an explicit env override', () => { + const runtimeUsable = jest.fn(() => true); + expect( + shouldPromotePersistedFrameCopyOptIn( + false, + undefined, + runtimeUsable + ) + ).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, '0', runtimeUsable) + ).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, '1', runtimeUsable) + ).toBe(false); + expect(runtimeUsable).not.toHaveBeenCalled(); - expect(shouldPromote).toBeDefined(); - expect(shouldPromote?.(true, undefined, true)).toBe(true); - expect(shouldPromote?.(true, undefined, false)).toBe(false); - expect(shouldPromote?.(true, '0', true)).toBe(false); - expect(shouldPromote?.(true, '1', true)).toBe(false); - expect(shouldPromote?.(false, undefined, true)).toBe(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable) + ).toBe(true); + expect(runtimeUsable).toHaveBeenCalledTimes(1); + + runtimeUsable.mockReturnValue(false); + expect( + shouldPromotePersistedFrameCopyOptIn(true, undefined, runtimeUsable) + ).toBe(false); + expect(runtimeUsable).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts index 55f00431f..8c71e4dc3 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-platform.util.ts @@ -2,7 +2,10 @@ import { app } from 'electron'; import { accessSync, constants as fsConstants, statSync } from 'fs'; import path from 'path'; import { probeEmbeddedMpvFrameCopyRuntime } from './embedded-mpv-frame-copy-runtime'; -import type { EmbeddedMpvFrameCopyRuntimeResult } from './embedded-mpv-frame-copy-runtime'; +import type { + EmbeddedMpvFrameCopyManifestContract, + EmbeddedMpvFrameCopyRuntimeResult, +} from './embedded-mpv-frame-copy-runtime'; /** * Platform gate + helper discovery for the embedded MPV frame-copy engine, @@ -127,7 +130,8 @@ export function resolveFrameCopyHelperPath(): string | null { } type FrameCopyRuntimeProbe = ( - helperPath: string + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract ) => EmbeddedMpvFrameCopyRuntimeResult; export type FrameCopyRuntimeAvailability = @@ -152,7 +156,7 @@ export function getFrameCopyRuntimeAvailability( availability = { usable: false, reason: - process.platform === 'linux' + process.platform === 'linux' || process.platform === 'darwin' ? 'unsupported-architecture' : 'unsupported-platform', }; @@ -167,7 +171,13 @@ export function getFrameCopyRuntimeAvailability( availability = { usable: true }; } else { try { - availability = probeRuntime(helperPath); + // app.isPackaged is the trusted boundary. Environment flags + // can request the feature, but cannot weaken its manifest + // contract or make development artifacts package-trusted. + availability = probeRuntime( + helperPath, + app.isPackaged ? 'packaged' : 'development' + ); } catch { availability = { usable: false, @@ -192,7 +202,7 @@ export function isFrameCopyRuntimeUsable( export function shouldPromotePersistedFrameCopyOptIn( storedEnabled: boolean, explicitEnv: string | undefined, - runtimeUsable = isFrameCopyRuntimeUsable() + runtimeUsable: () => boolean = isFrameCopyRuntimeUsable ): boolean { - return explicitEnv === undefined && storedEnabled && runtimeUsable; + return explicitEnv === undefined && storedEnabled && runtimeUsable(); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy-runtime.spec.ts index f7a506ee4..fcdfe2e27 100644 --- 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 @@ -24,6 +24,161 @@ import { 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; @@ -54,6 +209,54 @@ function createRuntimeFiles( .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' @@ -90,7 +293,11 @@ function createFixture( })), externalDependencies: [], }; - const externalSystemLibraries: unknown[] = []; + const externalSystemLibraries = cloneManifest(EXTERNAL_SYSTEM_LIBRARIES); + const sourceRuntime = createSourceRuntime( + runtimeFiles, + runtimeDependencyClosure + ); const manifest: Record = { schemaVersion: 1, origin: bundled @@ -150,19 +357,7 @@ function createFixture( ? { runtimeDependencyClosure, externalSystemLibraries, - sourceRuntime: { - schemaVersion: 1, - origin: 'vendored-lgpl-source-build', - platform: 'linux', - arch: 'x64', - runtimeFiles, - runtimeTotalBytes: runtimeFiles.reduce( - (total, runtimeFile) => total + runtimeFile.size, - 0 - ), - runtimeDependencyClosure, - externalSystemLibraries, - }, + sourceRuntime, } : {}), }; @@ -185,6 +380,89 @@ function createFixture( }; } +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 @@ -195,10 +473,8 @@ function writeManifest( chmodSync(manifestPath, 0o644); } -function cloneManifest( - manifest: Record -): Record { - return JSON.parse(JSON.stringify(manifest)) as Record; +function cloneManifest(manifest: T): T { + return JSON.parse(JSON.stringify(manifest)) as T; } describe('embedded-mpv-frame-copy-runtime', () => { @@ -234,14 +510,18 @@ describe('embedded-mpv-frame-copy-runtime', () => { return createEmbeddedMpvFrameCopyRuntimeProbe({ platform: 'linux', arch: 'x64', - env: { PATH: '/usr/bin', LD_LIBRARY_PATH: '/system/libs' }, + 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, preserves its environment, and caches by helper/manifest identity', () => { + it('validates a system package, sanitizes loader overrides, and caches by helper/manifest identity', () => { const fixture = createFixture(rootDir); const probeRuntime = createProbe(); @@ -263,7 +543,6 @@ describe('embedded-mpv-frame-copy-runtime', () => { windowsHide: true, env: { PATH: '/usr/bin', - LD_LIBRARY_PATH: '/system/libs', }, } ); @@ -271,7 +550,7 @@ describe('embedded-mpv-frame-copy-runtime', () => { }); it.each(['portable', 'flatpak'] as const)( - 'validates the exact %s bundled closure and prepends only its private library directory', + 'validates the exact %s bundled closure and uses only its private library directory', (profile) => { const fixture = createFixture(rootDir, profile); const probeRuntime = createProbe(); @@ -289,16 +568,126 @@ describe('embedded-mpv-frame-copy-runtime', () => { expect.objectContaining({ env: { PATH: '/usr/bin', - LD_LIBRARY_PATH: `${path.join( - fixture.nativeDir, - 'lib' - )}:/system/libs`, + 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(); @@ -487,6 +876,211 @@ describe('embedded-mpv-frame-copy-runtime', () => { 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')); 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 1073f24f1..0bd8cf7a0 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 @@ -10,11 +10,191 @@ 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 LINUX_LIBRARY_PATH_DELIMITER = ':'; 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: { @@ -43,6 +223,37 @@ const EXPECTED_PROCESS_ISOLATION = { 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', @@ -114,10 +325,15 @@ export type EmbeddedMpvFrameCopyRuntimeFailureReason = | 'helper-probe-unusable' | 'runtime-probe-internal-error'; +export type EmbeddedMpvFrameCopyManifestContract = 'packaged' | 'development'; + +type RuntimeProfile = keyof typeof PROFILE_CONTRACTS; +type RuntimeProbeProfile = RuntimeProfile | 'development'; + export type EmbeddedMpvFrameCopyRuntimeResult = | { usable: true; - profile: keyof typeof PROFILE_CONTRACTS; + profile: RuntimeProbeProfile; runtimeMode: 'system' | 'bundled'; libmpv: string; renderApi: 'egl'; @@ -142,8 +358,6 @@ export interface EmbeddedMpvFrameCopyRuntimeDependencies { spawnSync: typeof nodeSpawnSync; } -type RuntimeProfile = keyof typeof PROFILE_CONTRACTS; - interface RuntimeFile { name: string; size: number; @@ -151,7 +365,7 @@ interface RuntimeFile { } interface ValidManifest { - profile: RuntimeProfile; + profile: RuntimeProbeProfile; runtimeMode: 'system' | 'bundled'; runtimeFiles: RuntimeFile[]; } @@ -260,9 +474,10 @@ function validateTargets( targets: unknown, allowedTargets: ReadonlySet ): boolean { + const expectedTargets = [...allowedTargets].sort(); if ( !Array.isArray(targets) || - targets.length === 0 || + targets.length !== expectedTargets.length || targets.some( (target) => typeof target !== 'string' || @@ -273,10 +488,7 @@ function validateTargets( ) { return false; } - return ( - new Set(targets).size === targets.length && - isDeepStrictEqual([...targets].sort(), targets) - ); + return isDeepStrictEqual(targets, expectedTargets); } function validateRuntimeFiles(value: unknown): RuntimeFile[] | null { @@ -317,9 +529,14 @@ function validateRuntimeFiles(value: unknown): RuntimeFile[] | null { function validateRuntimeClosure( value: unknown, runtimeFiles: RuntimeFile[], - libmpvSoname: string + libmpvSoname: string, + externalSystemLibraries: unknown ): boolean { if ( + !isDeepStrictEqual( + externalSystemLibraries, + EXPECTED_EXTERNAL_SYSTEM_LIBRARIES + ) || !isObject(value) || !hasExactFields(value, ['entries', 'externalDependencies']) || !Array.isArray(value.entries) || @@ -327,14 +544,17 @@ function validateRuntimeClosure( value.externalDependencies.some( (dependency) => typeof dependency !== 'string' || - !SAFE_RUNTIME_NAME_PATTERN.test(dependency) + !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) || @@ -351,28 +571,274 @@ function validateRuntimeClosure( entry.needed.some( (dependency) => typeof dependency !== 'string' || - !SAFE_RUNTIME_NAME_PATTERN.test(dependency) + !SAFE_RUNTIME_NAME_PATTERN.test(dependency) || + !SHARED_LIBRARY_PATTERN.test(dependency) ) || !isDeepStrictEqual(entry.rpath, []) || - !isDeepStrictEqual(entry.runpath, ['$ORIGIN']) + !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].sort(), [...runtimeNames].sort()) && + isDeepStrictEqual(closureNames, runtimeNames) && new Set(closureNames).size === closureNames.length && + isDeepStrictEqual( + value.externalDependencies, + [...computedExternalDependencies].sort() + ) && isObject(linkerAlias) && linkerAlias.soname === libmpvSoname ); } -function validateManifest( +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 ( @@ -449,28 +915,13 @@ function validateManifest( !validateRuntimeClosure( manifest.runtimeDependencyClosure, runtimeFiles, - manifest.libmpvSoname + manifest.libmpvSoname, + manifest.externalSystemLibraries ) || - !Array.isArray(manifest.externalSystemLibraries) || - !isObject(manifest.sourceRuntime) - ) { - return validationFailure('runtime-manifest-invalid'); - } - - const sourceRuntime = manifest.sourceRuntime; - if ( - sourceRuntime.schemaVersion !== 1 || - sourceRuntime.origin !== 'vendored-lgpl-source-build' || - sourceRuntime.platform !== 'linux' || - sourceRuntime.arch !== 'x64' || - sourceRuntime.runtimeTotalBytes !== manifest.runtimeTotalBytes || - !isDeepStrictEqual(sourceRuntime.runtimeFiles, manifest.runtimeFiles) || - !isDeepStrictEqual( - sourceRuntime.runtimeDependencyClosure, - manifest.runtimeDependencyClosure - ) || - !isDeepStrictEqual( - sourceRuntime.externalSystemLibraries, + !validateSourceRuntimePolicy( + manifest.sourceRuntime, + runtimeFiles, + manifest.runtimeDependencyClosure, manifest.externalSystemLibraries ) ) { @@ -486,6 +937,144 @@ function validateManifest( }; } +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, @@ -606,7 +1195,8 @@ function validateBundledRuntimeFiles( function validatePackage( helperPath: string, manifestPath: string, - fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem + fileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem, + manifestContract: EmbeddedMpvFrameCopyManifestContract ): ValidationResult { const nativeDir = path.dirname(helperPath); if ( @@ -633,7 +1223,10 @@ function validatePackage( if (isValidationFailure(manifestResult)) { return manifestResult; } - const validManifest = validateManifest(manifestResult.value); + const validManifest = + manifestContract === 'packaged' + ? validatePackagedManifest(manifestResult.value) + : validateDevelopmentManifest(manifestResult.value); if (isValidationFailure(validManifest)) { return validManifest; } @@ -749,13 +1342,13 @@ function runHelperProbe( dependencies: EmbeddedMpvFrameCopyRuntimeDependencies ): EmbeddedMpvFrameCopyRuntimeResult { const probeEnvironment = { ...dependencies.env }; + // The manifest/OS package is the loader contract. Ambient overrides could + // replace libmpv or inject code into the helper before its bounded probe. + delete probeEnvironment.LD_LIBRARY_PATH; + delete probeEnvironment.LD_PRELOAD; if (runtimePackage.manifest.runtimeMode === 'bundled') { const bundledLibDir = path.join(runtimePackage.nativeDir, 'lib'); - probeEnvironment.LD_LIBRARY_PATH = - bundledLibDir + - (probeEnvironment.LD_LIBRARY_PATH - ? `${LINUX_LIBRARY_PATH_DELIMITER}${probeEnvironment.LD_LIBRARY_PATH}` - : ''); + probeEnvironment.LD_LIBRARY_PATH = bundledLibDir; } let result: ReturnType; @@ -799,7 +1392,10 @@ function runHelperProbe( */ export function createEmbeddedMpvFrameCopyRuntimeProbe( overrides: Partial = {} -): (helperPath: string) => EmbeddedMpvFrameCopyRuntimeResult { +): ( + helperPath: string, + manifestContract?: EmbeddedMpvFrameCopyManifestContract +) => EmbeddedMpvFrameCopyRuntimeResult { const defaultFileSystem: EmbeddedMpvFrameCopyRuntimeFileSystem = { accessSync: (filePath, mode) => nodeFileSystem.accessSync(filePath, mode), @@ -817,7 +1413,10 @@ export function createEmbeddedMpvFrameCopyRuntimeProbe( }; const resultCache = new Map(); - return (helperPath: string): EmbeddedMpvFrameCopyRuntimeResult => { + return ( + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract = 'packaged' + ): EmbeddedMpvFrameCopyRuntimeResult => { if (dependencies.platform !== 'linux') { return failure('unsupported-platform'); } @@ -846,7 +1445,7 @@ export function createEmbeddedMpvFrameCopyRuntimeProbe( ); } - const cacheKey = `${fileIdentity( + const cacheKey = `${manifestContract}\0${fileIdentity( helperPath, helperStat )}\0${fileIdentity(manifestPath, manifestStat)}`; @@ -860,7 +1459,8 @@ export function createEmbeddedMpvFrameCopyRuntimeProbe( const runtimePackage = validatePackage( helperPath, manifestPath, - dependencies.fileSystem + dependencies.fileSystem, + manifestContract ); result = isValidationFailure(runtimePackage) ? failure(runtimePackage.reason) @@ -880,7 +1480,8 @@ const processRuntimeProbe = createEmbeddedMpvFrameCopyRuntimeProbe(); * the service gate. The helper and manifest identities scope cached results. */ export function probeEmbeddedMpvFrameCopyRuntime( - helperPath: string + helperPath: string, + manifestContract: EmbeddedMpvFrameCopyManifestContract ): EmbeddedMpvFrameCopyRuntimeResult { - return processRuntimeProbe(helperPath); + return processRuntimeProbe(helperPath, manifestContract); } diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index 57e01df3f..fe12e6bc9 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts @@ -703,17 +703,35 @@ describe('Embedded MPV native source recording invariants', () => { ); }); - it('requires Linux embedded MPV build inputs and validates process isolation in CI', () => { + it('requires the pinned Linux source runtime artifact and validates process isolation in CI', () => { expect(buildAndMakeWorkflowSource).toContain( + 'Build and stage pinned LGPL Linux runtime' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'name: linux-embedded-mpv-runtime' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'path: vendor/embedded-mpv/linux-x64' + ); + expect(buildAndMakeWorkflowSource).toContain( + 'Download pinned Linux Embedded MPV runtime' + ); + expect(buildAndMakeWorkflowSource).not.toContain( 'libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev' ); - expect(buildAndMakeWorkflowSource).toContain( + expect(buildAndMakeWorkflowSource).not.toContain( 'Stage Linux embedded MPV build inputs' ); - expect(buildAndMakeWorkflowSource).toContain( - "linuxBackend: 'process-isolated mpv --wid'" - ); expect(buildAndMakeWorkflowSource).toContain("matrix.os == 'linux'"); + expect(buildAndMakeWorkflowSource).toContain( + "manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true" + ); expect(buildAndMakeWorkflowSource).toContain( 'Linux embedded MPV addon must not link directly to libmpv' ); @@ -721,7 +739,7 @@ describe('Embedded MPV native source recording invariants', () => { 'test -f dist/apps/electron-backend/native/iptvnator_mpv_helper' ); expect(buildAndMakeWorkflowSource).toContain( - 'Linux frame-copy helper must link libmpv' + 'Linux frame-copy helper must need libmpv.so.2' ); }); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts index 4cba7f713..4b190f607 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-native.service.ts @@ -113,8 +113,9 @@ export class EmbeddedMpvNativeService { /** * Frame-copy engine: helper process + shm ring + renderer canvas. * Experimental, macOS Apple Silicon (owner decision 2026-07-10), Linux - * and Windows, opted into with IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 - * on top of the regular embedded MPV experiment flag. + * x64 and Windows, opted into with + * IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY=1 on top of the regular + * embedded MPV experiment flag. */ private isFrameCopyEngineRequested(): boolean { return ['1', 'true', 'yes', 'on'].includes( diff --git a/apps/electron-backend/src/app/services/store.service.ts b/apps/electron-backend/src/app/services/store.service.ts index a6bb9d68a..7bd61ae7c 100644 --- a/apps/electron-backend/src/app/services/store.service.ts +++ b/apps/electron-backend/src/app/services/store.service.ts @@ -9,10 +9,10 @@ export const VLC_PLAYER_ARGUMENTS = 'VLC_PLAYER_ARGUMENTS'; export const MPV_REUSE_INSTANCE = 'MPV_REUSE_INSTANCE'; export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE'; /** - * Embedded MPV frame-copy engine opt-in (macOS arm64, Linux). Lives in the main - * process config file because it must be readable synchronously before the - * BrowserWindow is created — the engine relaxes the window sandbox for its - * preload frame pump, which cannot change after window creation. + * Embedded MPV frame-copy engine opt-in (macOS arm64, Linux x64). Lives in the + * main process config file because it must be readable synchronously before + * the BrowserWindow is created — the engine relaxes the window sandbox for + * its preload frame pump, which cannot change after window creation. */ export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY'; diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts index 0e99bd6ee..f59e96a90 100644 --- a/apps/electron-backend/src/main.ts +++ b/apps/electron-backend/src/main.ts @@ -36,10 +36,7 @@ import { shouldPromotePersistedFrameCopyOptIn, } from './app/services/embedded-mpv-frame-copy-platform.util'; import { isEmbeddedMpvFeatureEnabled } from './app/services/embedded-mpv-runtime-policy.util'; -import { - EMBEDDED_MPV_FRAME_COPY, - store, -} from './app/services/store.service'; +import { EMBEDDED_MPV_FRAME_COPY, store } from './app/services/store.service'; app.setName('iptvnator'); @@ -74,7 +71,7 @@ if ( shouldPromotePersistedFrameCopyOptIn( store.get(EMBEDDED_MPV_FRAME_COPY, false), process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY, - isFrameCopyRuntimeUsable() + isFrameCopyRuntimeUsable ) ) { process.env.IPTVNATOR_ENABLE_EMBEDDED_MPV_FRAME_COPY = '1';