diff --git a/tools/embedded-mpv/linux-runtime-manifest.cjs b/tools/embedded-mpv/linux-runtime-manifest.cjs new file mode 100644 index 000000000..44218fd13 --- /dev/null +++ b/tools/embedded-mpv/linux-runtime-manifest.cjs @@ -0,0 +1,282 @@ +'use strict'; + +const path = require('node:path'); + +const LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION = 1; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/; +const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/; +const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; +const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/; + +function isObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim().length > 0; +} + +function isSafeBasename(value) { + return ( + isNonEmptyString(value) && + path.basename(value) === value && + !value.includes('/') && + !value.includes('\\') && + value !== '.' && + value !== '..' && + SAFE_BASENAME_PATTERN.test(value) + ); +} + +function validateSourceMetadata(errors, value, label) { + if (!isNonEmptyString(value.version)) { + errors.push( + `Linux runtime manifest ${label}.version must be a non-empty string.` + ); + } + if (!isNonEmptyString(value.sourceUrl)) { + errors.push( + `Linux runtime manifest ${label}.sourceUrl must be a non-empty string.` + ); + } + + const hasSourceSha256 = value.sourceSha256 !== undefined; + const hasSourceGitCommit = value.sourceGitCommit !== undefined; + if (!hasSourceSha256 && !hasSourceGitCommit) { + errors.push( + `Linux runtime manifest ${label} must include sourceSha256 or sourceGitCommit.` + ); + return; + } + if ( + hasSourceSha256 && + (typeof value.sourceSha256 !== 'string' || + !SHA256_PATTERN.test(value.sourceSha256)) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceSha256 must be a lowercase 64-character hexadecimal digest.` + ); + } + if ( + hasSourceGitCommit && + (typeof value.sourceGitCommit !== 'string' || + !GIT_COMMIT_PATTERN.test(value.sourceGitCommit)) + ) { + errors.push( + `Linux runtime manifest ${label}.sourceGitCommit must be a lowercase hexadecimal commit digest.` + ); + } +} + +function validatePackages(errors, packages) { + if (!isObject(packages) || Object.keys(packages).length === 0) { + errors.push( + 'Linux runtime manifest packages must contain source package metadata.' + ); + return; + } + + for (const packageName of Object.keys(packages).sort()) { + const packageMetadata = packages[packageName]; + const label = `packages.${packageName}`; + if (!isObject(packageMetadata)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + + validateSourceMetadata(errors, packageMetadata, label); + if (!isNonEmptyString(packageMetadata.license)) { + errors.push( + `Linux runtime manifest ${label}.license must be a non-empty string.` + ); + } + } +} + +function validateFlags(errors, value, label, options) { + if ( + !Array.isArray(value) || + value.some((flag) => typeof flag !== 'string') + ) { + errors.push( + `Linux runtime manifest ${label} must be an array of strings.` + ); + return; + } + + const addForbiddenErrors = () => { + for (const forbiddenFlag of options.forbidden) { + if (value.includes(forbiddenFlag)) { + errors.push( + `Linux runtime manifest ${label} must not include "${forbiddenFlag}".` + ); + } + } + }; + const addRequiredErrors = () => { + for (const requiredFlag of options.required) { + if (!value.includes(requiredFlag)) { + errors.push( + `Linux runtime manifest ${label} must include "${requiredFlag}".` + ); + } + } + }; + + if (options.requiredFirst) { + addRequiredErrors(); + addForbiddenErrors(); + } else { + addForbiddenErrors(); + addRequiredErrors(); + } +} + +function validateFfmpeg(errors, ffmpeg) { + if (!isObject(ffmpeg)) { + errors.push('Linux runtime manifest ffmpeg must be an object.'); + return; + } + + validateFlags(errors, ffmpeg.configureFlags, 'ffmpeg.configureFlags', { + forbidden: ['--enable-gpl', '--enable-nonfree'], + required: ['--disable-gpl', '--disable-nonfree'], + }); +} + +function validateMpv(errors, mpv) { + if (!isObject(mpv)) { + errors.push('Linux runtime manifest mpv must be an object.'); + return; + } + + validateFlags(errors, mpv.mesonFlags, 'mpv.mesonFlags', { + forbidden: ['-Dgpl=true'], + required: ['-Dgpl=false', '-Dlibmpv=true'], + requiredFirst: true, + }); + + if (Array.isArray(mpv.mesonFlags)) { + for (const flag of mpv.mesonFlags) { + if ( + typeof flag === 'string' && + flag.startsWith('-Dgpl=') && + flag !== '-Dgpl=false' && + flag !== '-Dgpl=true' + ) { + errors.push( + `Linux runtime manifest mpv.mesonFlags must not include "${flag}".` + ); + } + } + } +} + +function validateRuntimeFiles(errors, runtimeFiles) { + if (!Array.isArray(runtimeFiles) || runtimeFiles.length === 0) { + errors.push( + 'Linux runtime manifest runtimeFiles must be a non-empty array.' + ); + return; + } + + const names = new Set(); + let hasVersionedLibMpv = false; + + for (const [index, runtimeFile] of runtimeFiles.entries()) { + const label = `runtimeFiles[${index}]`; + if (!isObject(runtimeFile)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + + const { name, sha256, size } = runtimeFile; + const safeName = isSafeBasename(name); + if (!safeName) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if ( + typeof name === 'string' && + safeName && + !SHARED_LIBRARY_PATTERN.test(name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must end in ".so" or a numeric ".so.N" suffix.` + ); + } + if (!Number.isInteger(size) || size <= 0) { + errors.push( + `Linux runtime manifest ${label}.size must be a positive integer.` + ); + } + if (typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)) { + errors.push( + `Linux runtime manifest ${label}.sha256 must be a lowercase 64-character hexadecimal digest.` + ); + } + + if (typeof name === 'string') { + if (names.has(name)) { + errors.push( + `Linux runtime manifest runtimeFiles contains duplicate name "${name}".` + ); + } else { + names.add(name); + } + if (VERSIONED_LIBMPV_PATTERN.test(name)) { + hasVersionedLibMpv = true; + } + } + } + + if (!hasVersionedLibMpv) { + errors.push( + 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.' + ); + } +} + +function validateLinuxRuntimeManifest(manifest) { + if (!isObject(manifest)) { + return ['Linux runtime manifest must be an object.']; + } + + const errors = []; + if (manifest.schemaVersion !== LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION) { + errors.push( + `Linux runtime manifest schemaVersion must be ${LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION}.` + ); + } + if (manifest.origin !== 'vendored-lgpl-source-build') { + errors.push( + 'Linux runtime manifest origin must be "vendored-lgpl-source-build".' + ); + } + if (manifest.platform !== 'linux') { + errors.push('Linux runtime manifest platform must be "linux".'); + } + if (manifest.arch !== 'x64') { + errors.push('Linux runtime manifest arch must be "x64".'); + } + + validatePackages(errors, manifest.packages); + validateFfmpeg(errors, manifest.ffmpeg); + validateMpv(errors, manifest.mpv); + + if (!isNonEmptyString(manifest.sourceDistribution)) { + errors.push( + 'Linux runtime manifest sourceDistribution must be a non-empty string.' + ); + } + + validateRuntimeFiles(errors, manifest.runtimeFiles); + return errors; +} + +module.exports = { + LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, + validateLinuxRuntimeManifest, +}; diff --git a/tools/embedded-mpv/linux-runtime-manifest.test.mjs b/tools/embedded-mpv/linux-runtime-manifest.test.mjs new file mode 100644 index 000000000..0879ef841 --- /dev/null +++ b/tools/embedded-mpv/linux-runtime-manifest.test.mjs @@ -0,0 +1,473 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, + validateLinuxRuntimeManifest, +} = require('./linux-runtime-manifest.cjs'); + +const workspaceRoot = fileURLToPath(new URL('../..', import.meta.url)); +const stageRuntimeScript = path.join( + workspaceRoot, + 'tools', + 'embedded-mpv', + 'stage-runtime.mjs' +); + +function sha256(value) { + return crypto.createHash('sha256').update(value).digest('hex'); +} + +function runtimeFile(name, contents) { + return { + name, + size: Buffer.byteLength(contents), + sha256: sha256(contents), + }; +} + +function createValidManifest( + runtimeFiles = [ + runtimeFile('libmpv.so.2', 'libmpv-runtime'), + runtimeFile('libavcodec.so.61', 'libavcodec-runtime'), + ] +) { + return { + schemaVersion: 1, + origin: 'vendored-lgpl-source-build', + platform: 'linux', + arch: 'x64', + packages: { + ffmpeg: { + version: '8.1', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + sourceSha256: 'a'.repeat(64), + license: 'LGPL-2.1-or-later', + }, + mpv: { + version: '0.41.0', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + sourceSha256: 'b'.repeat(64), + license: 'LGPL-2.1-or-later', + }, + }, + ffmpeg: { + version: '8.1', + sourceUrl: 'https://ffmpeg.org/releases/ffmpeg-8.1.tar.xz', + sourceSha256: 'a'.repeat(64), + configureFlags: [ + '--enable-shared', + '--disable-gpl', + '--disable-nonfree', + ], + }, + mpv: { + version: '0.41.0', + sourceUrl: + 'https://github.com/mpv-player/mpv/archive/refs/tags/v0.41.0.tar.gz', + sourceSha256: 'b'.repeat(64), + mesonFlags: ['-Dlibmpv=true', '-Dgpl=false'], + }, + sourceDistribution: + 'https://downloads.example.test/iptvnator/linux-runtime-sources.tar.zst', + runtimeFiles, + }; +} + +function createFixture(t, options = {}) { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-linux-runtime-test-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + + const prefix = path.join(root, 'prefix'); + const includeDir = path.join(prefix, 'include', 'mpv'); + const libDir = path.join(prefix, 'lib'); + fs.mkdirSync(includeDir, { recursive: true }); + fs.mkdirSync(libDir, { recursive: true }); + fs.writeFileSync( + path.join(includeDir, 'client.h'), + '/* libmpv header */\n' + ); + + const contentsByName = new Map([ + ['libmpv.so.2', 'libmpv-runtime'], + ['libavcodec.so.61', 'libavcodec-runtime'], + ]); + + for (const [name, contents] of contentsByName) { + fs.writeFileSync(path.join(libDir, name), contents); + } + + const manifest = createValidManifest( + [...contentsByName].map(([name, contents]) => + runtimeFile(name, contents) + ) + ); + options.mutateManifest?.(manifest); + + if (options.removeRuntimeFile) { + fs.rmSync(path.join(libDir, options.removeRuntimeFile), { + force: true, + }); + } + if (options.removeHeader) { + fs.rmSync(path.join(includeDir, 'client.h'), { force: true }); + } + if (!options.omitManifest) { + fs.writeFileSync( + path.join(prefix, 'runtime-manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n` + ); + } + + return { libDir, manifest, prefix, root }; +} + +function runStage(fixture) { + return spawnSync( + process.execPath, + [stageRuntimeScript, 'linux', 'x64', fixture.prefix], + { + cwd: fixture.root, + encoding: 'utf8', + } + ); +} + +function assertStageRejected(t, options, expectedError) { + const fixture = createFixture(t, options); + const result = runStage(fixture); + + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, expectedError); +} + +test('exports the Linux runtime manifest schema version', () => { + assert.equal(LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION, 1); +}); + +test('accepts a complete LGPL Linux x64 source-build manifest', () => { + assert.deepEqual(validateLinuxRuntimeManifest(createValidManifest()), []); +}); + +test('returns deterministic validation errors for untrusted input', () => { + assert.deepEqual(validateLinuxRuntimeManifest(null), [ + 'Linux runtime manifest must be an object.', + ]); + assert.deepEqual(validateLinuxRuntimeManifest(null), [ + 'Linux runtime manifest must be an object.', + ]); + + const manifest = createValidManifest(); + manifest.runtimeFiles[0].sha256 = Symbol('not-a-digest'); + assert.doesNotThrow(() => validateLinuxRuntimeManifest(manifest)); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /runtimeFiles\[0\]\.sha256/ + ); +}); + +test('requires schema, provenance, target, package, and source metadata', () => { + const manifest = createValidManifest(); + manifest.schemaVersion = 2; + manifest.origin = 'vendored-lgpl'; + manifest.platform = 'darwin'; + manifest.arch = 'arm64'; + manifest.packages.ffmpeg.sourceUrl = ''; + manifest.packages.mpv.version = ''; + manifest.sourceDistribution = ' '; + + assert.deepEqual(validateLinuxRuntimeManifest(manifest), [ + 'Linux runtime manifest schemaVersion must be 1.', + 'Linux runtime manifest origin must be "vendored-lgpl-source-build".', + 'Linux runtime manifest platform must be "linux".', + 'Linux runtime manifest arch must be "x64".', + 'Linux runtime manifest packages.ffmpeg.sourceUrl must be a non-empty string.', + 'Linux runtime manifest packages.mpv.version must be a non-empty string.', + 'Linux runtime manifest sourceDistribution must be a non-empty string.', + ]); + + manifest.packages = {}; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /packages must contain source package metadata/ + ); +}); + +test('rejects GPL and nonfree FFmpeg configurations', () => { + const cases = [ + { + flags: ['--disable-nonfree'], + expected: /must include "--disable-gpl"/, + }, + { + flags: ['--disable-gpl'], + expected: /must include "--disable-nonfree"/, + }, + { + flags: ['--disable-gpl', '--disable-nonfree', '--enable-gpl'], + expected: /must not include "--enable-gpl"/, + }, + { + flags: ['--disable-gpl', '--disable-nonfree', '--enable-nonfree'], + expected: /must not include "--enable-nonfree"/, + }, + ]; + + for (const { flags, expected } of cases) { + const manifest = createValidManifest(); + manifest.ffmpeg.configureFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } + + const manifest = createValidManifest(); + manifest.ffmpeg = { configureFlags: ['--enable-gpl'] }; + assert.match(validateLinuxRuntimeManifest(manifest)[0], /--enable-gpl/); +}); + +test('requires libmpv and GPL-disabled mpv Meson flags', () => { + const cases = [ + { + flags: ['-Dgpl=false'], + expected: /must include "-Dlibmpv=true"/, + }, + { + flags: ['-Dlibmpv=true'], + expected: /must include "-Dgpl=false"/, + }, + { + flags: ['-Dlibmpv=true', '-Dgpl=false', '-Dgpl=true'], + expected: /must not include "-Dgpl=true"/, + }, + ]; + + for (const { flags, expected } of cases) { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags = flags; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + expected + ); + } + + const manifest = createValidManifest(); + manifest.mpv = { mesonFlags: ['-Dgpl=true'] }; + assert.match(validateLinuxRuntimeManifest(manifest)[0], /-Dgpl=false/); +}); + +test('validates safe, unique shared-library metadata', () => { + const manifest = createValidManifest([ + { + name: '../libmpv.so.2', + size: 0, + sha256: 'ABC', + }, + runtimeFile('libcodec.a', 'archive'), + runtimeFile('libcodec.a', 'duplicate'), + ]); + + assert.deepEqual(validateLinuxRuntimeManifest(manifest), [ + 'Linux runtime manifest runtimeFiles[0].name must be a safe shared-library basename.', + 'Linux runtime manifest runtimeFiles[0].size must be a positive integer.', + 'Linux runtime manifest runtimeFiles[0].sha256 must be a lowercase 64-character hexadecimal digest.', + 'Linux runtime manifest runtimeFiles[1].name must end in ".so" or a numeric ".so.N" suffix.', + 'Linux runtime manifest runtimeFiles[2].name must end in ".so" or a numeric ".so.N" suffix.', + 'Linux runtime manifest runtimeFiles contains duplicate name "libcodec.a".', + 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.', + ]); +}); + +test('rejects control characters in shared-library basenames', () => { + const manifest = createValidManifest(); + manifest.runtimeFiles.push( + runtimeFile('libinjected.so.1\n', 'unsafe-name') + ); + + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /runtimeFiles\[2\]\.name must be a safe shared-library basename/ + ); +}); + +test('stages only declared Linux libraries and materializes source symlinks', (t) => { + const fixture = createFixture(t); + const versionedMpvContents = fs.readFileSync( + path.join(fixture.libDir, 'libmpv.so.2') + ); + fs.renameSync( + path.join(fixture.libDir, 'libmpv.so.2'), + path.join(fixture.libDir, 'libmpv.so.2.1.0') + ); + fs.symlinkSync('libmpv.so.2.1.0', path.join(fixture.libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(fixture.libDir, 'libmpv.so')); + fs.writeFileSync(path.join(fixture.libDir, 'libundeclared.so.1'), 'extra'); + fixture.manifest.runtimeFiles.push( + runtimeFile('libmpv.so', versionedMpvContents) + ); + fs.writeFileSync( + path.join(fixture.prefix, 'runtime-manifest.json'), + `${JSON.stringify(fixture.manifest, null, 2)}\n` + ); + + const result = runStage(fixture); + assert.equal(result.status, 0, result.stderr); + + const destinationRoot = path.join( + fixture.root, + 'vendor', + 'embedded-mpv', + 'linux-x64' + ); + const destinationLibDir = path.join(destinationRoot, 'lib'); + assert.deepEqual(fs.readdirSync(destinationLibDir).sort(), [ + 'libavcodec.so.61', + 'libmpv.so', + 'libmpv.so.2', + ]); + for (const runtimeEntry of fixture.manifest.runtimeFiles) { + const destinationPath = path.join(destinationLibDir, runtimeEntry.name); + const stat = fs.lstatSync(destinationPath); + assert.equal(stat.isFile(), true); + assert.equal(stat.isSymbolicLink(), false); + assert.equal(stat.size, runtimeEntry.size); + assert.equal( + sha256(fs.readFileSync(destinationPath)), + runtimeEntry.sha256 + ); + } + assert.equal( + fs.readFileSync( + path.join(destinationRoot, 'include', 'mpv', 'client.h'), + 'utf8' + ), + '/* libmpv header */\n' + ); + + const stagedManifest = JSON.parse( + fs.readFileSync( + path.join(destinationRoot, 'runtime-manifest.json'), + 'utf8' + ) + ); + assert.equal(stagedManifest.origin, 'vendored-lgpl'); + assert.equal( + stagedManifest.sourceBuildOrigin, + 'vendored-lgpl-source-build' + ); + assert.equal(stagedManifest.platform, 'linux'); + assert.equal(stagedManifest.arch, 'x64'); + assert.deepEqual( + stagedManifest.runtimeFiles, + fixture.manifest.runtimeFiles + ); +}); + +test('rejects missing Linux headers or manifests', (t) => { + assertStageRejected(t, { removeHeader: true }, /Missing libmpv header/); + assertStageRejected( + t, + { omitManifest: true }, + /Missing Linux runtime manifest/ + ); +}); + +test('rejects unsafe or duplicate declared library names', (t) => { + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].name = '../libmpv.so.2'; + }, + }, + /safe shared-library basename/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles.push({ ...manifest.runtimeFiles[0] }); + }, + }, + /duplicate name "libmpv.so.2"/ + ); +}); + +test('rejects missing files and mismatched size or hash metadata', (t) => { + assertStageRejected( + t, + { removeRuntimeFile: 'libavcodec.so.61' }, + /Missing declared Linux runtime file.*libavcodec\.so\.61/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].size += 1; + }, + }, + /Size mismatch for Linux runtime file.*libmpv\.so\.2/ + ); + assertStageRejected( + t, + { + mutateManifest(manifest) { + manifest.runtimeFiles[0].sha256 = '0'.repeat(64); + }, + }, + /SHA-256 mismatch for Linux runtime file.*libmpv\.so\.2/ + ); +}); + +test('rejects forbidden build flags and a missing versioned libmpv entry', (t) => { + const invalidConfigurations = [ + { + mutateManifest(manifest) { + manifest.ffmpeg.configureFlags.push('--enable-gpl'); + }, + expected: /must not include "--enable-gpl"/, + }, + { + mutateManifest(manifest) { + manifest.ffmpeg.configureFlags.push('--enable-nonfree'); + }, + expected: /must not include "--enable-nonfree"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = ['-Dlibmpv=true', '-Dgpl=true']; + }, + expected: /must include "-Dgpl=false"/, + }, + { + mutateManifest(manifest) { + manifest.mpv.mesonFlags = ['-Dgpl=false']; + }, + expected: /must include "-Dlibmpv=true"/, + }, + { + mutateManifest(manifest) { + manifest.runtimeFiles = manifest.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so.2' + ); + }, + expected: /must include a versioned libmpv\.so\.N entry/, + }, + ]; + + for (const { expected, mutateManifest } of invalidConfigurations) { + assertStageRejected(t, { mutateManifest }, expected); + } +}); diff --git a/tools/embedded-mpv/stage-runtime.mjs b/tools/embedded-mpv/stage-runtime.mjs index 81b06e3fa..4c5633e7a 100644 --- a/tools/embedded-mpv/stage-runtime.mjs +++ b/tools/embedded-mpv/stage-runtime.mjs @@ -1,5 +1,12 @@ +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { + validateLinuxRuntimeManifest, +} = require('./linux-runtime-manifest.cjs'); const rawArgs = process.argv.slice(2); const args = rawArgs[0] === '--' ? rawArgs.slice(1) : rawArgs; @@ -161,11 +168,111 @@ function readJsonIfExists(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function readLinuxRuntimeManifest() { + const manifestPath = path.join(normalizedPrefix, 'runtime-manifest.json'); + if (!fs.existsSync(manifestPath)) { + throw new Error(`Missing Linux runtime manifest: ${manifestPath}`); + } + + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new Error( + `Invalid JSON in Linux runtime manifest ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + + const errors = validateLinuxRuntimeManifest(manifest); + if (errors.length > 0) { + throw new Error( + ['Invalid Linux runtime manifest.', ...errors].join('\n') + ); + } + + return manifest; +} + +function sha256File(filePath) { + return crypto + .createHash('sha256') + .update(fs.readFileSync(filePath)) + .digest('hex'); +} + +function assertPathInsideDirectory(filePath, directory) { + const relativePath = path.relative( + fs.realpathSync(directory), + fs.realpathSync(filePath) + ); + if ( + relativePath === '..' || + relativePath.startsWith(`..${path.sep}`) || + path.isAbsolute(relativePath) + ) { + throw new Error( + `Declared Linux runtime file resolves outside prefix/lib: ${filePath}` + ); + } +} + +function verifyLinuxRuntimeFiles(manifest) { + for (const runtimeFile of manifest.runtimeFiles) { + const sourcePath = path.join(sourceLibDir, runtimeFile.name); + if (!fs.existsSync(sourcePath)) { + throw new Error( + `Missing declared Linux runtime file: ${sourcePath}` + ); + } + + assertPathInsideDirectory(sourcePath, sourceLibDir); + const sourceStat = fs.statSync(sourcePath); + if (!sourceStat.isFile()) { + throw new Error( + `Declared Linux runtime path is not a regular file: ${sourcePath}` + ); + } + if (sourceStat.size !== runtimeFile.size) { + throw new Error( + `Size mismatch for Linux runtime file ${sourcePath}: expected ${runtimeFile.size}, received ${sourceStat.size}` + ); + } + + const actualSha256 = sha256File(sourcePath); + if (actualSha256 !== runtimeFile.sha256) { + throw new Error( + `SHA-256 mismatch for Linux runtime file ${sourcePath}: expected ${runtimeFile.sha256}, received ${actualSha256}` + ); + } + } +} + +function copyLinuxRuntimeFiles(manifest) { + fs.mkdirSync(destinationLibDir, { recursive: true }); + for (const runtimeFile of manifest.runtimeFiles) { + const sourcePath = path.join(sourceLibDir, runtimeFile.name); + const destinationPath = path.join(destinationLibDir, runtimeFile.name); + fs.copyFileSync(sourcePath, destinationPath); + fs.chmodSync(destinationPath, 0o755); + } +} + try { assertExists( path.join(sourceIncludeDir, 'mpv', 'client.h'), 'Missing libmpv header' ); + const externalManifest = + platform === 'linux' + ? readLinuxRuntimeManifest() + : (readJsonIfExists( + path.join(normalizedPrefix, 'runtime-manifest.json') + ) ?? {}); + if (platform === 'linux') { + verifyLinuxRuntimeFiles(externalManifest); + } if (platform !== 'linux' && !findRuntimeFile(sourceLibDir)) { throw new Error( `Missing libmpv runtime for ${platform} in ${sourceLibDir}` @@ -187,22 +294,28 @@ try { ); if (platform !== 'linux') { copyDirectory(sourceLibDir, destinationLibDir, runtimeFileFilter); + } else { + copyLinuxRuntimeFiles(externalManifest); } if (platform === 'win32') { copyDirectory(sourceBinDir, destinationLibDir, runtimeFileFilter); } - const externalManifest = - readJsonIfExists( - path.join(normalizedPrefix, 'runtime-manifest.json') - ) ?? {}; const manifest = { ...externalManifest, origin: 'vendored-lgpl', + ...(platform === 'linux' + ? { sourceBuildOrigin: externalManifest.origin } + : {}), platform, arch, stagedAt: new Date().toISOString(), - runtimeFiles: listRuntimeFiles(destinationLibDir), + runtimeFiles: + platform === 'linux' + ? externalManifest.runtimeFiles.map((runtimeFile) => ({ + ...runtimeFile, + })) + : listRuntimeFiles(destinationLibDir), ffmpeg: { licensePolicy: 'LGPL, built without --enable-gpl and --enable-nonfree', diff --git a/tools/packaging/project.json b/tools/packaging/project.json index 0dfe9dddb..7d28ad67d 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -23,11 +23,13 @@ "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", + "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.cjs", + "{workspaceRoot}/tools/embedded-mpv/linux-runtime-manifest.test.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-runtime.mjs", "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } },