diff --git a/apps/electron-backend/build-embedded-mpv.js b/apps/electron-backend/build-embedded-mpv.js index 69e4768bf..8fe6443df 100644 --- a/apps/electron-backend/build-embedded-mpv.js +++ b/apps/electron-backend/build-embedded-mpv.js @@ -16,11 +16,15 @@ const { validateLinuxRuntimeManifest, validateLinuxSystemBuildInputManifest, } = require('../../tools/embedded-mpv/linux-runtime-manifest.cjs'); +const { + resolveVerifiedLinuxLibMpvSoname, + runWithCleanup, + validateLinuxFrameCopyLinkage, +} = require('./embedded-mpv-linux-linkage.cjs'); const LINUX_PACKAGE_RUNTIME_MODES = Object.freeze(['system', 'bundled']); const LINUX_STAGED_RUNTIME_ORIGIN = 'vendored-lgpl'; const LINUX_SOURCE_RUNTIME_ORIGIN = 'vendored-lgpl-source-build'; -const VERSIONED_LINUX_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; const workspaceRoot = process.cwd(); const addonRoot = path.join( @@ -452,35 +456,6 @@ function assertRequiredLinuxFrameCopyRuntime(runtime) { } } -function deriveLinuxLibMpvSoname(runtime) { - if ( - runtime.buildInputMode !== 'bundled-runtime' || - runtime.sourceRuntimeValidated !== true || - !Array.isArray(runtime.sourceRuntimeManifest?.runtimeFiles) - ) { - return null; - } - - const candidates = runtime.sourceRuntimeManifest.runtimeFiles - .map((runtimeFile) => runtimeFile.name) - .filter((fileName) => VERSIONED_LINUX_LIBMPV_PATTERN.test(fileName)) - .sort((left, right) => { - const leftVersionParts = left - .slice('libmpv.so.'.length) - .split('.').length; - const rightVersionParts = right - .slice('libmpv.so.'.length) - .split('.').length; - return ( - leftVersionParts - rightVersionParts || - left.length - right.length || - left.localeCompare(right) - ); - }); - - return candidates[0] ?? null; -} - function copyLinuxRuntimeClosureToNativeBuild(runtime) { fs.rmSync(outputLibDir, { recursive: true, force: true }); @@ -540,7 +515,18 @@ function copyLinuxRuntimeClosureToNativeBuild(runtime) { function writeLinuxFrameCopyBuildManifest(runtime) { const copiedRuntimeFiles = copyLinuxRuntimeClosureToNativeBuild(runtime); - const libmpvSoname = deriveLinuxLibMpvSoname(runtime); + const libmpvSoname = + runtime.sourceRuntimeValidated === true && + runtime.buildInputMode === 'bundled-runtime' && + copiedRuntimeFiles.length > 0 + ? resolveVerifiedLinuxLibMpvSoname({ + outputLibDir, + runtimeFiles: copiedRuntimeFiles, + runtimeDependencyClosure: + runtime.sourceRuntimeManifest.runtimeDependencyClosure, + readDynamicSection: readLinuxDynamicSection, + }) + : null; const packageRuntimeAvailable = runtime.sourceRuntimeValidated === true && runtime.buildInputMode === 'bundled-runtime' && @@ -704,12 +690,33 @@ function runNodeGyp(command, env) { } } +function readLinuxDynamicSection(filePath) { + const result = spawnSync('readelf', ['-d', filePath], { + cwd: workspaceRoot, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error) { + throw new Error( + `Unable to run readelf for ${filePath}: ${result.error.message}` + ); + } + if (result.status !== 0) { + throw new Error( + `readelf -d failed for ${filePath} with status ${ + result.status ?? 1 + }: ${(result.stderr ?? '').trim()}` + ); + } + return result.stdout; +} + function main() { fs.mkdirSync(outputDir, { recursive: true }); cleanDistNativeOutput(); + cleanOutput(); if (targetPlatform !== process.platform) { - cleanOutput(); if (embeddedMpvRequired) { throw new Error( `Embedded MPV is required for ${targetPlatform}-${targetArch}, but this host is ${process.platform}-${process.arch}.` @@ -743,57 +750,101 @@ function main() { assertRequiredLinuxFrameCopyRuntime(runtime); - const runtimeManifest = - targetPlatform === 'darwin' - ? copyRuntimeToNativeBuild({ - runtimeLibDir: runtime.libDir, - outputLibDir, - runtimeOrigin: runtime.origin, - runtimeManifest: { - targetArch, - ...runtime.manifest, - }, - }) - : targetPlatform === 'linux' - ? writeLinuxFrameCopyBuildManifest(runtime) - : copyGenericRuntimeToNativeBuild(runtime); - fs.rmSync(unavailableMarkerFile, { force: true }); - - const electronPackageJson = require( - path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') - ); - const electronVersion = electronPackageJson.version; - const env = { - ...process.env, - npm_config_runtime: 'electron', - npm_config_target: electronVersion, - npm_config_arch: targetArch, - npm_config_disturl: 'https://electronjs.org/headers', - npm_config_build_from_source: 'true', - npm_config_update_binary: 'false', - LIBMPV_INCLUDE_DIR: runtime.includeDir, - ...(targetPlatform === 'linux' - ? { - LINUX_NATIVE_LIBRARY_DIR: runtime.libDir, - } - : { LIBMPV_LIBRARY_DIR: outputLibDir }), - ...(runtime.windowsImportLib - ? { - LIBMPV_IMPORT_LIB: path.join( + const buildNativeArtifacts = () => { + const runtimeManifest = + targetPlatform === 'darwin' + ? copyRuntimeToNativeBuild({ + runtimeLibDir: runtime.libDir, outputLibDir, - path.basename(runtime.windowsImportLib) - ), - } - : {}), - }; + runtimeOrigin: runtime.origin, + runtimeManifest: { + targetArch, + ...runtime.manifest, + }, + }) + : targetPlatform === 'linux' + ? writeLinuxFrameCopyBuildManifest(runtime) + : copyGenericRuntimeToNativeBuild(runtime); - log( - `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` - ); - cleanNativeBuildIntermediates(); - try { + const electronPackageJson = require( + path.join(workspaceRoot, 'node_modules', 'electron', 'package.json') + ); + const electronVersion = electronPackageJson.version; + const env = { + ...process.env, + npm_config_runtime: 'electron', + npm_config_target: electronVersion, + npm_config_arch: targetArch, + npm_config_disturl: 'https://electronjs.org/headers', + npm_config_build_from_source: 'true', + npm_config_update_binary: 'false', + LIBMPV_INCLUDE_DIR: runtime.includeDir, + ...(targetPlatform === 'linux' + ? { + LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: outputLibDir, + } + : { LIBMPV_LIBRARY_DIR: outputLibDir }), + ...(runtime.windowsImportLib + ? { + LIBMPV_IMPORT_LIB: path.join( + outputLibDir, + path.basename(runtime.windowsImportLib) + ), + } + : {}), + }; + + log( + `Building native addon against Electron ${electronVersion} using ${runtime.origin} runtime for ${targetPlatform}-${targetArch}...` + ); + cleanNativeBuildIntermediates(); runNodeGyp('configure', env); runNodeGyp('build', env); + + if (!fs.existsSync(outputFile)) { + throw new Error(`Build finished without producing ${outputFile}.`); + } + + if (targetPlatform === 'linux') { + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: runtimeManifest.libmpvSoname, + outputDir, + readDynamicSection: readLinuxDynamicSection, + }); + } + + if (targetPlatform === 'darwin') { + patchAddonForBundledRuntime(outputFile, outputLibDir); + // The frame-copy helper executable links libmpv too and sits next + // to the same lib/ directory, so it gets the identical + // dependency-path rewrite + ad-hoc re-sign. + const frameHelperFile = path.join( + outputDir, + 'iptvnator_mpv_helper' + ); + if (fs.existsSync(frameHelperFile)) { + patchAddonForBundledRuntime(frameHelperFile, outputLibDir); + } + const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ + outputFile, + ...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []), + ...runtimeManifest.dylibs.map((dylib) => + path.join(outputLibDir, dylib) + ), + ]); + if ( + runtime.origin === 'vendored-lgpl' && + forbiddenLinkErrors.length > 0 + ) { + throw new Error(forbiddenLinkErrors.join('\n')); + } + } + + fs.rmSync(unavailableMarkerFile, { force: true }); + }; + + try { + runWithCleanup(buildNativeArtifacts, cleanOutput); } catch (error) { if (!embeddedMpvRequired && runtime.origin === 'system-dev') { // The system-dev fallback triggers on any machine with @@ -805,40 +856,11 @@ function main() { error instanceof Error ? error.message : String(error) }` ); - cleanOutput(); return; } throw error; } - if (!fs.existsSync(outputFile)) { - throw new Error(`Build finished without producing ${outputFile}.`); - } - - if (targetPlatform === 'darwin') { - patchAddonForBundledRuntime(outputFile, outputLibDir); - // The frame-copy helper executable links libmpv too and sits next to - // the same lib/ directory, so it gets the identical dependency-path - // rewrite + ad-hoc re-sign. - const frameHelperFile = path.join(outputDir, 'iptvnator_mpv_helper'); - if (fs.existsSync(frameHelperFile)) { - patchAddonForBundledRuntime(frameHelperFile, outputLibDir); - } - const forbiddenLinkErrors = validateNoForbiddenRuntimeLinks([ - outputFile, - ...(fs.existsSync(frameHelperFile) ? [frameHelperFile] : []), - ...runtimeManifest.dylibs.map((dylib) => - path.join(outputLibDir, dylib) - ), - ]); - if ( - runtime.origin === 'vendored-lgpl' && - forbiddenLinkErrors.length > 0 - ) { - throw new Error(forbiddenLinkErrors.join('\n')); - } - } - log(`Built ${path.relative(workspaceRoot, outputFile)}.`); } diff --git a/apps/electron-backend/embedded-mpv-linux-linkage.cjs b/apps/electron-backend/embedded-mpv-linux-linkage.cjs new file mode 100644 index 000000000..adc1cd806 --- /dev/null +++ b/apps/electron-backend/embedded-mpv-linux-linkage.cjs @@ -0,0 +1,294 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; +const LIBMPV_NEEDED_PATTERN = /^libmpv\.so(?:\..*)?$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; + +const LINUX_FRAME_COPY_ARTIFACTS = Object.freeze([ + Object.freeze({ + fileName: 'embedded_mpv.node', + label: 'embedded MPV addon', + mayLinkLibmpv: false, + }), + Object.freeze({ + fileName: 'embedded_mpv_frame_reader.node', + label: 'embedded MPV frame reader', + mayLinkLibmpv: false, + }), + Object.freeze({ + fileName: 'iptvnator_mpv_helper', + label: 'embedded MPV frame-copy helper', + mayLinkLibmpv: true, + }), +]); + +function parseReadelfDynamic(output) { + if (typeof output !== 'string') { + throw new TypeError('readelf dynamic output must be a string.'); + } + + const dynamic = { + needed: [], + rpath: [], + runpath: [], + soname: [], + }; + const dynamicEntryPattern = + /\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g; + for (const [, tag, value] of output.matchAll(dynamicEntryPattern)) { + if (tag === 'NEEDED') { + dynamic.needed.push(value); + continue; + } + if (tag === 'SONAME') { + dynamic.soname.push(value); + continue; + } + dynamic[tag.toLowerCase()].push( + ...value.split(':').filter((entry) => entry.length > 0) + ); + } + + return dynamic; +} + +function exactlyOneRuntimeFile(runtimeFiles, name) { + if (!Array.isArray(runtimeFiles)) { + throw new Error('Linux runtimeFiles metadata must be an array.'); + } + const matchingRecords = runtimeFiles.filter( + (runtimeFile) => runtimeFile?.name === name + ); + if (matchingRecords.length !== 1) { + throw new Error( + `Linux runtime must contain exactly one exact runtimeFiles record for ${name}.` + ); + } + + const [runtimeFile] = matchingRecords; + if ( + !Number.isInteger(runtimeFile.size) || + runtimeFile.size <= 0 || + typeof runtimeFile.sha256 !== 'string' || + !SHA256_PATTERN.test(runtimeFile.sha256) + ) { + throw new Error( + `Linux runtimeFiles record for ${name} has invalid size or SHA-256 metadata.` + ); + } + return runtimeFile; +} + +function readVerifiedRuntimeFile(filePath, runtimeFile) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing copied Linux runtime file: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Copied Linux runtime file must be a regular non-symbolic-link file: ${filePath}` + ); + } + + let descriptor; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) + ); + const descriptorStat = fs.fstatSync(descriptor); + if (!descriptorStat.isFile()) { + throw new Error( + `Copied Linux runtime path is not a regular file: ${filePath}` + ); + } + const contents = fs.readFileSync(descriptor); + if (contents.byteLength !== runtimeFile.size) { + throw new Error( + `Size mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.size}, received ${contents.byteLength}.` + ); + } + const actualSha256 = crypto + .createHash('sha256') + .update(contents) + .digest('hex'); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for copied Linux runtime file ${runtimeFile.name}: expected ${runtimeFile.sha256}, received ${actualSha256}.` + ); + } + } finally { + if (descriptor !== undefined) { + fs.closeSync(descriptor); + } + } +} + +function closureLibMpvSoname(runtimeDependencyClosure) { + if (!Array.isArray(runtimeDependencyClosure?.entries)) { + throw new Error( + 'Validated Linux runtime dependency closure entries are required.' + ); + } + + const libMpvEntries = runtimeDependencyClosure.entries.filter( + (entry) => + entry?.name === 'libmpv.so' || + VERSIONED_LIBMPV_PATTERN.test(entry?.name) + ); + const declaredSonames = libMpvEntries.map((entry) => entry.soname); + const uniqueSonames = new Set(declaredSonames); + if ( + declaredSonames.length === 0 || + declaredSonames.some( + (soname) => + typeof soname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(soname) + ) || + uniqueSonames.size !== 1 + ) { + throw new Error( + 'Validated Linux runtime closure must declare exactly one versioned libmpv SONAME.' + ); + } + + return declaredSonames[0]; +} + +function resolveVerifiedLinuxLibMpvSoname({ + outputLibDir, + runtimeFiles, + runtimeDependencyClosure, + readDynamicSection, +}) { + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + const expectedSoname = closureLibMpvSoname(runtimeDependencyClosure); + const linkerInputRecord = exactlyOneRuntimeFile(runtimeFiles, 'libmpv.so'); + const exactSonameRecord = exactlyOneRuntimeFile( + runtimeFiles, + expectedSoname + ); + const linkerInputPath = path.join(outputLibDir, 'libmpv.so'); + const exactSonamePath = path.join(outputLibDir, expectedSoname); + + readVerifiedRuntimeFile(linkerInputPath, linkerInputRecord); + readVerifiedRuntimeFile(exactSonamePath, exactSonameRecord); + + const dynamic = parseReadelfDynamic(readDynamicSection(linkerInputPath)); + if ( + dynamic.soname.length !== 1 || + !VERSIONED_LIBMPV_PATTERN.test(dynamic.soname[0]) + ) { + throw new Error( + 'Copied Linux libmpv.so must contain exactly one DT_SONAME with a versioned libmpv basename.' + ); + } + if (dynamic.soname[0] !== expectedSoname) { + throw new Error( + `Copied Linux libmpv.so DT_SONAME ${dynamic.soname[0]} does not match validated closure SONAME ${expectedSoname}.` + ); + } + + return expectedSoname; +} + +function assertRegularArtifact(filePath, label) { + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + throw new Error(`Missing ${label}: ${filePath}`); + } + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `${label} must be a regular non-symbolic-link file: ${filePath}` + ); + } +} + +function validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname, + outputDir, + readDynamicSection, +}) { + if ( + typeof expectedLibmpvSoname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(expectedLibmpvSoname) + ) { + throw new Error( + 'Linux frame-copy linkage validation requires an exact versioned libmpv SONAME.' + ); + } + if (typeof readDynamicSection !== 'function') { + throw new TypeError('Linux readelf dynamic reader is required.'); + } + + for (const artifact of LINUX_FRAME_COPY_ARTIFACTS) { + const artifactPath = path.join(outputDir, artifact.fileName); + assertRegularArtifact(artifactPath, artifact.label); + const dynamic = parseReadelfDynamic(readDynamicSection(artifactPath)); + const libMpvDependencies = dynamic.needed.filter((dependency) => + LIBMPV_NEEDED_PATTERN.test(dependency) + ); + + if (!artifact.mayLinkLibmpv) { + if (libMpvDependencies.length > 0) { + throw new Error( + `${artifact.label} must not have a direct libmpv DT_NEEDED entry; found ${libMpvDependencies.join(', ')}.` + ); + } + continue; + } + + if ( + libMpvDependencies.length !== 1 || + libMpvDependencies[0] !== expectedLibmpvSoname + ) { + throw new Error( + `${artifact.label} DT_NEEDED must contain exactly ${expectedLibmpvSoname}; found ${ + libMpvDependencies.join(', ') || '' + }.` + ); + } + if (dynamic.rpath.length !== 0) { + throw new Error( + `${artifact.label} must not contain RPATH; found ${dynamic.rpath.join(':')}.` + ); + } + if ( + dynamic.runpath.length !== 1 || + dynamic.runpath[0] !== '$ORIGIN/lib' + ) { + throw new Error( + `${artifact.label} RUNPATH must be exactly $ORIGIN/lib; found ${ + dynamic.runpath.join(':') || '' + }.` + ); + } + } +} + +function runWithCleanup(operation, cleanup) { + try { + return operation(); + } catch (error) { + cleanup(); + throw error; + } +} + +module.exports = { + parseReadelfDynamic, + resolveVerifiedLinuxLibMpvSoname, + runWithCleanup, + validateLinuxFrameCopyLinkage, +}; diff --git a/apps/electron-backend/native/binding.gyp b/apps/electron-backend/native/binding.gyp index b6f83bc7e..488b3fddd 100644 --- a/apps/electron-backend/native/binding.gyp +++ b/apps/electron-backend/native/binding.gyp @@ -162,8 +162,8 @@ "-Wl,-rpath,'$$ORIGIN/lib'" ], "libraries": [ - "-L; + }; + runtimeFiles: RuntimeFileRecord[]; +} + +function loadLinkageModule(): { + parseReadelfDynamic: (output: string) => { + needed: string[]; + rpath: string[]; + runpath: string[]; + soname: string[]; + }; + resolveVerifiedLinuxLibMpvSoname: (options: { + outputLibDir: string; + readDynamicSection: (filePath: string) => string; + runtimeDependencyClosure: SonameFixture['runtimeDependencyClosure']; + runtimeFiles: RuntimeFileRecord[]; + }) => string; + runWithCleanup: (operation: () => T, cleanup: () => void) => T; + validateLinuxFrameCopyLinkage: (options: { + expectedLibmpvSoname: string; + outputDir: string; + readDynamicSection: (filePath: string) => string; + }) => void; +} { + expect(existsSync(linkageModulePath)).toBe(true); + return requireBuildHelper(linkageModulePath); +} + +function sha256(contents: Buffer): string { + return createHash('sha256').update(contents).digest('hex'); +} + +function runtimeFile(name: string, contents: Buffer): RuntimeFileRecord { + return { + name, + size: contents.byteLength, + sha256: sha256(contents), + }; +} + +function readelfDynamic( + entries: Array<['NEEDED' | 'RPATH' | 'RUNPATH' | 'SONAME', string]> +): string { + return entries + .map( + ([tag, value], index) => + ` 0x${index + .toString(16) + .padStart(16, '0')} (${tag}) Library value: [${value}]` + ) + .join('\n'); +} + +describe('Linux Embedded MPV linkage verification', () => { + const temporaryDirectories: string[] = []; + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } + }); + + function temporaryDirectory(): string { + const directory = mkdtempSync( + path.join(tmpdir(), 'iptvnator-mpv-linkage-') + ); + temporaryDirectories.push(directory); + return directory; + } + + function createSonameFixture(soname = 'libmpv.so.2'): SonameFixture { + const outputLibDir = path.join(temporaryDirectory(), 'lib'); + mkdirSync(outputLibDir, { recursive: true }); + const contents = Buffer.from('verified libmpv ELF contents'); + const aliasPath = path.join(outputLibDir, 'libmpv.so'); + const exactPath = path.join(outputLibDir, soname); + writeFileSync(aliasPath, contents); + writeFileSync(exactPath, contents); + + return { + exactPath, + outputLibDir, + runtimeFiles: [ + runtimeFile('libmpv.so', contents), + runtimeFile(soname, contents), + ], + runtimeDependencyClosure: { + entries: [ + { + name: 'libmpv.so', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + { + name: soname, + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname, + }, + ], + }, + }; + } + + it('parses every dynamic tag without hiding duplicate SONAME entries', () => { + const { parseReadelfDynamic } = loadLinkageModule(); + + expect( + parseReadelfDynamic( + readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/forbidden'], + ['RUNPATH', '$ORIGIN/lib'], + ['SONAME', 'libmpv.so.2'], + ['SONAME', 'libmpv.so.3'], + ]) + ) + ).toEqual({ + needed: ['libmpv.so.2'], + rpath: ['/forbidden'], + runpath: ['$ORIGIN/lib'], + soname: ['libmpv.so.2', 'libmpv.so.3'], + }); + }); + + it('resolves the exact libmpv SONAME from closure metadata and verified copied files', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + + expect( + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toBe('libmpv.so.2'); + }); + + it('rejects missing and ambiguous closure SONAME metadata', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const missingFixture = createSonameFixture(); + for (const entry of missingFixture.runtimeDependencyClosure.entries) { + entry.soname = null; + } + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...missingFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exactly one versioned libmpv SONAME/i); + + const ambiguousFixture = createSonameFixture(); + ambiguousFixture.runtimeDependencyClosure.entries.push({ + name: 'libmpv.so.3', + needed: [], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libmpv.so.3', + }); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...ambiguousFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exactly one versioned libmpv SONAME/i); + }); + + it('rejects missing, ambiguous, and mismatched DT_SONAME values', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => readelfDynamic([]), + }) + ).toThrow(/exactly one DT_SONAME/i); + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([ + ['SONAME', 'libmpv.so.2'], + ['SONAME', 'libmpv.so.3'], + ]), + }) + ).toThrow(/exactly one DT_SONAME/i); + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.3']]), + }) + ).toThrow(/does not match validated closure SONAME/i); + }); + + (process.platform === 'win32' ? it.skip : it)( + 'rejects a symlinked copied linker input', + () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const fixture = createSonameFixture(); + const aliasPath = path.join(fixture.outputLibDir, 'libmpv.so'); + unlinkSync(aliasPath); + symlinkSync(path.basename(fixture.exactPath), aliasPath); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...fixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/must be a regular non-symbolic-link file/i); + } + ); + + it('rejects a missing exact runtime record and a mismatched exact file hash', () => { + const { resolveVerifiedLinuxLibMpvSoname } = loadLinkageModule(); + const missingRecordFixture = createSonameFixture(); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...missingRecordFixture, + runtimeFiles: missingRecordFixture.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so.2' + ), + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/exact runtimeFiles record/i); + + const mismatchedFileFixture = createSonameFixture(); + writeFileSync( + mismatchedFileFixture.exactPath, + Buffer.from('tampered exact SONAME file') + ); + + expect(() => + resolveVerifiedLinuxLibMpvSoname({ + ...mismatchedFileFixture, + readDynamicSection: () => + readelfDynamic([['SONAME', 'libmpv.so.2']]), + }) + ).toThrow(/size|SHA-256/i); + }); + + function createArtifactFixture(): { + outputDir: string; + readDynamicSection: (filePath: string) => string; + outputs: Record; + } { + const outputDir = temporaryDirectory(); + const outputs: Record = { + 'embedded_mpv.node': readelfDynamic([['NEEDED', 'libX11.so.6']]), + 'embedded_mpv_frame_reader.node': readelfDynamic([]), + iptvnator_mpv_helper: readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['NEEDED', 'libEGL.so.1'], + ['RUNPATH', '$ORIGIN/lib'], + ]), + }; + for (const artifact of Object.keys(outputs)) { + writeFileSync(path.join(outputDir, artifact), 'ELF'); + } + return { + outputDir, + outputs, + readDynamicSection: (filePath: string) => + outputs[path.basename(filePath)], + }; + } + + it('accepts only process-isolated Linux frame-copy linkage', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).not.toThrow(); + }); + + it('rejects a helper linked to the wrong libmpv SONAME', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + + fixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.3'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(/helper.*DT_NEEDED must contain exactly libmpv\.so\.2/i); + }); + + it('rejects helper RPATH and any RUNPATH other than $ORIGIN/lib', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const rpathFixture = createArtifactFixture(); + rpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RPATH', '/host/lib'], + ['RUNPATH', '$ORIGIN/lib'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: rpathFixture.outputDir, + readDynamicSection: rpathFixture.readDynamicSection, + }) + ).toThrow(/helper must not contain RPATH/i); + + const runpathFixture = createArtifactFixture(); + runpathFixture.outputs.iptvnator_mpv_helper = readelfDynamic([ + ['NEEDED', 'libmpv.so.2'], + ['RUNPATH', '$ORIGIN'], + ]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: runpathFixture.outputDir, + readDynamicSection: runpathFixture.readDynamicSection, + }) + ).toThrow(/helper RUNPATH must be exactly \$ORIGIN\/lib/i); + }); + + it.each([ + ['embedded_mpv.node', 'addon'], + ['embedded_mpv_frame_reader.node', 'frame reader'], + ])('rejects Electron-side libmpv linkage from %s', (fileName, label) => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const fixture = createArtifactFixture(); + fixture.outputs[fileName] = readelfDynamic([['NEEDED', 'libmpv.so.2']]); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: fixture.outputDir, + readDynamicSection: fixture.readDynamicSection, + }) + ).toThrow(new RegExp(`${label} must not have a direct libmpv`, 'i')); + }); + + it('rejects missing artifacts and readelf failures', () => { + const { validateLinuxFrameCopyLinkage } = loadLinkageModule(); + const missingArtifactFixture = createArtifactFixture(); + unlinkSync( + path.join( + missingArtifactFixture.outputDir, + 'embedded_mpv_frame_reader.node' + ) + ); + + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: missingArtifactFixture.outputDir, + readDynamicSection: missingArtifactFixture.readDynamicSection, + }) + ).toThrow(/missing.*frame reader/i); + + const readelfFailureFixture = createArtifactFixture(); + expect(() => + validateLinuxFrameCopyLinkage({ + expectedLibmpvSoname: 'libmpv.so.2', + outputDir: readelfFailureFixture.outputDir, + readDynamicSection: () => { + throw new Error('readelf is unavailable'); + }, + }) + ).toThrow(/readelf is unavailable/); + }); + + it('runs cleanup before rethrowing the original transaction failure', () => { + const { runWithCleanup } = loadLinkageModule(); + const calls: string[] = []; + const failure = new Error('post-link validation failed'); + + expect(() => + runWithCleanup( + () => { + calls.push('operation'); + throw failure; + }, + () => calls.push('cleanup') + ) + ).toThrow(failure); + expect(calls).toEqual(['operation', 'cleanup']); + }); +}); diff --git a/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts b/apps/electron-backend/src/app/services/embedded-mpv-native-source.spec.ts index f7fba8d8a..857d3cafd 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 @@ -590,11 +590,14 @@ describe('Embedded MPV native source recording invariants', () => { 'SHA-256 mismatch for staged Linux runtime file' ); expect(buildScriptSource).toContain( - 'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir' + 'LINUX_VERIFIED_RUNTIME_LIBRARY_DIR: outputLibDir' ); expect(buildScriptSource).not.toContain( 'process.env.LINUX_NATIVE_LIBRARY_DIR || runtime.libDir' ); + expect(buildScriptSource).not.toContain( + 'LINUX_NATIVE_LIBRARY_DIR: runtime.libDir' + ); }); it('writes a profile-neutral Linux frame-copy build manifest', () => { @@ -636,15 +639,15 @@ describe('Embedded MPV native source recording invariants', () => { ); }); - it('derives packaged Linux libmpv identity from the validated runtime', () => { + it('derives packaged Linux libmpv identity from validated closure SONAME metadata', () => { + expect(buildScriptSource).toContain('resolveVerifiedLinuxLibMpvSoname'); expect(buildScriptSource).toContain( - 'function deriveLinuxLibMpvSoname(runtime)' + 'runtime.sourceRuntimeManifest.runtimeDependencyClosure' ); - expect(buildScriptSource).toContain( - 'runtime.sourceRuntimeManifest.runtimeFiles' - ); - expect(buildScriptSource).toContain('VERSIONED_LINUX_LIBMPV_PATTERN'); expect(buildScriptSource).toContain('libmpvSoname,'); + expect(buildScriptSource).not.toContain( + 'VERSIONED_LINUX_LIBMPV_PATTERN' + ); expect(buildScriptSource).not.toContain("libmpvSoname: 'libmpv.so.2'"); }); @@ -659,6 +662,24 @@ describe('Embedded MPV native source recording invariants', () => { expect(buildScriptSource).toContain('bundled: packageRuntimeAvailable'); }); + it('validates Linux post-link isolation before marking the build available', () => { + const postLinkValidation = buildScriptSource.indexOf( + 'validateLinuxFrameCopyLinkage({' + ); + const availabilityMarkerRemoval = buildScriptSource.lastIndexOf( + 'fs.rmSync(unavailableMarkerFile' + ); + + expect(buildScriptSource).toContain( + "spawnSync('readelf', ['-d', filePath]" + ); + expect(postLinkValidation).toBeGreaterThanOrEqual(0); + expect(availabilityMarkerRemoval).toBeGreaterThan(postLinkValidation); + expect(buildScriptSource).toContain( + 'runWithCleanup(buildNativeArtifacts, cleanOutput)' + ); + }); + it('uses platform-specific embedded MPV runtime cache key inputs in CI', () => { expect(buildAndMakeWorkflowSource).toContain( "const targetPlatform = '${{ matrix.embedded_mpv_platform }}';" @@ -907,7 +928,13 @@ describe('Embedded MPV native build configuration', () => { linuxHelperConfig?.libraries.find((library: string) => library.includes("path.join(dir, 'libmpv.so')") ) - ).toContain('LINUX_NATIVE_LIBRARY_DIR'); + ).toContain('LINUX_VERIFIED_RUNTIME_LIBRARY_DIR'); + expect(JSON.stringify(linuxHelperConfig)).not.toContain( + "LINUX_VERIFIED_RUNTIME_LIBRARY_DIR || '/usr/lib'" + ); + expect(JSON.stringify(linuxHelperConfig)).toContain( + 'Missing LINUX_VERIFIED_RUNTIME_LIBRARY_DIR' + ); const runtimeLinkerFlags = linuxHelperConfig?.ldflags.filter( (flag: string) => flag.startsWith('-Wl,') diff --git a/apps/electron-backend/tsconfig.spec.json b/apps/electron-backend/tsconfig.spec.json index 976a7011d..d6ec5f648 100644 --- a/apps/electron-backend/tsconfig.spec.json +++ b/apps/electron-backend/tsconfig.spec.json @@ -3,6 +3,7 @@ "compilerOptions": { "outDir": "../../dist/out-tsc", "esModuleInterop": true, + "allowJs": true, "module": "commonjs", "moduleResolution": "node10", "types": ["jest", "node"]