From 351f9609d401a4481c9a27f6f1105650ccb4f3f2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 18 Jul 2026 10:31:11 +0200 Subject: [PATCH] fix(packaging): scope Snap Electron library checks --- AGENTS.md | 5 + CLAUDE.md | 8 +- docs/architecture/embedded-mpv-native.md | 8 + ...mbedded-mpv-frame-copy-packaging-design.md | 6 +- tools/embedded-mpv/README.md | 6 +- tools/packaging/embedded-mpv-arch.test.mjs | 127 ++++++++++++++-- tools/packaging/embedded-mpv-packaging.cjs | 23 ++- .../verify-linux-frame-copy-runtime.mjs | 46 ++---- .../verify-linux-frame-copy-runtime.test.mjs | 137 ++++++++++++++++++ 9 files changed, 315 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 11419600b..c3c3d0f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,6 +236,11 @@ Key files: Electron libraries, `embedded_mpv.node`, and `embedded_mpv_frame_reader.node` must not load or link it. Preserve this process-isolation contract in build, package, and smoke checks. +- Pristine afterPack/unpacked layouts scan Electron libraries recursively. + Extracted Snap payloads exclude only the package-manager `lib/**` and + `usr/lib/**` trees that Snap overlays into the same root; every other + directory remains recursive, and Electron-library symlinks still fail + closed. - Linux frame-copy availability is fail-closed. The packaged manifest, artifact modes, declared bundled hashes/closure, and bounded `--runtime-probe` must all succeed before frame-copy can relax the renderer diff --git a/CLAUDE.md b/CLAUDE.md index ff9c73760..962c35ae6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -627,8 +627,12 @@ engine` (restart required) or ``. Shared `app-player-controls` owns the DOM UI; native-view retains the legacy dock. On Linux, only `iptvnator_mpv_helper` may link libmpv; Electron, its shipped libraries, the - addon, and frame reader must not. Official x64 packages use three separate - profiles: DEB/RPM/Pacman depend on system libmpv plus the helper's direct + addon, and frame reader must not. Pristine afterPack/unpacked layouts scan + Electron libraries recursively; extracted Snap payloads exclude only the + package-manager `lib/**` and `usr/lib/**` trees overlaid into the same root. + Every other directory remains recursive, and Electron-library symlinks still + fail closed. Official x64 packages use three separate profiles: + DEB/RPM/Pacman depend on system libmpv plus the helper's direct EGL/OpenGL/GBM interfaces, AppImage/Snap bundle the pinned LGPL closure, and Flatpak bundles the same closure. Exact system dependencies are DEB=`libmpv2,libegl1,libopengl0,libgbm1`, diff --git a/docs/architecture/embedded-mpv-native.md b/docs/architecture/embedded-mpv-native.md index f3eb52623..16012b8d3 100644 --- a/docs/architecture/embedded-mpv-native.md +++ b/docs/architecture/embedded-mpv-native.md @@ -106,6 +106,14 @@ native directory also contains hash-validated `embedded-mpv-notices.json`, marker-only packages intentionally contain neither a private `native/lib` directory nor the bundled-runtime legal payload. +The pristine `afterPack` and unpacked-layout checks recursively inspect +Electron-owned shared libraries. An extracted Snap has already overlaid its +package-manager `lib/**` and `usr/lib/**` runtime trees onto that same payload +root, so the post-target verifier excludes exactly those two target-provided +trees while continuing to scan every other directory recursively. Electron +libraries are still required to be regular files and free of libmpv linkage; +Snap runtime symlinks are outside that ownership boundary. + ARM Linux packages remain marker-only. They never borrow x64 native artifacts, even when build environment variables claim a matching staged architecture. Consequently frame-copy is not advertised there, and the normal inline/external diff --git a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md index a8d55c119..346627252 100644 --- a/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md +++ b/docs/superpowers/specs/2026-07-17-linux-embedded-mpv-frame-copy-packaging-design.md @@ -117,7 +117,11 @@ they may not retain build-prefix paths. `embedded_mpv.node`, the Electron executable (`iptvnator.bin`), and Electron's shipped libraries must not have a direct `DT_NEEDED` entry for libmpv. `iptvnator_mpv_helper` must have one. Process isolation is an invariant, not a -profile-specific choice. +profile-specific choice. The pristine Electron tree is scanned recursively +before target packaging. Because Snap later overlays package-manager +`lib/**`/`usr/lib/**` trees into the payload root, its extracted-target scan +excludes exactly those two target-provided trees while remaining recursive +everywhere else. Electron-library symlinks outside those roots fail closed. The manifest records: diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index b47b15144..8175fde5c 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -172,7 +172,11 @@ the bundled legal payload. AppImage, Snap, and Flatpak receive `embedded-mpv-notices.json`, `THIRD_PARTY_NOTICES.txt`, and `licenses//**`; DEB, RPM, Pacman, and marker-only packages must not retain them. Package validation also scans the Electron executable and all -shipped Electron libraries for a direct libmpv dependency. +shipped Electron libraries for a direct libmpv dependency. Before target +packaging, that scan is recursive over the pristine Electron tree. After Snap +has merged its template runtime into the payload root, the post-target scan +excludes exactly its package-manager `lib/**` and `usr/lib/**` trees while +remaining recursive everywhere else. At startup, Linux x64 frame-copy is advertised only after the main process validates that manifest/files and successfully executes: diff --git a/tools/packaging/embedded-mpv-arch.test.mjs b/tools/packaging/embedded-mpv-arch.test.mjs index 14d15c659..ddcf8a810 100644 --- a/tools/packaging/embedded-mpv-arch.test.mjs +++ b/tools/packaging/embedded-mpv-arch.test.mjs @@ -958,17 +958,29 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure [] ); - const addonLinkErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, { - ...options, - elfInspector: validElfInspector(fixture.nativeDir, manifest, { - [FRAME_COPY_ARTIFACTS.addon]: { - needed: ['libmpv.so.2'], - rpath: [], - runpath: [], - }, - }), - }); - assert.match(addonLinkErrors.join('\n'), /must not link libmpv/); + for (const artifactName of [ + FRAME_COPY_ARTIFACTS.addon, + FRAME_COPY_ARTIFACTS.frameReader, + ]) { + const isolationErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...options, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + [artifactName]: { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + isolationErrors.join('\n'), + /must not link libmpv/, + `${artifactName} must remain process-isolated from libmpv` + ); + } const pathBearingAddonLinkErrors = validatePackagedEmbeddedMpv( fixture.resourceDir, @@ -1065,6 +1077,99 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure ); }); +test('recursively validates pristine Electron libraries before target packaging', (t) => { + const fixture = createNativeFixture(); + t.after(() => + fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true }) + ); + const manifest = preparePackagedFrameCopyArtifacts( + fixture.nativeDir, + 'linux', + { + profile: 'portable', + targetNames: ['snap'], + } + ); + const nestedElectronLibrary = join( + fixture.appOutDir, + 'future-electron-runtime', + 'libfuture-electron.so' + ); + fs.mkdirSync(dirname(nestedElectronLibrary), { recursive: true }); + fs.writeFileSync(nestedElectronLibrary, 'future electron library'); + + const validationOptions = { + platform: 'linux', + required: true, + foreignArch: false, + profile: 'portable', + targetNames: ['snap'], + hostPlatform: 'linux', + }; + const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...validationOptions, + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libfuture-electron.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + }); + + assert.match( + errors.join('\n'), + /Linux Electron library must not link libmpv.*libfuture-electron\.so/ + ); + + fs.rmSync(dirname(nestedElectronLibrary), { + recursive: true, + force: true, + }); + for (const packageLibraryDir of [ + join(fixture.appOutDir, 'lib', 'x86_64-linux-gnu'), + join(fixture.appOutDir, 'usr', 'lib', 'x86_64-linux-gnu'), + ]) { + const targetPath = join(packageLibraryDir, 'libpackage.so.1.0'); + fs.mkdirSync(packageLibraryDir, { recursive: true }); + fs.writeFileSync(targetPath, 'Snap template library'); + fs.symlinkSync( + 'libpackage.so.1.0', + join(packageLibraryDir, 'libpackage.so.1') + ); + } + + assert.deepEqual( + validatePackagedEmbeddedMpv(fixture.resourceDir, { + ...validationOptions, + artifactFormat: 'snap', + elfInspector: validElfInspector(fixture.nativeDir, manifest), + }), + [] + ); + + fs.mkdirSync(dirname(nestedElectronLibrary), { recursive: true }); + fs.writeFileSync(nestedElectronLibrary, 'future electron library'); + const snapIsolationErrors = validatePackagedEmbeddedMpv( + fixture.resourceDir, + { + ...validationOptions, + artifactFormat: 'snap', + elfInspector: validElfInspector(fixture.nativeDir, manifest, { + 'libfuture-electron.so': { + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }, + }), + } + ); + assert.match( + snapIsolationErrors.join('\n'), + /Linux Electron library must not link libmpv.*libfuture-electron\.so/ + ); +}); + test('rejects undeclared helper dependencies in system-runtime packages', (t) => { const fixture = createNativeFixture(); t.after(() => diff --git a/tools/packaging/embedded-mpv-packaging.cjs b/tools/packaging/embedded-mpv-packaging.cjs index 5320c20ca..3ae2fa784 100644 --- a/tools/packaging/embedded-mpv-packaging.cjs +++ b/tools/packaging/embedded-mpv-packaging.cjs @@ -1320,9 +1320,18 @@ function dependencyFileName(dependencyName) { return dependencyName.replaceAll('\\', '/').split('/').at(-1) ?? ''; } -function listElectronShippedLinuxLibraries(resourceDir) { +function listElectronShippedLinuxLibraries(resourceDir, options = {}) { const appDir = path.dirname(resourceDir); const normalizedResourceDir = path.resolve(resourceDir); + const excludedSnapLibraryRoots = + options.artifactFormat === 'snap' + ? new Set( + [ + path.join(appDir, 'lib'), + path.join(appDir, 'usr', 'lib'), + ].map((directoryPath) => path.resolve(directoryPath)) + ) + : new Set(); const libraries = []; function visit(directoryPath) { @@ -1334,6 +1343,9 @@ function listElectronShippedLinuxLibraries(resourceDir) { continue; } if (entry.isDirectory()) { + if (excludedSnapLibraryRoots.has(path.resolve(entryPath))) { + continue; + } visit(entryPath); continue; } @@ -1346,6 +1358,11 @@ function listElectronShippedLinuxLibraries(resourceDir) { } } + // afterPack and unpacked-layout checks see the pristine Electron tree, so + // recurse to catch future nested Electron libraries. An extracted Snap + // overlays package-manager lib/ and usr/lib/ trees onto that same root; + // exclude exactly those target-provided roots while scanning everything + // else recursively. visit(appDir); return libraries.sort(); } @@ -1385,7 +1402,8 @@ function inspectLinuxElfIsolation( helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name), }; for (const [index, libraryPath] of listElectronShippedLinuxLibraries( - resourceDir + resourceDir, + { artifactFormat: options.artifactFormat } ).entries()) { inspectedPaths[`electronLibrary:${index}`] = libraryPath; } @@ -1918,6 +1936,7 @@ module.exports = { commandExists, copyRuntimeToNativeBuild, findLibMpv, + listElectronShippedLinuxLibraries, listRuntimeFiles, listDylibs, parseOtoolDependencies, diff --git a/tools/packaging/verify-linux-frame-copy-runtime.mjs b/tools/packaging/verify-linux-frame-copy-runtime.mjs index 8f57aca82..cfe6856a6 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.mjs @@ -11,7 +11,10 @@ const require = createRequire(import.meta.url); const { parseReadelfDynamic, } = require('../embedded-mpv/build-linux-runtime.cjs'); -const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs'); +const { + listElectronShippedLinuxLibraries, + validatePackagedEmbeddedMpv, +} = require('./embedded-mpv-packaging.cjs'); const { LINUX_SYSTEM_PACKAGE_DEPENDENCIES, resolveLinuxFrameCopyProfile, @@ -970,42 +973,14 @@ function dependencyFileName(dependencyName) { return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? ''; } -function listElectronLibraries(resourceDir) { - const appDir = path.dirname(resourceDir); - const resolvedResourceDir = path.resolve(resourceDir); - const libraries = []; - - function visit(directoryPath) { - for (const entry of fs.readdirSync(directoryPath, { - withFileTypes: true, - })) { - const entryPath = path.join(directoryPath, entry.name); - if (path.resolve(entryPath) === resolvedResourceDir) { - continue; - } - if (entry.isDirectory()) { - visit(entryPath); - continue; - } - if ( - (entry.isFile() || entry.isSymbolicLink()) && - /\.so(?:\.\d+)*$/.test(entry.name) - ) { - libraries.push(entryPath); - } - } - } - - visit(appDir); - return libraries.sort(); -} - -function validateElectronIsolation(resourceDir, elfInspector) { +function validateElectronIsolation(resourceDir, artifactFormat, elfInspector) { const errors = []; const electronPath = path.join(path.dirname(resourceDir), 'iptvnator.bin'); const binaries = [ { label: 'Electron binary', binaryPath: electronPath }, - ...listElectronLibraries(resourceDir).map((binaryPath) => ({ + ...listElectronShippedLinuxLibraries(resourceDir, { + artifactFormat, + }).map((binaryPath) => ({ label: 'Electron library', binaryPath, })), @@ -1196,12 +1171,15 @@ export function verifyExtractedLinuxFrameCopyRuntime({ targetArch: packageArch, profile: profile.name, targetNames: profile.targets, + artifactFormat, hostPlatform: 'linux', executableName: 'iptvnator', elfInspector, }) ); - errors.push(...validateElectronIsolation(resourceDir, elfInspector)); + errors.push( + ...validateElectronIsolation(resourceDir, artifactFormat, elfInspector) + ); if (foreignArch || errors.length > 0) { return errors; } diff --git a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs index 7e9954b26..0844e948d 100644 --- a/tools/packaging/verify-linux-frame-copy-runtime.test.mjs +++ b/tools/packaging/verify-linux-frame-copy-runtime.test.mjs @@ -792,6 +792,143 @@ test('validates an x64 system payload and executes one bounded helper probe', () } }); +test('excludes only Snap template library roots from Electron isolation checks', () => { + const fixture = createSystemPayload({ architecture: 'arm64' }); + const packageLibraryDirs = [ + path.join(fixture.appDir, 'lib', 'x86_64-linux-gnu'), + path.join(fixture.appDir, 'usr', 'lib', 'x86_64-linux-gnu'), + ]; + const electronLibrary = path.join(fixture.appDir, 'libelectron-extra.so'); + for (const [index, packageLibraryDir] of packageLibraryDirs.entries()) { + const packageLibraryTarget = path.join( + packageLibraryDir, + `libpackage-${index}.so.0.12.2` + ); + fs.mkdirSync(packageLibraryDir, { recursive: true }); + fs.writeFileSync(packageLibraryTarget, 'package-managed library'); + fs.symlinkSync( + path.basename(packageLibraryTarget), + path.join(packageLibraryDir, `libpackage-${index}.so.0`) + ); + } + fs.writeFileSync(electronLibrary, 'electron library'); + + const inspectedPaths = []; + const isPackageLibraryPath = (binaryPath) => + packageLibraryDirs.some((packageLibraryDir) => + path + .resolve(binaryPath) + .startsWith(`${path.resolve(packageLibraryDir)}${path.sep}`) + ); + const ownershipScopedElfInspector = (binaryPath) => { + inspectedPaths.push(binaryPath); + if (isPackageLibraryPath(binaryPath)) { + throw new Error( + 'package-manager library tree crossed the Electron ownership boundary' + ); + } + return validElfInspector(binaryPath); + }; + + try { + assert.deepEqual( + verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector: ownershipScopedElfInspector, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }), + [] + ); + assert.ok(inspectedPaths.includes(electronLibrary)); + assert.equal(inspectedPaths.some(isPackageLibraryPath), false); + + const isolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (binaryPath === electronLibrary) { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return ownershipScopedElfInspector(binaryPath); + }, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + isolationErrors.join('\n'), + /Electron library must not link libmpv.*libelectron-extra\.so/ + ); + + const nestedElectronLibrary = path.join( + fixture.appDir, + 'future-electron-runtime', + 'libfuture-electron.so' + ); + fs.mkdirSync(path.dirname(nestedElectronLibrary), { + recursive: true, + }); + fs.writeFileSync(nestedElectronLibrary, 'nested electron library'); + const nestedIsolationErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector(binaryPath) { + if (binaryPath === nestedElectronLibrary) { + return { + soname: null, + needed: ['libmpv.so.2'], + rpath: [], + runpath: [], + }; + } + return ownershipScopedElfInspector(binaryPath); + }, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + nestedIsolationErrors.join('\n'), + /Electron library must not link libmpv.*libfuture-electron\.so/ + ); + + fs.symlinkSync( + path.basename(electronLibrary), + path.join(fixture.appDir, 'libEGL.so') + ); + const symlinkErrors = verifyExtractedLinuxFrameCopyRuntime({ + resourceDir: fixture.resourceDir, + artifactFormat: 'snap', + profileName: 'portable', + packageDependencies: [], + elfInspector: ownershipScopedElfInspector, + probeRunner() { + assert.fail('foreign-architecture Snap must not probe'); + }, + }); + assert.match( + symlinkErrors.join('\n'), + /Electron library must be a regular file: .*libEGL\.so/ + ); + } finally { + fs.rmSync(fixture.root, { recursive: true, force: true }); + } +}); + test('rejects helper probes terminated by a signal or hard timeout', () => { for (const probeResult of [ {