From 482630dd2a43c0db94012b42e8a206c3c6b71110 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 09:32:42 +0200 Subject: [PATCH] fix(packaging): harden Linux frame-copy delivery --- .github/workflows/build-and-make.yaml | 9 +- AGENTS.md | 6 +- CLAUDE.md | 7 +- apps/electron-backend/build-embedded-mpv.js | 27 +++- .../embedded-mpv-linux-linkage.cjs | 46 ++++++ .../helper-environment.spec.ts | 3 + .../helper-environment.ts | 1 + .../runtime-harness.test-helpers.ts | 1 + .../embedded-mpv-frame-copy.adapter.spec.ts | 2 + .../embedded-mpv-linux-linkage.spec.ts | 97 ++++++++++++ .../embedded-mpv-native-source.spec.ts | 9 +- docs/architecture/embedded-mpv-native.md | 11 +- tools/embedded-mpv/README.md | 11 +- .../configure-linux-frame-copy-build.test.mjs | 12 ++ .../verify-linux-frame-copy-runtime.mjs | 14 +- .../verify-linux-frame-copy-runtime.test.mjs | 139 ++++++++++++++++++ 16 files changed, 367 insertions(+), 28 deletions(-) diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 5976f3cc7..2e0166460 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -827,9 +827,12 @@ jobs: cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json node tools/packaging/configure-linux-frame-copy-build.mjs --foreign-deb - pnpm nx run electron-backend:make \ - --outputPath=dist/executables-linux-foreign \ - --publishPolicy=never + for foreign_arch in armv7l arm64; do + pnpm nx run electron-backend:make \ + --arch="${foreign_arch}" \ + --outputPath=dist/executables-linux-foreign \ + --publishPolicy=never + done find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' \ -exec mv '{}' dist/executables/ ';' diff --git a/AGENTS.md b/AGENTS.md index af8006709..f24e10d8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,9 +240,9 @@ Key files: sandbox. Any failure reports a stable reason and falls back to native-view without crashing; an environment flag never bypasses this gate. - Snap uses an exact private `shared-memory` plug. The probe and playback - helper share one sanitized loader environment: ambient preload/library - paths are removed, the validated private closure wins, and trusted Snap GL - roots precede generic in-snap library roots. + helper share one sanitized loader environment: ambient audit, preload, and + library paths are removed, the validated private closure wins, and trusted + Snap GL roots precede generic in-snap library roots. - Bundled Linux releases must publish the exact source archives/git records, checksums, licenses, flags, patches, build scripts, and the pinned hwdata `pnp.ids` input. Each bundled package carries diff --git a/CLAUDE.md b/CLAUDE.md index 918fc8851..e995a4999 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -636,9 +636,10 @@ engine` (restart required) or packaged manifest/file/hash gate and bounded `--runtime-probe`; any failure keeps the sandbox enabled, records a stable reason, and falls back to native-view without crashing. Snap uses an exact private `shared-memory` - plug; probe and playback share a sanitized loader environment in which the - validated private closure and trusted Snap GL roots have explicit - precedence. Bundled Linux packages carry hash-validated + plug; probe and playback share a sanitized loader environment in which + ambient audit, preload, and library paths are removed and the validated + private closure and trusted Snap GL roots have explicit precedence. Bundled + Linux packages carry hash-validated `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses/**`. CI caches the staged runtime plus immutable source inputs, never finished notices or the compliance tarball; it regenerates those notices and the diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 8fe6443df..163629735 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -17,6 +17,7 @@ const { validateLinuxSystemBuildInputManifest, } = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs'); const { + resolveLinuxFrameCopyLinkageInputs, resolveVerifiedLinuxLibMpvSoname, runWithCleanup, validateLinuxFrameCopyLinkage, @@ -316,9 +317,9 @@ function findWindowsLibMpv(runtimeRoot) { } /* Debian/Ubuntu install linker targets under the multiarch triple dir. The - * compiler's built-in search paths cover it for -l resolution either way; - * this keeps the -L flag and the helper's baked rpath pointing somewhere - * real. */ + * compiler's built-in search paths cover it for -l resolution either way. + * This directory is a link-time input only; the helper runtime intentionally + * stays on the sanitized system loader contract. */ function defaultLinuxSystemLibDir() { const multiarchTriples = { arm: 'arm-linux-gnueabihf', @@ -381,8 +382,9 @@ function resolveRuntime() { if (targetPlatform === 'linux') { // Dev-first Linux flow (frame-copy helper links system libmpv): a // distro libmpv-dev install is a full runtime — no staging needed. - // LIBMPV_INCLUDE_DIR / LINUX_NATIVE_LIBRARY_DIR override the system - // paths for machines with a local (non-root) libmpv prefix. + // LIBMPV_INCLUDE_DIR overrides the header path. + // LINUX_NATIVE_LIBRARY_DIR overrides the link-time library directory, + // which must already be visible to the system dynamic loader. const systemIncludeDir = process.env.LIBMPV_INCLUDE_DIR || '/usr/include'; if (fs.existsSync(path.join(systemIncludeDir, 'mpv', 'client.h'))) { @@ -765,6 +767,16 @@ function main() { : targetPlatform === 'linux' ? writeLinuxFrameCopyBuildManifest(runtime) : copyGenericRuntimeToNativeBuild(runtime); + const linuxLinkageInputs = + targetPlatform === 'linux' + ? resolveLinuxFrameCopyLinkageInputs({ + buildInputMode: runtime.buildInputMode, + outputLibDir, + packagedLibmpvSoname: runtimeManifest.libmpvSoname, + readDynamicSection: readLinuxDynamicSection, + runtimeLibDir: runtime.libDir, + }) + : null; const electronPackageJson = require( path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') @@ -781,7 +793,8 @@ function main() { LIBMPV_INCLUDE_DIR: runtime.includeDir, ...(targetPlatform === 'linux' ? { - LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: outputLibDir, + LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: + linuxLinkageInputs.linkerLibraryDir, } : { LIBMPV_LIBRARY_DIR: outputLibDir }), ...(runtime.windowsImportLib @@ -807,7 +820,7 @@ function main() { if (targetPlatform === 'linux') { validateLinuxFrameCopyLinkage({ - expectedLibmpvSoname: runtimeManifest.libmpvSoname, + expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname, outputDir, readDynamicSection: readLinuxDynamicSection, }); diff --git a/apps/electron-backend/embedded-mpv-linux-linkage.cjs b/apps/electron-backend/embedded-mpv-linux-linkage.cjs index adc1cd806..3cfa0b197 100644 --- a/apps/electron-backend/embedded-mpv-linux-linkage.cjs +++ b/apps/electron-backend/embedded-mpv-linux-linkage.cjs @@ -201,6 +201,51 @@ function resolveVerifiedLinuxLibMpvSoname({ return expectedSoname; } +function resolveLinuxFrameCopyLinkageInputs({ + buildInputMode, + outputLibDir, + packagedLibmpvSoname, + readDynamicSection, + runtimeLibDir, +}) { + const systemDevelopment = buildInputMode === 'system-dev'; + const linkerLibraryDir = systemDevelopment ? runtimeLibDir : outputLibDir; + if ( + typeof linkerLibraryDir !== 'string' || + linkerLibraryDir.trim().length === 0 + ) { + throw new Error( + 'Linux frame-copy linkage requires a non-empty linker library directory.' + ); + } + + if (!systemDevelopment) { + return { + expectedLibmpvSoname: packagedLibmpvSoname, + linkerLibraryDir, + }; + } + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + const linkerInputPath = path.join(linkerLibraryDir, 'libmpv.so'); + const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath)); + if ( + dynamic.soname.length !== 1 || + !VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0]) + ) { + throw new Error( + 'The system-development libmpv linker input must contain exactly one versioned libmpv SONAME.' + ); + } + + return { + expectedLibmpvSoname: dynamic.soname[0], + linkerLibraryDir, + }; +} + function assertRegularArtifact(filePath, label) { let stat; try { @@ -288,6 +333,7 @@ function runWithCleanup(operation, cleanup) { module.exports = { parseReadelfDynamic, + resolveLinuxFrameCopyLinkageInputs, resolveVerifiedLinuxLibMpvSoname, runWithCleanup, validateLinuxFrameCopyLinkage, 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 index 20dd20af0..a5abdf900 100644 --- 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 @@ -8,6 +8,7 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { { PATH: '/usr/bin', HOME: '/home/user', + LD_AUDIT: '/tmp/audit.so', LD_LIBRARY_PATH: '/tmp/hostile-libs', LD_PRELOAD: '/tmp/inject.so', }, @@ -41,6 +42,7 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { '/var/lib/snapd/lib/gl/nvidia', '/var/lib/snapd/lib/gl-evil', ].join(':'), + LD_AUDIT: '/tmp/audit.so', LD_LIBRARY_PATH: '/tmp/hostile-libs', LD_PRELOAD: '/tmp/inject.so', }, @@ -75,6 +77,7 @@ describe('createLinuxFrameCopyHelperEnvironment', () => { PATH: '/usr/bin', SNAP: '/snap/iptvnator/42', SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_AUDIT: '/tmp/audit.so', LD_LIBRARY_PATH: '/tmp/hostile-libs', LD_PRELOAD: '/tmp/inject.so', }, 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 index 9889f4cb4..860efdd8b 100644 --- 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 @@ -86,6 +86,7 @@ export function createLinuxFrameCopyHelperEnvironment( runtimeMode: EmbeddedMpvFrameCopyRuntimeMode ): NodeJS.ProcessEnv { const helperEnvironment = { ...environment }; + delete helperEnvironment.LD_AUDIT; delete helperEnvironment.LD_LIBRARY_PATH; delete helperEnvironment.LD_PRELOAD; 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 index 03f5ec53d..3f27c66fa 100644 --- 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 @@ -53,6 +53,7 @@ export function createRuntimeTestContext(): RuntimeTestContext { arch: 'x64', env: { PATH: '/usr/bin', + LD_AUDIT: '/tmp/audit.so', LD_LIBRARY_PATH: '/ambient/libs', LD_PRELOAD: '/tmp/inject.so', }, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts index adbfa3327..3900a055a 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-frame-copy.adapter.spec.ts @@ -117,6 +117,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { environment: { PATH: '/usr/bin', HOME: '/home/user', + LD_AUDIT: '/tmp/audit.so', LD_LIBRARY_PATH: '/tmp/hostile-libs', LD_PRELOAD: '/tmp/inject.so', }, @@ -148,6 +149,7 @@ describe('EmbeddedMpvFrameCopyAdapter', () => { PATH: '/snap/bin:/usr/bin', SNAP: snapRoot, SNAP_LIBRARY_PATH: '/var/lib/snapd/lib/gl:/tmp/hostile-gl', + LD_AUDIT: '/tmp/audit.so', LD_LIBRARY_PATH: '/tmp/hostile-libs', LD_PRELOAD: '/tmp/inject.so', }, diff --git a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts index 2847438ee..5ce94928d 100644 --- a/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts +++ b/apps/electron-backend/src/app/services/embedded-mpv-linux-linkage.spec.ts @@ -52,6 +52,16 @@ function loadLinkageModule(): { runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; runtimeFiles: RuntimeFileRecord[]; }) => string; + resolveLinuxFrameCopyLinkageInputs: (options: { + buildInputMode: string; + outputLibDir: string; + packagedLibmpvSoname: string | null; + readDynamicSection: (filePath: string) => string; + runtimeLibDir: string; + }) => { + expectedLibmpvSoname: string | null; + linkerLibraryDir: string; + }; runWithCleanup: (operation: () => T, cleanup: () => void) => T; validateLinuxFrameCopyLinkage: (options: { expectedLibmpvSoname: string; @@ -287,6 +297,93 @@ describe('Linux Embedded MPV linkage verification', () => { ).toThrow(/size|SHA-256/i); }); + it('uses and identifies the unmanaged system libmpv only for system development', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const readDynamicSection = jest.fn((filePath: string) => { + expect(filePath).toBe('/opt/libmpv/lib/libmpv.so'); + return readelfDynamic([['SONAME', 'libmpv.so.2']]); + }); + + expect( + resolveLinuxFrameCopyLinkageInputs({ + buildInputMode: 'system-dev', + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: null, + readDynamicSection, + runtimeLibDir: '/opt/libmpv/lib', + }) + ).toEqual({ + expectedLibmpvSoname: 'libmpv.so.2', + linkerLibraryDir: '/opt/libmpv/lib', + }); + expect(readDynamicSection).toHaveBeenCalledTimes(1); + }); + + it('keeps bundled and untrusted build modes on the copied runtime directory', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const readDynamicSection = jest.fn(() => { + throw new Error('must not inspect the ambient system runtime'); + }); + + for (const buildInputMode of [ + 'bundled-runtime', + 'system-build-inputs', + 'unexpected-mode', + ]) { + expect( + resolveLinuxFrameCopyLinkageInputs({ + buildInputMode, + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: + buildInputMode === 'bundled-runtime' + ? 'libmpv.so.2' + : null, + readDynamicSection, + runtimeLibDir: '/usr/lib/x86_64-linux-gnu', + }) + ).toEqual({ + expectedLibmpvSoname: + buildInputMode === 'bundled-runtime' ? 'libmpv.so.2' : null, + linkerLibraryDir: '/native/build/Release/lib', + }); + } + expect(readDynamicSection).not.toHaveBeenCalled(); + }); + + it('rejects ambiguous or unversioned system-development libmpv identities', () => { + const { resolveLinuxFrameCopyLinkageInputs } = loadLinkageModule(); + const options = { + buildInputMode: 'system-dev', + outputLibDir: '/native/build/Release/lib', + packagedLibmpvSoname: null, + runtimeLibDir: '/usr/lib/x86_64-linux-gnu', + }; + + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so']]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => readelfDynamic([]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + expect(() => + resolveLinuxFrameCopyLinkageInputs({ + ...options, + readDynamicSection: () => + readelfDynamic([ + ['SONAME', 'libmpv.so.1'], + ['SONAME', 'libmpv.so.2'], + ]), + }) + ).toThrow(/system-development.*exactly one versioned libmpv SONAME/i); + }); + function createArtifactFixture(): { outputDir: string; readDynamicSection: (filePath: string) => string; 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 e5c274e6c..a91254818 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 @@ -604,7 +604,14 @@ describe('Embedded MPV native source recording invariants', () => { 'SHA-256 mismatch for staged Linux runtime file' ); expect(buildScriptSource).toContain( - 'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: outputLibDir' + 'resolveLinuxFrameCopyLinkageInputs({' + ); + expect(buildScriptSource).toContain( + 'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR:\n' + + ' linuxLinkageInputs.linkerLibraryDir' + ); + expect(buildScriptSource).toContain( + 'expectedLibmpvSoname: linuxLinkageInputs.expectedLibmpvSoname' ); expect(buildScriptSource).not.toContain( 'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir' diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index 6838aee5a..79c7ba35a 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -254,10 +254,11 @@ JSON line and return zero. The startup probe and every playback helper session use the same sanitized loader environment selected by the validated manifest's cached `runtimeMode`. -Both remove ambient `LD_PRELOAD` and `LD_LIBRARY_PATH`; the system profile then -uses the default system loader without a private path. Bundled profiles put the -validated packaged `native/lib` first. AppImage and Flatpak resolve the declared -external graphics/audio interfaces through their normal host or sandbox loader. +Both remove ambient `LD_AUDIT`, `LD_PRELOAD`, and `LD_LIBRARY_PATH`; the system +profile then uses the default system loader without a private path. Bundled +profiles put the validated packaged `native/lib` first. AppImage and Flatpak +resolve the declared external graphics/audio interfaces through their normal +host or sandbox loader. Inside a genuine Snap mount, filtered absolute `SNAP_LIBRARY_PATH` entries below `/var/lib/snapd/lib/gl` follow `native/lib` and precede the generic `$SNAP/lib`, `$SNAP/usr/lib`, and x64 multiarch roots. This preserves the pinned @@ -558,7 +559,7 @@ The Electron main process holds an `electron.powerSaveBlocker` of type `prevent- Current development behavior: - The addon build supports `darwin`, `win32`, and `linux`; Windows and Linux builds require running on that target OS. -- The build script first looks for staged inputs at `vendor/embedded-mpv/-/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries; `LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR` override the default system paths. +- The build script first looks for staged inputs at `vendor/embedded-mpv/-/`. On Linux, local development can fall back to distribution `libmpv-dev` headers and libraries. `LIBMPV_INCLUDE_DIR` overrides the header root. `LINUX_NATIVE_LIBRARY_DIR` is a link-time override and must name a directory already visible to the system dynamic loader; it is never inherited as helper `LD_LIBRARY_PATH`. - When the staged-input path is used, it must contain `include/mpv/client.h`, `runtime-manifest.json`, and the platform runtime/build files. The Linux source builder also stages the complete declared `.so` closure. diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index 8e506dd59..d990830bc 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -126,8 +126,9 @@ POSIX shm namespace without granting global cross-snap shared-memory access. The bounded probe and every playback helper share one sanitized loader environment derived from the validated, cached runtime mode. Ambient -`LD_PRELOAD` and `LD_LIBRARY_PATH` are removed. System packages then use the -default loader; bundled packages put their validated `native/lib` first. +`LD_AUDIT`, `LD_PRELOAD`, and `LD_LIBRARY_PATH` are removed. System packages +then use the default loader; bundled packages put their validated `native/lib` +first. AppImage and Flatpak use normal host/sandbox lookup for the declared external interfaces. In a genuine Snap mount, filtered `SNAP_LIBRARY_PATH` GL roots under `/var/lib/snapd/lib/gl` come next, ahead of generic `$SNAP` library and @@ -230,8 +231,10 @@ license notices with the binary. Linux can use distribution development packages for an unshipped local build (`libmpv-dev`, EGL/OpenGL/GBM development files, and X11 headers). Overrides: -`LIBMPV_INCLUDE_DIR` and `LINUX_NATIVE_LIBRARY_DIR`. Required/release package -builds must use the pinned staged runtime and manifest. +`LIBMPV_INCLUDE_DIR` selects the header root. `LINUX_NATIVE_LIBRARY_DIR` +selects a link-time library directory that must already be visible to the +system dynamic loader; it is not inherited as a helper `LD_LIBRARY_PATH`. +Required/release package builds must use the pinned staged runtime and manifest. On macOS: diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index dde8b74e6..427e81518 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -362,6 +362,18 @@ test('Linux CI verifies every package family and exercises intended environments assert.doesNotMatch(buildWorkflow, /\bldd\b/); }); +test('foreign DEB CI explicitly selects both marker-only ARM architectures', () => { + const foreignDebStep = workflowStep( + 'Make marker-only foreign-architecture DEB packages' + ); + + assert.match(foreignDebStep, /for foreign_arch in armv7l arm64; do/); + assert.match(foreignDebStep, /--arch="\$\{foreign_arch\}"/); + assert.match(foreignDebStep, /IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ''/); + assert.match(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1'/); + assert.doesNotMatch(foreignDebStep, /IPTVNATOR_REQUIRE_EMBEDDED_MPV: '0'/); +}); + test('dedicated packaged x64 smoke cannot silently skip', () => { const linuxDependencies = workflowStep('Install Linux system dependencies'); assert.match(linuxDependencies, /--no-install-recommends/); diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index 34186550a..302e937f0 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -179,7 +179,8 @@ export function extractLinuxArtifact({ destination, runCommand = defaultRunCommand, }) { - fs.mkdirSync(destination, { recursive: true }); + fs.rmSync(destination, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(destination), { recursive: true }); const run = (command, args, options = {}) => assertCommandSucceeded( command, @@ -198,7 +199,6 @@ export function extractLinuxArtifact({ const failures = []; for (const offset of squashfsOffsets) { fs.rmSync(destination, { recursive: true, force: true }); - fs.mkdirSync(destination, { recursive: true }); const args = [ '-no-progress', '-offset', @@ -226,10 +226,12 @@ export function extractLinuxArtifact({ ); } case 'deb': + fs.mkdirSync(destination, { recursive: true }); run('dpkg-deb', ['--extract', artifactPath, destination]); return destination; case 'rpm': case 'pacman': + fs.mkdirSync(destination, { recursive: true }); run('bsdtar', [ '--extract', '--file', @@ -247,9 +249,15 @@ export function extractLinuxArtifact({ ]); return destination; case 'flatpak': { + fs.mkdirSync(destination, { recursive: true }); const repository = path.join(destination, '.ostree-repository'); const checkout = path.join(destination, 'checkout'); fs.mkdirSync(repository, { recursive: true }); + run('ostree', [ + `--repo=${repository}`, + 'init', + '--mode=archive-z2', + ]); run('flatpak', ['build-import-bundle', repository, artifactPath]); const refsResult = run('ostree', ['refs', `--repo=${repository}`]); const refs = String(refsResult.stdout ?? '') @@ -265,6 +273,7 @@ export function extractLinuxArtifact({ } run('ostree', [ 'checkout', + '-U', `--repo=${repository}`, refs[0], checkout, @@ -1122,6 +1131,7 @@ export function createRuntimeProbeEnvironment({ runtimeMode, }) { const probeEnvironment = { ...(environment ?? {}) }; + delete probeEnvironment.LD_AUDIT; delete probeEnvironment.LD_LIBRARY_PATH; delete probeEnvironment.LD_PRELOAD; if (runtimeMode === 'bundled') { diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index fb3421714..1899e7568 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -378,6 +378,7 @@ test('extracts every payload format with argument arrays and no shell', () => { 'bsdtar', 'bsdtar', 'unsquashfs', + 'ostree', 'flatpak', 'ostree', 'ostree', @@ -402,6 +403,142 @@ test('extracts every payload format with argument arrays and no shell', () => { '-no-progress', '-dest', ]); + assert.equal(invocations[5].args[1], 'init'); + assert.ok(invocations[5].args.includes('--mode=archive-z2')); + assert.equal(invocations[8].args[0], 'checkout'); + assert.ok(invocations[8].args.includes('-U')); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('gives unsquashfs a fresh destination for every AppImage and Snap extraction', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-unsquashfs-') + ); + const appImagePath = path.join(root, 'IPTVnator.AppImage'); + const snapPath = path.join(root, 'IPTVnator.snap'); + const appImageDestination = path.join(root, 'appimage-payload'); + const snapDestination = path.join(root, 'snap-payload'); + fs.writeFileSync( + appImagePath, + Buffer.concat([ + Buffer.alloc(128), + Buffer.from('hsqs'), + Buffer.alloc(64), + Buffer.from('hsqs'), + ]) + ); + fs.writeFileSync(snapPath, 'snap fixture'); + fs.mkdirSync(appImageDestination); + fs.mkdirSync(snapDestination); + let appImageAttempt = 0; + + try { + extractLinuxArtifact({ + artifactPath: appImagePath, + format: 'appimage', + destination: appImageDestination, + runCommand: (command) => { + assert.equal(command, 'unsquashfs'); + assert.equal(fs.existsSync(appImageDestination), false); + appImageAttempt += 1; + fs.mkdirSync(appImageDestination); + return { + status: appImageAttempt === 1 ? 1 : 0, + stdout: '', + stderr: '', + }; + }, + }); + assert.equal(appImageAttempt, 2); + + extractLinuxArtifact({ + artifactPath: snapPath, + format: 'snap', + destination: snapDestination, + runCommand: (command) => { + assert.equal(command, 'unsquashfs'); + assert.equal(fs.existsSync(snapDestination), false); + fs.mkdirSync(snapDestination); + return { status: 0, stdout: '', stderr: '' }; + }, + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('initializes a user-checkout OSTree repository before importing Flatpak bundles', () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-verifier-flatpak-repo-') + ); + const artifactPath = path.join(root, 'IPTVnator.flatpak'); + const destination = path.join(root, 'flatpak-payload'); + const invocations = []; + let initializedRepository = null; + fs.writeFileSync(artifactPath, 'flatpak fixture'); + + try { + extractLinuxArtifact({ + artifactPath, + format: 'flatpak', + destination, + runCommand: (command, args) => { + invocations.push([command, ...args]); + if (command === 'ostree' && args.includes('init')) { + initializedRepository = args + .find((argument) => argument.startsWith('--repo=')) + ?.slice('--repo='.length); + assert.ok(args.includes('--mode=archive-z2')); + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'flatpak') { + assert.equal(args[0], 'build-import-bundle'); + assert.equal(args[1], initializedRepository); + if (!initializedRepository) { + return { + status: 1, + stdout: '', + stderr: 'error: opening repo: opendir(objects): No such file or directory', + }; + } + return { status: 0, stdout: '', stderr: '' }; + } + if (command === 'ostree' && args[0] === 'refs') { + return { + status: 0, + stdout: 'app/com.fourgray.iptvnator/x86_64/stable\n', + stderr: '', + }; + } + if (command === 'ostree' && args[0] === 'checkout') { + assert.ok(args.includes('-U')); + return { status: 0, stdout: '', stderr: '' }; + } + throw new Error(`Unexpected command: ${command}`); + }, + }); + + assert.deepEqual( + invocations.map(([command, ...args]) => [ + command, + args.find((argument) => + [ + 'init', + 'build-import-bundle', + 'refs', + 'checkout', + ].includes(argument) + ), + ]), + [ + ['ostree', 'init'], + ['flatpak', 'build-import-bundle'], + ['ostree', 'refs'], + ['ostree', 'checkout'], + ] + ); } finally { fs.rmSync(root, { recursive: true, force: true }); } @@ -602,6 +739,7 @@ test('validates an x64 system payload and executes one bounded helper probe', () }, environment: { PATH: '/usr/bin', + LD_AUDIT: '/host/can-inject-audit.so', LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', LD_PRELOAD: '/host/can-inject.so', }, @@ -976,6 +1114,7 @@ test('bundled probes use only the packaged library directory', () => { createRuntimeProbeEnvironment({ environment: { PATH: '/usr/bin', + LD_AUDIT: '/host/can-inject-audit.so', LD_LIBRARY_PATH: '/host/can-mask-missing-dependencies', LD_PRELOAD: '/host/can-inject.so', },