diff --git a/tools/embedded-mpv/build-linux-runtime.cjs b/tools/embedded-mpv/build-linux-runtime.cjs index aba0eb164..05b9c3a23 100644 --- a/tools/embedded-mpv/build-linux-runtime.cjs +++ b/tools/embedded-mpv/build-linux-runtime.cjs @@ -94,6 +94,16 @@ const SOURCE_PACKAGES = Object.freeze( expectedGitCommit: 'cee9b076f2c63104ccfd497fa79c39a867293ec4', license: 'LGPL-2.1-or-later', }, + { + id: 'libdisplay-info', + version: '0.1.1', + sourceKind: 'archive', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + expectedSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }, { id: 'mpv', version: '0.41.0', @@ -203,7 +213,7 @@ const MPV_MESON_FLAGS = Object.freeze([ '-Dd3d11=disabled', '-Ddirect3d=disabled', '-Ddmabuf-wayland=disabled', - '-Ddrm=disabled', + '-Ddrm=enabled', '-Degl=enabled', '-Degl-android=disabled', '-Degl-angle=disabled', @@ -318,6 +328,12 @@ const BUILD_RECIPES = Object.freeze({ '-Dtools=disabled', '-Dcache-build=disabled', '-Dnls=disabled', + '-Dxml-backend=expat', + '-Dbaseconfig-dir=/etc/fonts', + '-Dconfig-dir=/etc/fonts/conf.d', + '-Dtemplate-dir=/usr/share/fontconfig/conf.avail', + '-Dcache-dir=/var/cache/fontconfig', + '-Dxml-dir=/usr/share/xml/fontconfig', ]), }), libass: Object.freeze({ @@ -344,6 +360,7 @@ const BUILD_RECIPES = Object.freeze({ 'no-legacy', 'no-module', 'no-weak-ssl-ciphers', + '--openssldir=/etc/ssl', ]), }), ffmpeg: Object.freeze({ @@ -371,6 +388,11 @@ const BUILD_RECIPES = Object.freeze({ '-Dxxhash=disabled', ]), }), + 'libdisplay-info': Object.freeze({ + buildSystem: 'meson', + sharedOnly: true, + args: Object.freeze([]), + }), mpv: Object.freeze({ buildSystem: 'meson', sharedOnly: true, @@ -385,6 +407,7 @@ const REQUIRED_TOOLS = Object.freeze([ 'git', 'make', 'meson', + 'nasm', 'ninja', 'patchelf', 'perl', @@ -394,6 +417,23 @@ const REQUIRED_TOOLS = Object.freeze([ 'tar', ]); +const MINIMUM_TOOL_VERSIONS = Object.freeze({ + cc: '9.0.0', + cmake: '3.16.0', + curl: '7.71.0', + git: '2.30.0', + make: '4.0.0', + meson: '1.6.0', + nasm: '2.15.05', + ninja: '1.10.0', + patchelf: '0.14.0', + perl: '5.30.0', + 'pkg-config': '0.29.0', + python3: '3.8.0', + readelf: '2.35.0', + tar: '1.30.0', +}); + const DEFAULT_SYSTEM_PKG_CONFIG_DIRS = Object.freeze([ '/usr/lib/x86_64-linux-gnu/pkgconfig', '/usr/lib64/pkgconfig', @@ -406,6 +446,7 @@ const EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES = Object.freeze([ 'egl', 'gbm', 'gl', + 'libdrm', 'libpulse', 'libva', 'libva-drm', @@ -477,6 +518,31 @@ const EXTERNAL_SYSTEM_LIBRARIES = Object.freeze( ].map((externalLibrary) => Object.freeze(externalLibrary)) ); +const RUNTIME_EXTERNAL_CONFIGURATION = Object.freeze({ + fontconfig: Object.freeze({ + configDirectory: '/etc/fonts', + templateDirectory: '/usr/share/fontconfig', + cacheDirectory: '/var/cache/fontconfig', + ownership: 'system', + }), + openssl: Object.freeze({ + configFile: '/etc/ssl/openssl.cnf', + certificateFile: '/etc/ssl/cert.pem', + certificateDirectory: '/etc/ssl/certs', + ownership: 'system', + }), +}); + +const OUTPUT_OWNERSHIP_MARKER = '.iptvnator-linux-runtime-owner'; +const OUTPUT_OWNERSHIP_MARKER_CONTENT = + 'iptvnator-embedded-mpv-linux-runtime-v1\n'; + +const PORTABLE_ABI_BASELINE = Object.freeze({ + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', +}); + const SHARED_LIBRARY_PATTERN = /\.so(?:\.\d+)*$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const externalSystemLibraryNames = new Set( @@ -503,6 +569,217 @@ function assertGitCommitMatchesPin(sourcePackage, actualGitCommit) { } } +function parseVersion(value) { + if (typeof value !== 'string') { + return null; + } + const match = value.match(/\b(\d+\.\d+(?:\.\d+)*)\b/); + return match?.[1] ?? null; +} + +function compareVersions(left, right) { + const leftParts = left.split('.').map(Number); + const rightParts = right.split('.').map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (difference !== 0) { + return Math.sign(difference); + } + } + return 0; +} + +function assertMinimumToolVersions(toolVersions) { + for (const tool of REQUIRED_TOOLS) { + const declaredVersion = toolVersions?.[tool]; + if (typeof declaredVersion !== 'string' || !declaredVersion.trim()) { + throw new Error(`Missing required tool version for ${tool}.`); + } + const actualVersion = parseVersion(declaredVersion); + if (!actualVersion) { + throw new Error( + `Unable to parse required tool version for ${tool}: ${declaredVersion}.` + ); + } + const minimumVersion = MINIMUM_TOOL_VERSIONS[tool]; + if (compareVersions(actualVersion, minimumVersion) < 0) { + throw new Error( + `${tool} ${actualVersion} is unsupported; ${tool} requires ${minimumVersion} or newer for Linux runtime builds.` + ); + } + } +} + +function assertUniqueMesonOptionAssignments(buildRecipes) { + if (!buildRecipes || typeof buildRecipes !== 'object') { + throw new TypeError('Linux runtime build recipes must be an object.'); + } + for (const [packageId, recipe] of Object.entries(buildRecipes)) { + if (recipe?.buildSystem !== 'meson') { + continue; + } + if (!Array.isArray(recipe.args)) { + throw new Error( + `${packageId} Meson recipe must declare an argument array.` + ); + } + + const assignmentsByOption = new Map(); + for (const flag of recipe.args) { + const match = + typeof flag === 'string' ? flag.match(/^(-D[^=]+)=/) : null; + if (!match) { + continue; + } + const option = match[1]; + const assignmentCount = (assignmentsByOption.get(option) ?? 0) + 1; + assignmentsByOption.set(option, assignmentCount); + if (assignmentCount > 1) { + throw new Error( + `${packageId} Meson recipe must assign ${option} exactly once.` + ); + } + } + } +} + +function lstatIfExists(fileSystem, filePath) { + try { + return fileSystem.lstatSync(filePath); + } catch (error) { + if (error?.code === 'ENOENT') { + return null; + } + throw error; + } +} + +function assertOwnedOutputDestination(outputPrefix, fileSystem = fs) { + const outputStat = lstatIfExists(fileSystem, outputPrefix); + if (!outputStat) { + return; + } + if (!outputStat.isDirectory() || outputStat.isSymbolicLink()) { + throw new Error( + `Existing output ${outputPrefix} must be a non-symbolic-link directory carrying the IPTVnator ownership marker.` + ); + } + + const markerPath = path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER); + const markerStat = lstatIfExists(fileSystem, markerPath); + if ( + !markerStat || + !markerStat.isFile() || + markerStat.isSymbolicLink() || + fileSystem.readFileSync(markerPath, 'utf8') !== + OUTPUT_OWNERSHIP_MARKER_CONTENT + ) { + throw new Error( + `Existing output ${outputPrefix} is missing the valid IPTVnator ownership marker.` + ); + } +} + +function ownedStagingPrefixPath(outputPrefix, token) { + return path.join( + path.dirname(outputPrefix), + `.${path.basename(outputPrefix)}.iptvnator-stage-${token}` + ); +} + +function createOwnedStagingPrefix( + outputPrefix, + { fileSystem = fs, token = crypto.randomBytes(8).toString('hex') } = {} +) { + assertOwnedOutputDestination(outputPrefix, fileSystem); + const outputParent = path.dirname(outputPrefix); + const stagingPrefix = ownedStagingPrefixPath(outputPrefix, token); + if (lstatIfExists(fileSystem, stagingPrefix)) { + throw new Error( + `Refusing to reuse existing Linux runtime staging path ${stagingPrefix}.` + ); + } + + fileSystem.mkdirSync(outputParent, { recursive: true }); + fileSystem.mkdirSync(stagingPrefix); + try { + fileSystem.writeFileSync( + path.join(stagingPrefix, OUTPUT_OWNERSHIP_MARKER), + OUTPUT_OWNERSHIP_MARKER_CONTENT, + { mode: 0o644 } + ); + } catch (error) { + fileSystem.rmSync(stagingPrefix, { recursive: true, force: true }); + throw error; + } + return stagingPrefix; +} + +function publishOwnedOutput({ + outputPrefix, + stagingPrefix, + fileSystem = fs, + token = crypto.randomBytes(8).toString('hex'), +}) { + assertOwnedOutputDestination(outputPrefix, fileSystem); + assertOwnedOutputDestination(stagingPrefix, fileSystem); + if (!lstatIfExists(fileSystem, stagingPrefix)) { + throw new Error( + `Linux runtime staging prefix does not exist: ${stagingPrefix}.` + ); + } + + const backupPrefix = path.join( + path.dirname(outputPrefix), + `.${path.basename(outputPrefix)}.iptvnator-backup-${token}` + ); + if (lstatIfExists(fileSystem, backupPrefix)) { + throw new Error( + `Refusing to reuse existing Linux runtime backup path ${backupPrefix}.` + ); + } + + let movedPreviousOutput = false; + let published = false; + try { + if (lstatIfExists(fileSystem, outputPrefix)) { + fileSystem.renameSync(outputPrefix, backupPrefix); + movedPreviousOutput = true; + } + fileSystem.renameSync(stagingPrefix, outputPrefix); + published = true; + if (movedPreviousOutput) { + fileSystem.rmSync(backupPrefix, { + recursive: true, + force: true, + }); + } + } catch (error) { + if ( + movedPreviousOutput && + !lstatIfExists(fileSystem, outputPrefix) && + lstatIfExists(fileSystem, backupPrefix) + ) { + fileSystem.renameSync(backupPrefix, outputPrefix); + } + throw error; + } finally { + if (lstatIfExists(fileSystem, stagingPrefix)) { + fileSystem.rmSync(stagingPrefix, { + recursive: true, + force: true, + }); + } + if (published && lstatIfExists(fileSystem, backupPrefix)) { + fileSystem.rmSync(backupPrefix, { + recursive: true, + force: true, + }); + } + } +} + function joinEnvironmentParts(parts, separator = ' ') { return parts.filter((value) => value && value.trim()).join(separator); } @@ -625,9 +902,12 @@ function assertPathInside(parentPath, candidatePath, label) { } } -function materializeLibrarySymlinks(libDir) { +function materializeLibrarySymlinks(libDir, selectedNames = null) { const realLibDir = fs.realpathSync(libDir); for (const name of runtimeLibraryNames(libDir)) { + if (selectedNames && !selectedNames.has(name)) { + continue; + } const libraryPath = path.join(libDir, name); const stat = fs.lstatSync(libraryPath); if (!stat.isSymbolicLink()) { @@ -654,6 +934,109 @@ function materializeLibrarySymlinks(libDir) { } } +function selectReachableRuntimeLibraryNames(entries) { + if (!Array.isArray(entries)) { + throw new TypeError('Runtime dynamic entries must be an array.'); + } + + const entriesByName = new Map(); + for (const entry of entries) { + if ( + !entry || + typeof entry.name !== 'string' || + !SHARED_LIBRARY_PATTERN.test(entry.name) + ) { + throw new Error( + 'Runtime dynamic entry has an invalid library name.' + ); + } + if (entriesByName.has(entry.name)) { + throw new Error( + `Runtime dynamic entries contain duplicate library ${entry.name}.` + ); + } + entriesByName.set(entry.name, entry); + } + + const linkerAlias = entriesByName.get('libmpv.so'); + if (!linkerAlias) { + throw new Error( + 'Linux runtime must contain the libmpv.so linker alias.' + ); + } + if ( + typeof linkerAlias.soname !== 'string' || + !SHARED_LIBRARY_PATTERN.test(linkerAlias.soname) || + !entriesByName.has(linkerAlias.soname) + ) { + throw new Error( + 'libmpv.so must declare a bundled SONAME before runtime pruning.' + ); + } + + const reachableNames = new Set(['libmpv.so']); + const pendingNames = [linkerAlias.soname]; + while (pendingNames.length > 0) { + const libraryName = pendingNames.shift(); + if (reachableNames.has(libraryName)) { + continue; + } + reachableNames.add(libraryName); + const entry = entriesByName.get(libraryName); + if (!entry) { + throw new Error( + `Reachable runtime library ${libraryName} is missing its dynamic entry.` + ); + } + for (const neededName of entry.needed ?? []) { + if ( + entriesByName.has(neededName) && + !reachableNames.has(neededName) + ) { + pendingNames.push(neededName); + } + } + } + + return [...reachableNames].sort(); +} + +function retainRuntimeLibraries(libDir, retainedNames) { + if (!Array.isArray(retainedNames) || retainedNames.length === 0) { + throw new Error('Runtime retention list must be a non-empty array.'); + } + const retainedNameSet = new Set(retainedNames); + if (retainedNameSet.size !== retainedNames.length) { + throw new Error('Runtime retention list contains duplicate libraries.'); + } + + const availableNames = runtimeLibraryNames(libDir); + for (const retainedName of retainedNameSet) { + if (!availableNames.includes(retainedName)) { + throw new Error( + `Retained runtime library does not exist: ${retainedName}.` + ); + } + } + + materializeLibrarySymlinks(libDir, retainedNameSet); + for (const libraryName of availableNames) { + if (!retainedNameSet.has(libraryName)) { + fs.rmSync(path.join(libDir, libraryName)); + } + } + + for (const retainedName of retainedNameSet) { + const retainedPath = path.join(libDir, retainedName); + const stat = fs.lstatSync(retainedPath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error( + `Retained runtime library ${retainedName} must be a materialized regular file.` + ); + } + } +} + function createRuntimeFileRecords(libDir) { return runtimeLibraryNames(libDir).map((name) => { const libraryPath = path.join(libDir, name); @@ -677,10 +1060,16 @@ function parseReadelfDynamic(output) { needed: [], rpath: [], runpath: [], + soname: null, }; - const dynamicEntryPattern = /\((NEEDED|RPATH|RUNPATH)\)[^[]*\[([^\]]*)\]/g; + const dynamicEntryPattern = + /\((NEEDED|RPATH|RUNPATH|SONAME)\)[^[]*\[([^\]]*)\]/g; for (const match of output.matchAll(dynamicEntryPattern)) { const [, tag, value] = match; + if (tag === 'SONAME') { + dynamic.soname = value; + continue; + } if (tag === 'NEEDED') { dynamic.needed.push(value); continue; @@ -691,12 +1080,75 @@ function parseReadelfDynamic(output) { ); } - for (const field of Object.keys(dynamic)) { + for (const field of ['needed', 'rpath', 'runpath']) { dynamic[field] = [...new Set(dynamic[field])].sort(); } return dynamic; } +function parseReadelfVersionInfo(output, name) { + if (typeof output !== 'string' || typeof name !== 'string' || !name) { + throw new TypeError( + 'readelf version output and runtime library name are required.' + ); + } + + let requiredGlibc = null; + let requiredGlibcxx = null; + const versionPattern = /\b(GLIBCXX|GLIBC)_(\d+(?:\.\d+)+)\b/g; + for (const [, namespace, version] of output.matchAll(versionPattern)) { + if ( + namespace === 'GLIBC' && + (!requiredGlibc || compareVersions(version, requiredGlibc) > 0) + ) { + requiredGlibc = version; + } + if ( + namespace === 'GLIBCXX' && + (!requiredGlibcxx || compareVersions(version, requiredGlibcxx) > 0) + ) { + requiredGlibcxx = version; + } + } + + return { name, requiredGlibc, requiredGlibcxx }; +} + +function assertPortableAbiRecords(records) { + if (!Array.isArray(records)) { + throw new TypeError('Runtime ABI records must be an array.'); + } + for (const record of records) { + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ]) { + const version = record?.[field]; + if (version && compareVersions(version, maximum) > 0) { + throw new Error( + `Portable ABI baseline ${PORTABLE_ABI_BASELINE.distribution} rejects newer symbol ${version} required by ${record.name}; maximum ${field} is ${maximum}.` + ); + } + } + } +} + +function assertPortableBuildHostGlibc(glibcVersion) { + if ( + typeof glibcVersion !== 'string' || + !/^\d+(?:\.\d+)+$/.test(glibcVersion) + ) { + throw new Error( + 'Unable to determine the Linux build host glibc version.' + ); + } + if (compareVersions(glibcVersion, PORTABLE_ABI_BASELINE.glibcMaximum) > 0) { + throw new Error( + `Build host glibc ${glibcVersion} exceeds the portable ABI baseline ${PORTABLE_ABI_BASELINE.distribution} maximum ${PORTABLE_ABI_BASELINE.glibcMaximum}.` + ); + } +} + function validateRuntimeDependencyClosure({ entries, runtimeFileNames, @@ -714,6 +1166,7 @@ function validateRuntimeDependencyClosure({ const normalizedEntries = [...entries] .map((entry) => ({ name: entry.name, + soname: entry.soname ?? null, needed: [...new Set(entry.needed ?? [])].sort(), rpath: [...new Set(entry.rpath ?? [])].sort(), runpath: [...new Set(entry.runpath ?? [])].sort(), @@ -733,6 +1186,27 @@ function validateRuntimeDependencyClosure({ } entryNames.add(entry.name); + if ( + entry.soname !== null && + (typeof entry.soname !== 'string' || + !SHARED_LIBRARY_PATTERN.test(entry.soname) || + path.basename(entry.soname) !== entry.soname) + ) { + throw new Error( + `${entry.name} SONAME must be null or a safe shared-library basename.` + ); + } + if ( + entry.name === 'libmpv.so' && + (typeof entry.soname !== 'string' || + !/^libmpv\.so\.\d+(?:\.\d+)*$/.test(entry.soname) || + !bundledNames.has(entry.soname)) + ) { + throw new Error( + 'libmpv.so must declare a versioned SONAME present in the runtime closure.' + ); + } + if (entry.rpath.length > 0) { throw new Error( `${entry.name} has forbidden RPATH ${entry.rpath.join(':')}.` @@ -845,6 +1319,7 @@ function sourceManifestMetadata(sourceRecord) { function createLinuxRuntimeManifest({ sourceRecords, runtimeFiles, + abiRecords, dependencyClosure, buildHost, generatedAt = new Date().toISOString(), @@ -867,6 +1342,7 @@ function createLinuxRuntimeManifest({ } packages[sourcePackage.id] = sourceManifestMetadata(sourceRecord); } + assertPortableAbiRecords(abiRecords); const runtimeTotalBytes = runtimeFiles.reduce( (total, runtimeFile) => total + runtimeFile.size, @@ -893,9 +1369,18 @@ function createLinuxRuntimeManifest({ }, runtimeFiles: runtimeFiles.map((runtimeFile) => ({ ...runtimeFile })), runtimeTotalBytes, + runtimeAbi: { + baseline: { ...PORTABLE_ABI_BASELINE }, + files: abiRecords.map((record) => ({ ...record })), + }, + runtimeExternalConfiguration: { + fontconfig: { ...RUNTIME_EXTERNAL_CONFIGURATION.fontconfig }, + openssl: { ...RUNTIME_EXTERNAL_CONFIGURATION.openssl }, + }, runtimeDependencyClosure: { entries: dependencyClosure.entries.map((entry) => ({ name: entry.name, + soname: entry.soname ?? null, needed: [...entry.needed], rpath: [...entry.rpath], runpath: [...entry.runpath], @@ -907,7 +1392,7 @@ function createLinuxRuntimeManifest({ ), buildHost, sourceDistribution: - 'Attach a source archive to the corresponding Linux binary release containing the exact downloaded source archives, a checkout or git bundle of the recorded libplacebo commit and submodules, tools/embedded-mpv/build-linux-runtime.mjs, tools/embedded-mpv/build-linux-runtime.cjs, this runtime manifest, and any local patches.', + 'Attach a source archive to the corresponding Linux binary release containing the exact downloaded source archives, including the pinned MIT-licensed libdisplay-info source archive, a checkout or git bundle of the recorded libplacebo commit and submodules, tools/embedded-mpv/build-linux-runtime.mjs, tools/embedded-mpv/build-linux-runtime.cjs, this runtime manifest, and any local patches.', }; } @@ -919,19 +1404,36 @@ module.exports = { EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, FFMPEG_CONFIGURE_FLAGS, GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, MPV_MESON_FLAGS, + OUTPUT_OWNERSHIP_MARKER, + PORTABLE_ABI_BASELINE, REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, SOURCE_PACKAGES, assertArchiveMatchesPin, assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, + compareVersions, createBuildEnvironment, createLinuxRuntimeManifest, + createOwnedStagingPrefix, createRuntimeFileRecords, materializeLibrarySymlinks, + ownedStagingPrefixPath, parseCliInvocation, parseReadelfDynamic, + parseReadelfVersionInfo, + parseVersion, resolveSystemPkgConfigDirs, runtimeLibraryNames, sha256Buffer, + publishOwnedOutput, + retainRuntimeLibraries, + selectReachableRuntimeLibraryNames, validateRuntimeDependencyClosure, }; diff --git a/tools/embedded-mpv/build-linux-runtime.mjs b/tools/embedded-mpv/build-linux-runtime.mjs index e24e9fd4f..4dd678629 100644 --- a/tools/embedded-mpv/build-linux-runtime.mjs +++ b/tools/embedded-mpv/build-linux-runtime.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from 'node:fs'; +import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -13,19 +14,30 @@ const { BUILD_ORDER, EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, MPV_MESON_FLAGS, + PORTABLE_ABI_BASELINE, REQUIRED_TOOLS, SOURCE_PACKAGES, assertArchiveMatchesPin, assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, createBuildEnvironment, createLinuxRuntimeManifest, + createOwnedStagingPrefix, createRuntimeFileRecords, - materializeLibrarySymlinks, + ownedStagingPrefixPath, parseCliInvocation, parseReadelfDynamic, + parseReadelfVersionInfo, + retainRuntimeLibraries, resolveSystemPkgConfigDirs, runtimeLibraryNames, + selectReachableRuntimeLibraryNames, sha256Buffer, + publishOwnedOutput, validateRuntimeDependencyClosure, } = require('./build-linux-runtime.cjs'); const { @@ -344,6 +356,91 @@ function mesonInstall(packageId, recipe, context) { context.run('meson', ['install', '-C', buildPath], { cwd: sourcePath }); } +function pathInsideDestdir(destdir, absolutePath) { + return path.join( + destdir, + absolutePath.slice(path.parse(absolutePath).root.length) + ); +} + +function copyDirectoryContents(sourceDirectory, destinationDirectory) { + if (!fs.existsSync(sourceDirectory)) { + return; + } + fs.mkdirSync(destinationDirectory, { recursive: true }); + for (const entry of fs.readdirSync(sourceDirectory)) { + fs.cpSync( + path.join(sourceDirectory, entry), + path.join(destinationDirectory, entry), + { + recursive: true, + force: true, + } + ); + } +} + +function installWithDestdir(packageId, context, install, externalPaths = []) { + const destdir = path.join(context.buildRoot, 'install-roots', packageId); + fs.rmSync(destdir, { recursive: true, force: true }); + fs.mkdirSync(destdir, { recursive: true }); + try { + install(destdir); + copyDirectoryContents( + pathInsideDestdir(destdir, context.prefix), + context.prefix + ); + for (const { destination, source } of externalPaths) { + copyDirectoryContents( + pathInsideDestdir(destdir, source), + path.join(context.prefix, destination) + ); + } + } finally { + fs.rmSync(destdir, { recursive: true, force: true }); + } +} + +function fontconfigInstall(recipe, context) { + const sourcePath = sourcePathFor('fontconfig', context.sourceRoot); + const buildPath = path.join(sourcePath, 'build-iptvnator'); + fs.rmSync(buildPath, { recursive: true, force: true }); + context.run( + 'meson', + ['setup', buildPath, ...mesonSetupArgs(context.prefix, recipe.args)], + { cwd: sourcePath } + ); + context.run( + 'meson', + ['compile', '--jobs', context.parallelism, '-C', buildPath], + { cwd: sourcePath } + ); + installWithDestdir( + 'fontconfig', + context, + (destdir) => + context.run('meson', ['install', '-C', buildPath], { + cwd: sourcePath, + env: { ...context.buildEnvironment, DESTDIR: destdir }, + }), + [ + { source: '/etc/fonts', destination: path.join('etc', 'fonts') }, + { + source: '/usr/share/fontconfig', + destination: path.join('share', 'fontconfig'), + }, + { + source: '/usr/share/xml/fontconfig', + destination: path.join('share', 'xml', 'fontconfig'), + }, + { + source: '/var/cache/fontconfig', + destination: path.join('var', 'cache', 'fontconfig'), + }, + ] + ); +} + function cmakeInstall(packageId, recipe, context) { const sourcePath = sourcePathFor(packageId, context.sourceRoot); const buildPath = path.join(sourcePath, 'build-iptvnator'); @@ -378,14 +475,17 @@ function opensslInstall(recipe, context) { './Configure', 'linux-x86_64', `--prefix=${context.prefix}`, - `--openssldir=${path.join(context.prefix, 'etc', 'ssl')}`, '--libdir=lib', ...recipe.args, ], { cwd: sourcePath } ); context.run('make', [`-j${context.parallelism}`], { cwd: sourcePath }); - context.run('make', ['install_sw'], { cwd: sourcePath }); + installWithDestdir('openssl', context, (destdir) => + context.run('make', ['install_sw', `DESTDIR=${destdir}`], { + cwd: sourcePath, + }) + ); } function ffmpegInstall(recipe, context) { @@ -401,6 +501,10 @@ function buildRuntime(context) { for (const packageId of BUILD_ORDER) { const recipe = BUILD_RECIPES[packageId]; log(`Building ${packageId} as shared libraries`); + if (packageId === 'fontconfig') { + fontconfigInstall(recipe, context); + continue; + } switch (recipe.buildSystem) { case 'configure': configureInstall(packageId, recipe, context); @@ -477,8 +581,25 @@ function postProcessRuntime(context) { if (!fs.existsSync(libDir)) { throw new Error(`Runtime library directory was not built: ${libDir}.`); } - materializeLibrarySymlinks(libDir); + const unprunedDynamicEntries = runtimeLibraryNames(libDir).map( + (libraryName) => { + const libraryPath = path.join(libDir, libraryName); + assertElfLibrary(libraryPath); + return { + name: libraryName, + ...parseReadelfDynamic( + context.runCapture('readelf', ['-d', libraryPath]) + ), + }; + } + ); + const retainedNames = selectReachableRuntimeLibraryNames( + unprunedDynamicEntries + ); + retainRuntimeLibraries(libDir, retainedNames); + + const abiRecords = []; const dynamicEntries = []; for (const libraryName of runtimeLibraryNames(libDir)) { const libraryPath = path.join(libDir, libraryName); @@ -488,7 +609,13 @@ function postProcessRuntime(context) { context.runCapture('readelf', ['-d', libraryPath]) ); dynamicEntries.push({ name: libraryName, ...dynamic }); + const versionInfo = context.runCapture('readelf', [ + '--version-info', + libraryPath, + ]); + abiRecords.push(parseReadelfVersionInfo(versionInfo, libraryName)); } + assertPortableAbiRecords(abiRecords); const runtimeFiles = createRuntimeFileRecords(libDir); if (runtimeFiles.length === 0) { @@ -502,7 +629,12 @@ function postProcessRuntime(context) { buildPrefix: context.prefix, }); - return { dependencyClosure, runtimeFiles }; + return { + abiBaseline: PORTABLE_ABI_BASELINE, + abiRecords, + dependencyClosure, + runtimeFiles, + }; } function firstLine(value) { @@ -515,6 +647,31 @@ function firstLine(value) { } function collectBuildHost(context) { + const tools = context.toolVersions; + if (!tools) { + throw new Error('Linux runtime tool versions were not preflighted.'); + } + return { + platform: process.platform, + arch: process.arch, + release: os.release(), + glibcVersion: context.glibcVersion, + systemPkgConfigDirs: [...context.systemPkgConfigDirs], + systemPkgConfigPackages: { + ...context.systemPkgConfigPackages, + }, + tools: { ...tools }, + }; +} + +function detectBuildHostGlibcVersion() { + const glibcVersion = + process.report?.getReport?.()?.header?.glibcVersionRuntime; + assertPortableBuildHostGlibc(glibcVersion); + return glibcVersion; +} + +function collectToolVersions(context) { const versionArgs = { cc: ['--version'], cmake: ['--version'], @@ -522,42 +679,34 @@ function collectBuildHost(context) { git: ['--version'], make: ['--version'], meson: ['--version'], + nasm: ['-v'], ninja: ['--version'], patchelf: ['--version'], - perl: ['--version'], + perl: ['-e', 'printf "%vd\\n", $^V'], 'pkg-config': ['--version'], python3: ['--version'], readelf: ['--version'], tar: ['--version'], }; - const tools = {}; + const toolVersions = {}; for (const tool of REQUIRED_TOOLS) { - tools[tool] = firstLine( + toolVersions[tool] = firstLine( context.runCapture(tool, versionArgs[tool] ?? ['--version']) ); } + return toolVersions; +} + +function verifySystemPkgConfigPackages(context) { const systemPkgConfigPackages = {}; for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { + context.run('pkg-config', ['--exists', packageName]); systemPkgConfigPackages[packageName] = context.runCapture( 'pkg-config', ['--modversion', packageName] ); } - - return { - platform: process.platform, - arch: process.arch, - release: os.release(), - systemPkgConfigDirs: [...context.systemPkgConfigDirs], - systemPkgConfigPackages, - tools, - }; -} - -function verifySystemPkgConfigPackages(context) { - for (const packageName of EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES) { - context.run('pkg-config', ['--exists', packageName]); - } + return systemPkgConfigPackages; } function writeManifest(context, sourceRecords, runtimeMetadata) { @@ -565,6 +714,7 @@ function writeManifest(context, sourceRecords, runtimeMetadata) { const manifest = createLinuxRuntimeManifest({ sourceRecords, runtimeFiles: runtimeMetadata.runtimeFiles, + abiRecords: runtimeMetadata.abiRecords, dependencyClosure: runtimeMetadata.dependencyClosure, buildHost: collectBuildHost(context), ffmpegConfigureFlags: context.ffmpegConfigureFlags, @@ -616,6 +766,7 @@ function createBuildContext(prefix, environment) { sourceRoot: path.join(buildRoot, 'sources'), parallelism: resolveParallelism(environment), systemPkgConfigDirs, + buildEnvironment, ffmpegConfigureFlags: null, }; } @@ -627,21 +778,56 @@ export function main({ cwd = process.cwd(), environment = process.env, } = {}) { - const { prefix } = parseCliInvocation({ platform, arch, argv, cwd }); + const { prefix: outputPrefix } = parseCliInvocation({ + platform, + arch, + argv, + cwd, + }); + assertUniqueMesonOptionAssignments(BUILD_RECIPES); ensureTools(); - const context = createBuildContext(prefix, environment); + assertOwnedOutputDestination(outputPrefix); + const stagingToken = `${process.pid}-${crypto + .randomBytes(8) + .toString('hex')}`; + const stagingPrefix = ownedStagingPrefixPath(outputPrefix, stagingToken); + const context = createBuildContext(stagingPrefix, environment); + assertSafeOutputPrefix(outputPrefix, context.buildRoot); + const toolVersions = collectToolVersions(context); + assertMinimumToolVersions(toolVersions); + context.toolVersions = toolVersions; + context.glibcVersion = detectBuildHostGlibcVersion(); + context.systemPkgConfigPackages = verifySystemPkgConfigPackages(context); fs.mkdirSync(context.buildRoot, { recursive: true }); - fs.rmSync(context.prefix, { recursive: true, force: true }); - fs.mkdirSync(context.prefix, { recursive: true }); - verifySystemPkgConfigPackages(context); - const sourceRecords = acquireSources(context); - buildRuntime(context); - removeNonRuntimeBuildOutputs(prefix); - const runtimeMetadata = postProcessRuntime(context); - const manifest = writeManifest(context, sourceRecords, runtimeMetadata); - log( - `Built ${manifest.runtimeFiles.length} LGPL-compatible runtime libraries (${manifest.runtimeTotalBytes} bytes) at ${prefix}` - ); + let stagingCreated = false; + try { + const createdStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: stagingToken, + }); + if (createdStagingPrefix !== stagingPrefix) { + throw new Error( + 'Linux runtime staging prefix changed unexpectedly.' + ); + } + stagingCreated = true; + const sourceRecords = acquireSources(context); + buildRuntime(context); + removeNonRuntimeBuildOutputs(context.prefix); + const runtimeMetadata = postProcessRuntime(context); + const manifest = writeManifest(context, sourceRecords, runtimeMetadata); + publishOwnedOutput({ + outputPrefix, + stagingPrefix, + }); + stagingCreated = false; + log( + `Built ${manifest.runtimeFiles.length} LGPL-compatible runtime libraries (${manifest.runtimeTotalBytes} bytes) at ${outputPrefix}` + ); + } finally { + if (stagingCreated) { + fs.rmSync(stagingPrefix, { recursive: true, force: true }); + } + } } const invokedScriptPath = process.argv[1] diff --git a/tools/embedded-mpv/build-linux-runtime.test.mjs b/tools/embedded-mpv/build-linux-runtime.test.mjs index 4842934cb..b97b73515 100644 --- a/tools/embedded-mpv/build-linux-runtime.test.mjs +++ b/tools/embedded-mpv/build-linux-runtime.test.mjs @@ -20,18 +20,32 @@ const { EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, FFMPEG_CONFIGURE_FLAGS, GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, MPV_MESON_FLAGS, + OUTPUT_OWNERSHIP_MARKER, + PORTABLE_ABI_BASELINE, REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, SOURCE_PACKAGES, assertArchiveMatchesPin, assertGitCommitMatchesPin, + assertMinimumToolVersions, + assertOwnedOutputDestination, + assertPortableAbiRecords, + assertPortableBuildHostGlibc, + assertUniqueMesonOptionAssignments, createBuildEnvironment, createLinuxRuntimeManifest, + createOwnedStagingPrefix, createRuntimeFileRecords, materializeLibrarySymlinks, parseCliInvocation, parseReadelfDynamic, + parseReadelfVersionInfo, + publishOwnedOutput, + retainRuntimeLibraries, resolveSystemPkgConfigDirs, + selectReachableRuntimeLibraryNames, validateRuntimeDependencyClosure, } = require('./build-linux-runtime.cjs'); const { @@ -77,6 +91,7 @@ test('pins the complete source stack and preserves dependency build order', () = { id: 'openssl', version: '3.5.7' }, { id: 'ffmpeg', version: '8.1' }, { id: 'libplacebo', version: '7.360.1' }, + { id: 'libdisplay-info', version: '0.1.1' }, { id: 'mpv', version: '0.41.0' }, ] ); @@ -114,6 +129,19 @@ test('pins the complete source stack and preserves dependency build order', () = byId.get('openssl').sourceUrl, 'https://github.com/openssl/openssl/releases/download/openssl-3.5.7/openssl-3.5.7.tar.gz' ); + assert.deepEqual(byId.get('libdisplay-info'), { + id: 'libdisplay-info', + version: '0.1.1', + sourceKind: 'archive', + sourceUrl: + 'https://gitlab.freedesktop.org/emersion/libdisplay-info/-/releases/0.1.1/downloads/libdisplay-info-0.1.1.tar.xz', + expectedSha256: + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', + license: 'MIT', + }); + assert.ok( + BUILD_ORDER.indexOf('libdisplay-info') < BUILD_ORDER.indexOf('mpv') + ); }); test('hardcodes the verified official archive digests and libplacebo commit', () => { @@ -129,6 +157,8 @@ test('hardcodes the verified official archive digests and libplacebo commit', () harfbuzz: '77e4f7f98f3d86bf8788b53e6832fb96279956e1c3961988ea3d4b7ca41ddc27', libass: 'eae425da50f0015c21f7b3a9c7262a910f0218af469e22e2931462fed3c50959', + 'libdisplay-info': + '0d8731588e9f82a9cac96324a3d7c82e2ba5b1b5e006143fefe692c74069fb60', mpv: 'ee21092a5ee427353392360929dc64645c54479aefdb5babc5cfbb5fad626209', openssl: 'a8c0d28a529ca480f9f36cf5792e2cd21984552a3c8e4aa11a24aa31aeac98e8', @@ -246,6 +276,12 @@ test('defines shared-only source recipes with font discovery before playback', ( '-Dtests=disabled', '-Dtools=disabled', '-Dcache-build=disabled', + '-Dxml-backend=expat', + '-Dbaseconfig-dir=/etc/fonts', + '-Dconfig-dir=/etc/fonts/conf.d', + '-Dtemplate-dir=/usr/share/fontconfig/conf.avail', + '-Dcache-dir=/var/cache/fontconfig', + '-Dxml-dir=/usr/share/xml/fontconfig', ]) { assert.ok(BUILD_RECIPES.fontconfig.args.includes(flag), flag); } @@ -254,18 +290,51 @@ test('defines shared-only source recipes with font discovery before playback', ( BUILD_RECIPES.libass.args.includes('--disable-fontconfig'), false ); - for (const flag of ['shared', 'no-apps', 'no-docs', 'no-tests']) { + for (const flag of [ + 'shared', + 'no-apps', + 'no-docs', + 'no-tests', + '--openssldir=/etc/ssl', + ]) { assert.ok(BUILD_RECIPES.openssl.args.includes(flag), flag); } assert.ok( BUILD_ORDER.indexOf('fontconfig') < BUILD_ORDER.indexOf('libass') ); assert.ok(BUILD_ORDER.indexOf('openssl') < BUILD_ORDER.indexOf('ffmpeg')); + assert.equal(BUILD_RECIPES['libdisplay-info'].buildSystem, 'meson'); for (const packageId of BUILD_ORDER) { assert.equal(BUILD_RECIPES[packageId].sharedOnly, true, packageId); } }); +test('rejects duplicate option assignments in every Meson recipe', () => { + assert.doesNotThrow(() => + assertUniqueMesonOptionAssignments(BUILD_RECIPES) + ); + + for (const [packageId, duplicateFlag] of [ + ['fontconfig', '-Dxml-backend=libxml2'], + ['libplacebo', '-Dvulkan=enabled'], + ]) { + const duplicateRecipes = { + ...BUILD_RECIPES, + [packageId]: { + ...BUILD_RECIPES[packageId], + args: [...BUILD_RECIPES[packageId].args, duplicateFlag], + }, + }; + assert.throws( + () => assertUniqueMesonOptionAssignments(duplicateRecipes), + new RegExp( + `${packageId}.*${duplicateFlag.split('=')[0]}.*once`, + 'i' + ) + ); + } +}); + test('constructs a prefix-only build environment and ignores hostile host flags', () => { const environment = createBuildEnvironment({ prefix: '/opt/runtime', @@ -330,6 +399,117 @@ test('constructs a prefix-only build environment and ignores hostile host flags' assert.doesNotMatch(JSON.stringify(environment), /\/host\//); }); +test('rejects an existing unmarked output without changing its contents', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-output-ownership-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const outputPrefix = path.join(root, 'usr', 'local'); + fs.mkdirSync(outputPrefix, { recursive: true }); + fs.writeFileSync(path.join(outputPrefix, 'keep-me'), 'untouched'); + + assert.throws( + () => assertOwnedOutputDestination(outputPrefix), + /existing output.*ownership marker/i + ); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'keep-me'), 'utf8'), + 'untouched' + ); +}); + +test('atomically publishes only owned outputs and rolls back failures', (t) => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-output-publish-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const outputPrefix = path.join(root, 'runtime'); + fs.mkdirSync(outputPrefix); + fs.writeFileSync( + path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER), + 'iptvnator-embedded-mpv-linux-runtime-v1\n' + ); + fs.writeFileSync(path.join(outputPrefix, 'state'), 'previous'); + + const failedStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: 'failed', + }); + fs.writeFileSync(path.join(failedStagingPrefix, 'state'), 'replacement'); + let renameCount = 0; + const failingFileSystem = { + ...fs, + renameSync(source, destination) { + renameCount += 1; + if (renameCount === 2) { + throw new Error('injected publication failure'); + } + return fs.renameSync(source, destination); + }, + }; + assert.throws( + () => + publishOwnedOutput({ + fileSystem: failingFileSystem, + outputPrefix, + stagingPrefix: failedStagingPrefix, + token: 'rollback', + }), + /injected publication failure/ + ); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'state'), 'utf8'), + 'previous' + ); + assert.equal(fs.existsSync(failedStagingPrefix), false); + assert.deepEqual( + fs.readdirSync(root).filter((name) => name.includes('backup')), + [] + ); + + const successfulStagingPrefix = createOwnedStagingPrefix(outputPrefix, { + token: 'successful', + }); + fs.writeFileSync( + path.join(successfulStagingPrefix, 'state'), + 'replacement' + ); + publishOwnedOutput({ + outputPrefix, + stagingPrefix: successfulStagingPrefix, + token: 'success', + }); + assert.equal( + fs.readFileSync(path.join(outputPrefix, 'state'), 'utf8'), + 'replacement' + ); + assert.equal( + fs.readFileSync( + path.join(outputPrefix, OUTPUT_OWNERSHIP_MARKER), + 'utf8' + ), + 'iptvnator-embedded-mpv-linux-runtime-v1\n' + ); +}); + +test('builds in an owned sibling before atomically publishing output', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.doesNotMatch( + builderSource, + /fs\.rmSync\(context\.prefix, \{ recursive: true, force: true \}\)/ + ); + const toolPreflight = builderSource.indexOf( + 'assertMinimumToolVersions(toolVersions)' + ); + const stagingMutation = builderSource.indexOf( + 'createOwnedStagingPrefix(outputPrefix' + ); + const publication = builderSource.indexOf('publishOwnedOutput({'); + assert.notEqual(stagingMutation, -1); + assert.notEqual(publication, -1); + assert.ok(toolPreflight < stagingMutation); + assert.ok(stagingMutation < publication); +}); + test('uses fixed Linux x64 pkg-config directories without host discovery', () => { assert.deepEqual(DEFAULT_SYSTEM_PKG_CONFIG_DIRS, [ '/usr/lib/x86_64-linux-gnu/pkgconfig', @@ -367,6 +547,7 @@ test('declares only the intended Linux system interface packages', () => { 'egl', 'gbm', 'gl', + 'libdrm', 'libpulse', 'libva', 'libva-drm', @@ -391,6 +572,7 @@ test('requires ELF patching and inspection tools in addition to the build toolch 'git', 'make', 'meson', + 'nasm', 'ninja', 'patchelf', 'pkg-config', @@ -401,6 +583,95 @@ test('requires ELF patching and inspection tools in addition to the build toolch } }); +test('preflights every required tool against a supported minimum version', () => { + assert.deepEqual( + Object.keys(MINIMUM_TOOL_VERSIONS).sort(), + [...REQUIRED_TOOLS].sort() + ); + assert.equal(MINIMUM_TOOL_VERSIONS.meson, '1.6.0'); + assert.equal(MINIMUM_TOOL_VERSIONS.nasm, '2.15.05'); + + const supportedVersions = Object.fromEntries( + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) + ); + assert.doesNotThrow(() => assertMinimumToolVersions(supportedVersions)); + + const missingTool = { ...supportedVersions }; + delete missingTool.nasm; + assert.throws( + () => assertMinimumToolVersions(missingTool), + /missing required tool version.*nasm/i + ); + + for (const [tool, oldVersion] of [ + ['meson', 'meson 1.5.9'], + ['nasm', 'NASM version 2.15.04'], + ]) { + assert.throws( + () => + assertMinimumToolVersions({ + ...supportedVersions, + [tool]: oldVersion, + }), + new RegExp(`${tool}.*requires.*${MINIMUM_TOOL_VERSIONS[tool]}`, 'i') + ); + } +}); + +test('completes tool and system-package preflight before filesystem mutation', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + const buildRootMutation = builderSource.indexOf( + 'fs.mkdirSync(context.buildRoot' + ); + const toolPreflight = builderSource.indexOf( + 'assertMinimumToolVersions(toolVersions)' + ); + const packagePreflight = builderSource.indexOf( + 'verifySystemPkgConfigPackages(context)' + ); + const mesonPolicyPreflight = builderSource.indexOf( + 'assertUniqueMesonOptionAssignments(BUILD_RECIPES)' + ); + assert.notEqual(mesonPolicyPreflight, -1); + assert.notEqual(toolPreflight, -1); + assert.notEqual(packagePreflight, -1); + assert.ok(mesonPolicyPreflight < buildRootMutation); + assert.ok(toolPreflight < buildRootMutation); + assert.ok(packagePreflight < buildRootMutation); +}); + +test('uses DESTDIR with standard fontconfig and OpenSSL runtime paths', () => { + assert.deepEqual(RUNTIME_EXTERNAL_CONFIGURATION, { + fontconfig: { + configDirectory: '/etc/fonts', + templateDirectory: '/usr/share/fontconfig', + cacheDirectory: '/var/cache/fontconfig', + ownership: 'system', + }, + openssl: { + configFile: '/etc/ssl/openssl.cnf', + certificateFile: '/etc/ssl/cert.pem', + certificateDirectory: '/etc/ssl/certs', + ownership: 'system', + }, + }); + assert.doesNotMatch( + JSON.stringify(RUNTIME_EXTERNAL_CONFIGURATION), + /build|prefix|tmp/i + ); + + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match(builderSource, /DESTDIR/); + assert.match(builderSource, /installWithDestdir/); + assert.doesNotMatch( + builderSource, + /--openssldir=\$\{path\.join\(context\.prefix/ + ); +}); + test('uses an explicit LGPL FFmpeg HTTPS and HLS protocol baseline', () => { const required = [ '--enable-shared', @@ -573,6 +844,7 @@ test('uses valid mpv v0.41 Meson option names and pins the Linux backends', () = '-Dvulkan=disabled', '-Dshaderc=disabled', '-Dspirv-cross=disabled', + '-Ddrm=enabled', '-Dgl=enabled', '-Dplain-gl=enabled', '-Degl=enabled', @@ -584,6 +856,13 @@ test('uses valid mpv v0.41 Meson option names and pins the Linux backends', () = ]) { assert.ok(MPV_MESON_FLAGS.includes(required), `missing ${required}`); } + assert.equal(MPV_MESON_FLAGS.includes('-Ddrm=disabled'), false); + for (const optionName of ['drm', 'gbm', 'vaapi-drm']) { + const assignments = MPV_MESON_FLAGS.filter((flag) => + flag.startsWith(`-D${optionName}=`) + ); + assert.deepEqual(assignments, [`-D${optionName}=enabled`]); + } }); test('keeps the external dependency allowlists explicit and deterministic', () => { @@ -620,6 +899,7 @@ test('keeps the external dependency allowlists explicit and deterministic', () = test('parses readelf dynamic sections and validates an ORIGIN-only closure', () => { const mpvDynamic = parseReadelfDynamic(` + 0x000000000000000e (SONAME) Library soname: [libmpv.so.2] 0x0000000000000001 (NEEDED) Shared library: [libavcodec.so.62] 0x0000000000000001 (NEEDED) Shared library: [libEGL.so.1] 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] @@ -629,6 +909,7 @@ test('parses readelf dynamic sections and validates an ORIGIN-only closure', () needed: ['libEGL.so.1', 'libavcodec.so.62', 'libc.so.6'], rpath: [], runpath: ['$ORIGIN'], + soname: 'libmpv.so.2', }); const closure = validateRuntimeDependencyClosure({ @@ -655,6 +936,50 @@ test('parses readelf dynamic sections and validates an ORIGIN-only closure', () 'libavcodec.so.62', 'libc.so.6', ]); + assert.equal(closure.entries[1].soname, 'libmpv.so.2'); + + const aliasClosure = validateRuntimeDependencyClosure({ + entries: [ + { + name: 'libmpv.so', + ...mpvDynamic, + }, + { + name: 'libmpv.so.2', + ...mpvDynamic, + }, + { + name: 'libavcodec.so.62', + needed: ['libm.so.6'], + rpath: [], + runpath: ['$ORIGIN'], + soname: 'libavcodec.so.62', + }, + ], + runtimeFileNames: ['libavcodec.so.62', 'libmpv.so', 'libmpv.so.2'], + buildPrefix: '/tmp/iptvnator-prefix', + }); + assert.equal( + aliasClosure.entries.find(({ name }) => name === 'libmpv.so').soname, + 'libmpv.so.2' + ); + assert.throws( + () => + validateRuntimeDependencyClosure({ + entries: aliasClosure.entries.map((entry) => + entry.name === 'libmpv.so' + ? { ...entry, soname: 'libmpv.so.99' } + : entry + ), + runtimeFileNames: [ + 'libavcodec.so.62', + 'libmpv.so', + 'libmpv.so.2', + ], + buildPrefix: '/tmp/iptvnator-prefix', + }), + /libmpv\.so must declare a versioned SONAME present in the runtime closure/ + ); assert.throws( () => @@ -696,6 +1021,140 @@ test('parses readelf dynamic sections and validates an ORIGIN-only closure', () } }); +test('retains only the reachable SONAME closure plus the libmpv linker alias', (t) => { + const entries = [ + { + name: 'libmpv.so', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libmpv.so.2', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libmpv.so.2.0.0', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libc.so.6'], + }, + { + name: 'libavcodec.so', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libavcodec.so.62', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libavcodec.so.62.1.0', + soname: 'libavcodec.so.62', + needed: ['libm.so.6'], + }, + { + name: 'libunused.so.1', + soname: 'libunused.so.1', + needed: ['libc.so.6'], + }, + ]; + const retainedNames = selectReachableRuntimeLibraryNames(entries); + assert.deepEqual(retainedNames, [ + 'libavcodec.so.62', + 'libmpv.so', + 'libmpv.so.2', + ]); + + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-runtime-prune-') + ); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + const libDir = path.join(root, 'lib'); + fs.mkdirSync(libDir); + fs.writeFileSync(path.join(libDir, 'libmpv.so.2.0.0'), 'mpv'); + fs.symlinkSync('libmpv.so.2.0.0', path.join(libDir, 'libmpv.so.2')); + fs.symlinkSync('libmpv.so.2', path.join(libDir, 'libmpv.so')); + fs.writeFileSync(path.join(libDir, 'libavcodec.so.62.1.0'), 'codec'); + fs.symlinkSync( + 'libavcodec.so.62.1.0', + path.join(libDir, 'libavcodec.so.62') + ); + fs.symlinkSync('libavcodec.so.62', path.join(libDir, 'libavcodec.so')); + fs.writeFileSync(path.join(libDir, 'libunused.so.1'), 'unused'); + + retainRuntimeLibraries(libDir, retainedNames); + assert.deepEqual(fs.readdirSync(libDir).sort(), retainedNames); + for (const retainedName of retainedNames) { + assert.equal( + fs.lstatSync(path.join(libDir, retainedName)).isFile(), + true, + retainedName + ); + } +}); + +test('enforces the Ubuntu 22.04 GLIBC and GLIBCXX symbol ceilings', () => { + assert.deepEqual(PORTABLE_ABI_BASELINE, { + distribution: 'Ubuntu 22.04', + glibcMaximum: '2.35', + glibcxxMaximum: '3.4.30', + }); + const record = parseReadelfVersionInfo( + ` + 0x0010: Name: GLIBC_2.2.5 Flags: none Version: 7 + 0x0020: Name: GLIBC_2.34 Flags: none Version: 5 + 0x0030: Name: GLIBCXX_3.4.21 Flags: none Version: 4 + 0x0040: Name: GLIBCXX_3.4.29 Flags: none Version: 3 +`, + 'libmpv.so.2' + ); + assert.deepEqual(record, { + name: 'libmpv.so.2', + requiredGlibc: '2.34', + requiredGlibcxx: '3.4.29', + }); + assert.doesNotThrow(() => assertPortableAbiRecords([record])); + + for (const [field, version] of [ + ['requiredGlibc', '2.36'], + ['requiredGlibcxx', '3.4.31'], + ]) { + assert.throws( + () => + assertPortableAbiRecords([ + { + ...record, + [field]: version, + }, + ]), + /portable ABI baseline.*newer symbol/i + ); + } +}); + +test('rejects build hosts newer than the portable glibc baseline', () => { + assert.doesNotThrow(() => assertPortableBuildHostGlibc('2.35')); + assert.throws( + () => assertPortableBuildHostGlibc('2.36'), + /build host glibc 2\.36.*portable ABI baseline.*2\.35/i + ); + assert.throws( + () => assertPortableBuildHostGlibc(undefined), + /unable to determine the Linux build host glibc version/i + ); +}); + +test('inspects every retained runtime file for portable symbol versions', () => { + const builderSource = fs.readFileSync(builderScript, 'utf8'); + assert.match( + builderSource, + /runCapture\('readelf', \[\s*'--version-info',\s*libraryPath,\s*\]\)/ + ); + assert.match(builderSource, /retainRuntimeLibraries\(libDir,/); + assert.match(builderSource, /assertPortableAbiRecords\(abiRecords\)/); +}); + test('materializes symlink aliases and hashes every runtime library', (t) => { const root = fs.mkdtempSync( path.join(os.tmpdir(), 'iptvnator-linux-builder-') @@ -742,22 +1201,38 @@ test('generates a hash-complete manifest accepted by the Linux validator', (t) = const libDir = path.join(root, 'lib'); fs.mkdirSync(libDir); fs.writeFileSync(path.join(libDir, 'libavcodec.so.62'), 'avcodec'); + fs.writeFileSync(path.join(libDir, 'libmpv.so'), 'mpv'); fs.writeFileSync(path.join(libDir, 'libmpv.so.2'), 'mpv'); const runtimeFiles = createRuntimeFileRecords(libDir); const sourceRecords = createPinnedSourceRecords(); + const abiRecords = runtimeFiles.map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })); const manifest = createLinuxRuntimeManifest({ sourceRecords, runtimeFiles, + abiRecords, dependencyClosure: { entries: [ { name: 'libavcodec.so.62', + soname: 'libavcodec.so.62', needed: ['libm.so.6'], rpath: [], runpath: ['$ORIGIN'], }, + { + name: 'libmpv.so', + soname: 'libmpv.so.2', + needed: ['libavcodec.so.62', 'libEGL.so.1'], + rpath: [], + runpath: ['$ORIGIN'], + }, { name: 'libmpv.so.2', + soname: 'libmpv.so.2', needed: ['libavcodec.so.62', 'libEGL.so.1'], rpath: [], runpath: ['$ORIGIN'], @@ -769,6 +1244,7 @@ test('generates a hash-complete manifest accepted by the Linux validator', (t) = platform: 'linux', arch: 'x64', release: 'fixture-kernel', + glibcVersion: '2.35', systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], systemPkgConfigPackages: Object.fromEntries( EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((packageName) => [ @@ -777,7 +1253,10 @@ test('generates a hash-complete manifest accepted by the Linux validator', (t) = ]) ), tools: Object.fromEntries( - REQUIRED_TOOLS.map((tool) => [tool, `${tool} fixture version`]) + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) ), }, generatedAt: '2026-07-17T00:00:00.000Z', @@ -792,10 +1271,25 @@ test('generates a hash-complete manifest accepted by the Linux validator', (t) = 'libEGL.so.1', 'libm.so.6', ]); + assert.equal( + manifest.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname, + 'libmpv.so.2' + ); assert.deepEqual( manifest.externalSystemLibraries, EXTERNAL_SYSTEM_LIBRARIES ); + assert.deepEqual(manifest.runtimeAbi, { + baseline: PORTABLE_ABI_BASELINE, + files: abiRecords, + }); + assert.deepEqual( + manifest.runtimeExternalConfiguration, + RUNTIME_EXTERNAL_CONFIGURATION + ); + assert.equal(manifest.buildHost.glibcVersion, '2.35'); assert.deepEqual( Object.keys(manifest.buildHost.systemPkgConfigPackages), EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES @@ -814,6 +1308,7 @@ test('generates a hash-complete manifest accepted by the Linux validator', (t) = ); assert.doesNotMatch(manifest.sourceDistribution, /TBD|TODO/i); assert.match(manifest.sourceDistribution, /source archives/i); + assert.match(manifest.sourceDistribution, /libdisplay-info/i); }); test('guards the CLI to Linux x64 and requires exactly one output prefix', () => { @@ -883,7 +1378,7 @@ test('patches every materialized ELF runtime to ORIGIN before validating closure builderSource, /runCapture\('readelf', \['-d', libraryPath\]\)/ ); - assert.match(builderSource, /materializeLibrarySymlinks\(libDir\)/); + assert.match(builderSource, /retainRuntimeLibraries\(libDir,/); assert.match(builderSource, /validateRuntimeDependencyClosure\(\{/); assert.match(builderSource, /createRuntimeFileRecords\(libDir\)/); assert.match(builderSource, /runtime-manifest\.json/); diff --git a/tools/embedded-mpv/linux-runtime-manifest.cjs b/tools/embedded-mpv/linux-runtime-manifest.cjs index 0ab15b807..db967e823 100644 --- a/tools/embedded-mpv/linux-runtime-manifest.cjs +++ b/tools/embedded-mpv/linux-runtime-manifest.cjs @@ -1,12 +1,18 @@ 'use strict'; const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); const { EXTERNAL_SYSTEM_LIBRARIES, EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + PORTABLE_ABI_BASELINE, REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, SOURCE_PACKAGES, + compareVersions, + parseVersion, } = require('./build-linux-runtime.cjs'); const LINUX_RUNTIME_MANIFEST_SCHEMA_VERSION = 1; @@ -303,19 +309,31 @@ function validateMpv(errors, mpv) { }); if (Array.isArray(mpv.mesonFlags)) { - for (const [option, requiredFlag] of [ - ['-Dgpl', '-Dgpl=false'], - ['-Dlibmpv', '-Dlibmpv=true'], - ]) { - const assignments = mpv.mesonFlags.filter( - (flag) => - typeof flag === 'string' && flag.startsWith(`${option}=`) - ); + const assignmentsByOption = new Map(); + for (const flag of mpv.mesonFlags) { + const match = + typeof flag === 'string' ? flag.match(/^(-D[^=]+)=/) : null; + if (!match) { + continue; + } + const option = match[1]; + const assignments = assignmentsByOption.get(option) ?? []; + assignments.push(flag); + assignmentsByOption.set(option, assignments); + } + for (const [option, assignments] of assignmentsByOption) { if (assignments.length > 1) { errors.push( `Linux runtime manifest mpv.mesonFlags must assign "${option}" exactly once.` ); } + } + + for (const [option, requiredFlag] of [ + ['-Dgpl', '-Dgpl=false'], + ['-Dlibmpv', '-Dlibmpv=true'], + ]) { + const assignments = assignmentsByOption.get(option) ?? []; for (const flag of assignments) { if (flag !== requiredFlag) { errors.push( @@ -336,6 +354,7 @@ function validateRuntimeFiles(errors, runtimeFiles) { } const names = new Set(); + let hasLibMpvLinkerAlias = false; let hasVersionedLibMpv = false; for (const [index, runtimeFile] of runtimeFiles.entries()) { @@ -380,6 +399,9 @@ function validateRuntimeFiles(errors, runtimeFiles) { } else { names.add(name); } + if (name === 'libmpv.so') { + hasLibMpvLinkerAlias = true; + } if (VERSIONED_LIBMPV_PATTERN.test(name)) { hasVersionedLibMpv = true; } @@ -391,6 +413,11 @@ function validateRuntimeFiles(errors, runtimeFiles) { 'Linux runtime manifest runtimeFiles must include a versioned libmpv.so.N entry.' ); } + if (!hasLibMpvLinkerAlias) { + errors.push( + 'Linux runtime manifest runtimeFiles must include the libmpv.so linker alias.' + ); + } } function validateRuntimeTotalBytes(errors, runtimeFiles, runtimeTotalBytes) { @@ -416,6 +443,110 @@ function validateRuntimeTotalBytes(errors, runtimeFiles, runtimeTotalBytes) { } } +function validateRuntimeAbi(errors, runtimeAbi, runtimeFiles) { + if (!isObject(runtimeAbi)) { + errors.push('Linux runtime manifest runtimeAbi must be an object.'); + return; + } + if ( + !isObject(runtimeAbi.baseline) || + !isDeepStrictEqual(runtimeAbi.baseline, PORTABLE_ABI_BASELINE) + ) { + errors.push( + 'Linux runtime manifest runtimeAbi.baseline must exactly match the portable ABI baseline.' + ); + } + + const runtimeNames = Array.isArray(runtimeFiles) + ? runtimeFiles + .filter((runtimeFile) => isObject(runtimeFile)) + .map(({ name }) => name) + .filter((name) => typeof name === 'string') + : []; + const runtimeNameSet = new Set(runtimeNames); + if (!Array.isArray(runtimeAbi.files)) { + errors.push( + 'Linux runtime manifest runtimeAbi.files must contain one record for every runtime file.' + ); + return; + } + + const abiNames = new Set(); + for (const [index, record] of runtimeAbi.files.entries()) { + const label = `runtimeAbi.files[${index}]`; + if (!isObject(record)) { + errors.push(`Linux runtime manifest ${label} must be an object.`); + continue; + } + if ( + !isSafeBasename(record.name) || + !SHARED_LIBRARY_PATTERN.test(record.name) + ) { + errors.push( + `Linux runtime manifest ${label}.name must be a safe shared-library basename.` + ); + } + if (abiNames.has(record.name)) { + errors.push( + `Linux runtime manifest runtimeAbi.files contains duplicate name "${String( + record.name + )}".` + ); + } else if (typeof record.name === 'string') { + abiNames.add(record.name); + } + + for (const [field, maximum] of [ + ['requiredGlibc', PORTABLE_ABI_BASELINE.glibcMaximum], + ['requiredGlibcxx', PORTABLE_ABI_BASELINE.glibcxxMaximum], + ]) { + const version = record[field]; + if ( + version !== null && + (typeof version !== 'string' || + !/^\d+(?:\.\d+)+$/.test(version)) + ) { + errors.push( + `Linux runtime manifest ${label}.${field} must be null or a dotted numeric symbol version.` + ); + continue; + } + if (version && compareVersions(version, maximum) > 0) { + errors.push( + `Linux runtime manifest ${label}.${field} must not exceed portable ABI maximum ${maximum}.` + ); + } + } + } + + if ( + runtimeAbi.files.length !== runtimeNames.length || + runtimeNames.some((name) => !abiNames.has(name)) || + [...abiNames].some((name) => !runtimeNameSet.has(name)) + ) { + errors.push( + 'Linux runtime manifest runtimeAbi.files must contain one record for every runtime file.' + ); + } +} + +function validateRuntimeExternalConfiguration( + errors, + runtimeExternalConfiguration +) { + if ( + !isObject(runtimeExternalConfiguration) || + !isDeepStrictEqual( + runtimeExternalConfiguration, + RUNTIME_EXTERNAL_CONFIGURATION + ) + ) { + errors.push( + 'Linux runtime manifest runtimeExternalConfiguration must exactly match the system-owned runtime paths.' + ); + } +} + function validateRuntimeDependencyClosure( errors, runtimeDependencyClosure, @@ -471,6 +602,16 @@ function validateRuntimeDependencyClosure( entryNames.add(entry.name); } + if ( + entry.soname !== null && + (!isSafeBasename(entry.soname) || + !SHARED_LIBRARY_PATTERN.test(entry.soname)) + ) { + errors.push( + `Linux runtime manifest ${label}.soname must be null or a safe shared-library basename.` + ); + } + if ( !Array.isArray(entry.needed) || entry.needed.some( @@ -531,6 +672,20 @@ function validateRuntimeDependencyClosure( ); } + const libMpvLinkerEntry = entries.find( + (entry) => isObject(entry) && entry.name === 'libmpv.so' + ); + if ( + !libMpvLinkerEntry || + typeof libMpvLinkerEntry.soname !== 'string' || + !VERSIONED_LIBMPV_PATTERN.test(libMpvLinkerEntry.soname) || + !runtimeNameSet.has(libMpvLinkerEntry.soname) + ) { + errors.push( + 'Linux runtime manifest libmpv.so must declare a versioned SONAME present in runtimeFiles.' + ); + } + const expectedExternalDependencies = [ ...computedExternalDependencies, ].sort(); @@ -598,6 +753,23 @@ function validateBuildHost(errors, buildHost) { 'Linux runtime manifest buildHost.release must be a non-empty string.' ); } + if ( + typeof buildHost.glibcVersion !== 'string' || + !/^\d+(?:\.\d+)+$/.test(buildHost.glibcVersion) + ) { + errors.push( + 'Linux runtime manifest buildHost.glibcVersion must be a dotted numeric version.' + ); + } else if ( + compareVersions( + buildHost.glibcVersion, + PORTABLE_ABI_BASELINE.glibcMaximum + ) > 0 + ) { + errors.push( + `Linux runtime manifest buildHost.glibcVersion ${buildHost.glibcVersion} exceeds portable ABI baseline maximum ${PORTABLE_ABI_BASELINE.glibcMaximum}.` + ); + } if ( !Array.isArray(buildHost.systemPkgConfigDirs) || @@ -648,10 +820,24 @@ function validateBuildHost(errors, buildHost) { return; } for (const tool of REQUIRED_TOOLS) { - if (!isNonEmptyString(buildHost.tools[tool])) { + const declaredVersion = buildHost.tools[tool]; + if (!isNonEmptyString(declaredVersion)) { errors.push( `Linux runtime manifest buildHost.tools.${tool} must be a non-empty version string.` ); + continue; + } + const actualVersion = parseVersion(declaredVersion); + if (!actualVersion) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} must contain a parseable dotted numeric version.` + ); + } else if ( + compareVersions(actualVersion, MINIMUM_TOOL_VERSIONS[tool]) < 0 + ) { + errors.push( + `Linux runtime manifest buildHost.tools.${tool} ${actualVersion} is unsupported; requires ${MINIMUM_TOOL_VERSIONS[tool]} or newer.` + ); } } for (const tool of Object.keys(buildHost.tools).sort()) { @@ -694,6 +880,10 @@ function validateLinuxRuntimeManifest(manifest) { errors.push( 'Linux runtime manifest sourceDistribution must be a non-empty string.' ); + } else if (!/\blibdisplay-info\b/i.test(manifest.sourceDistribution)) { + errors.push( + 'Linux runtime manifest sourceDistribution must explicitly include libdisplay-info.' + ); } validateRuntimeFiles(errors, manifest.runtimeFiles); @@ -702,6 +892,11 @@ function validateLinuxRuntimeManifest(manifest) { manifest.runtimeFiles, manifest.runtimeTotalBytes ); + validateRuntimeAbi(errors, manifest.runtimeAbi, manifest.runtimeFiles); + validateRuntimeExternalConfiguration( + errors, + manifest.runtimeExternalConfiguration + ); validateRuntimeDependencyClosure( errors, manifest.runtimeDependencyClosure, diff --git a/tools/embedded-mpv/linux-runtime-manifest.test.mjs b/tools/embedded-mpv/linux-runtime-manifest.test.mjs index d6b2fd552..4071bbd47 100644 --- a/tools/embedded-mpv/linux-runtime-manifest.test.mjs +++ b/tools/embedded-mpv/linux-runtime-manifest.test.mjs @@ -19,7 +19,10 @@ const { EXTERNAL_SYSTEM_LIBRARIES, EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES, GLIBC_TOOLCHAIN_ALLOWLIST, + MINIMUM_TOOL_VERSIONS, + PORTABLE_ABI_BASELINE, REQUIRED_TOOLS, + RUNTIME_EXTERNAL_CONFIGURATION, SOURCE_PACKAGES, } = require('./build-linux-runtime.cjs'); @@ -65,6 +68,7 @@ function createValidManifest( runtimeFiles = [ runtimeFile('libmpv.so.2', 'libmpv-runtime'), runtimeFile('libavcodec.so.61', 'libavcodec-runtime'), + runtimeFile('libmpv.so', 'libmpv-runtime'), ] ) { const packages = Object.fromEntries( @@ -85,6 +89,9 @@ function createValidManifest( ) .map((runtimeEntry) => ({ name: runtimeEntry.name, + soname: runtimeEntry.name.startsWith('libmpv.so') + ? 'libmpv.so.2' + : runtimeEntry.name, needed: runtimeEntry.name.startsWith('libmpv.so') ? [ ...(runtimeNames.has('libavcodec.so.61') @@ -124,12 +131,28 @@ function createValidManifest( mesonFlags: ['-Dlibmpv=true', '-Dgpl=false'], }, sourceDistribution: - 'https://downloads.example.test/iptvnator/linux-runtime-sources.tar.zst', + 'Publish the exact source archives, including the pinned MIT-licensed libdisplay-info source archive.', runtimeFiles, runtimeTotalBytes: runtimeFiles.reduce( (total, runtimeEntry) => total + (runtimeEntry?.size ?? 0), 0 ), + runtimeAbi: { + baseline: { ...PORTABLE_ABI_BASELINE }, + files: runtimeFiles + .filter( + (runtimeEntry) => + runtimeEntry && typeof runtimeEntry.name === 'string' + ) + .map(({ name }) => ({ + name, + requiredGlibc: '2.34', + requiredGlibcxx: null, + })), + }, + runtimeExternalConfiguration: structuredClone( + RUNTIME_EXTERNAL_CONFIGURATION + ), runtimeDependencyClosure: { entries: closureEntries, externalDependencies, @@ -141,6 +164,7 @@ function createValidManifest( platform: 'linux', arch: 'x64', release: 'fixture-kernel', + glibcVersion: '2.35', systemPkgConfigDirs: [...DEFAULT_SYSTEM_PKG_CONFIG_DIRS], systemPkgConfigPackages: Object.fromEntries( EXPECTED_SYSTEM_PKG_CONFIG_PACKAGES.map((packageName) => [ @@ -149,7 +173,10 @@ function createValidManifest( ]) ), tools: Object.fromEntries( - REQUIRED_TOOLS.map((tool) => [tool, `${tool} fixture version`]) + REQUIRED_TOOLS.map((tool) => [ + tool, + `${tool} ${MINIMUM_TOOL_VERSIONS[tool]}`, + ]) ), }, }; @@ -186,6 +213,7 @@ function createFixture(t, options = {}) { const contentsByName = new Map([ ['libmpv.so.2', 'libmpv-runtime'], ['libavcodec.so.61', 'libavcodec-runtime'], + ['libmpv.so', 'libmpv-runtime'], ]); for (const [name, contents] of contentsByName) { @@ -377,6 +405,54 @@ test('requires a complete ORIGIN-only runtime dependency closure', () => { ); }); +test('requires safe SONAME metadata and a bundled versioned libmpv target', () => { + const missingLinkerAlias = createValidManifest(); + const aliasFile = missingLinkerAlias.runtimeFiles.find( + ({ name }) => name === 'libmpv.so' + ); + missingLinkerAlias.runtimeFiles = missingLinkerAlias.runtimeFiles.filter( + ({ name }) => name !== 'libmpv.so' + ); + missingLinkerAlias.runtimeTotalBytes -= aliasFile.size; + missingLinkerAlias.runtimeAbi.files = + missingLinkerAlias.runtimeAbi.files.filter( + ({ name }) => name !== 'libmpv.so' + ); + missingLinkerAlias.runtimeDependencyClosure.entries = + missingLinkerAlias.runtimeDependencyClosure.entries.filter( + ({ name }) => name !== 'libmpv.so' + ); + assert.match( + validateLinuxRuntimeManifest(missingLinkerAlias).join('\n'), + /runtimeFiles must include the libmpv\.so linker alias/ + ); + + const missingLibmpvSoname = createValidManifest(); + missingLibmpvSoname.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname = null; + assert.match( + validateLinuxRuntimeManifest(missingLibmpvSoname).join('\n'), + /libmpv\.so must declare a versioned SONAME present in runtimeFiles/ + ); + + const unsafeSoname = createValidManifest(); + unsafeSoname.runtimeDependencyClosure.entries[0].soname = '../libmpv.so.2'; + assert.match( + validateLinuxRuntimeManifest(unsafeSoname).join('\n'), + /runtimeDependencyClosure\.entries\[0\]\.soname must be null or a safe shared-library basename/ + ); + + const absentSonameTarget = createValidManifest(); + absentSonameTarget.runtimeDependencyClosure.entries.find( + ({ name }) => name === 'libmpv.so' + ).soname = 'libmpv.so.99'; + assert.match( + validateLinuxRuntimeManifest(absentSonameTarget).join('\n'), + /libmpv\.so must declare a versioned SONAME present in runtimeFiles/ + ); +}); + test('requires the deterministic external-system-library records', () => { const manifest = createValidManifest(); manifest.externalSystemLibraries.reverse(); @@ -453,6 +529,68 @@ test('requires a Linux x64 build host and every required tool version', () => { validateLinuxRuntimeManifest(unexpectedPackage).join('\n'), /buildHost\.systemPkgConfigPackages contains unexpected package "unexpected"/ ); + + const unsupportedGlibc = createValidManifest(); + unsupportedGlibc.buildHost.glibcVersion = '2.36'; + assert.match( + validateLinuxRuntimeManifest(unsupportedGlibc).join('\n'), + /buildHost\.glibcVersion.*portable ABI baseline.*2\.35/i + ); + + const unsupportedMeson = createValidManifest(); + unsupportedMeson.buildHost.tools.meson = 'meson 1.5.9'; + assert.match( + validateLinuxRuntimeManifest(unsupportedMeson).join('\n'), + /buildHost\.tools\.meson.*requires 1\.6\.0 or newer/i + ); +}); + +test('requires exact portable ABI and external configuration records', () => { + const missingAbiFile = createValidManifest(); + missingAbiFile.runtimeAbi.files.pop(); + assert.match( + validateLinuxRuntimeManifest(missingAbiFile).join('\n'), + /runtimeAbi\.files must contain one record for every runtime file/ + ); + + const newerGlibcSymbol = createValidManifest(); + newerGlibcSymbol.runtimeAbi.files[0].requiredGlibc = '2.36'; + assert.match( + validateLinuxRuntimeManifest(newerGlibcSymbol).join('\n'), + /runtimeAbi\.files\[0\]\.requiredGlibc.*maximum 2\.35/ + ); + + const newerGlibcxxSymbol = createValidManifest(); + newerGlibcxxSymbol.runtimeAbi.files[0].requiredGlibcxx = '3.4.31'; + assert.match( + validateLinuxRuntimeManifest(newerGlibcxxSymbol).join('\n'), + /runtimeAbi\.files\[0\]\.requiredGlibcxx.*maximum 3\.4\.30/ + ); + + const wrongBaseline = createValidManifest(); + wrongBaseline.runtimeAbi.baseline.distribution = 'current host'; + assert.match( + validateLinuxRuntimeManifest(wrongBaseline).join('\n'), + /runtimeAbi\.baseline must exactly match the portable ABI baseline/ + ); + + const buildPathConfiguration = createValidManifest(); + buildPathConfiguration.runtimeExternalConfiguration.fontconfig.configDirectory = + '/tmp/build-prefix/etc/fonts'; + assert.match( + validateLinuxRuntimeManifest(buildPathConfiguration).join('\n'), + /runtimeExternalConfiguration must exactly match the system-owned runtime paths/ + ); +}); + +test('requires the source-distribution statement to name libdisplay-info', () => { + const manifest = createValidManifest(); + manifest.sourceDistribution = + 'Publish all of the other source archives with the binary release.'; + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /sourceDistribution must explicitly include libdisplay-info/ + ); }); test('accepts and stages the current Linux CI system build-input manifest', (t) => { @@ -690,6 +828,15 @@ test('rejects duplicate or contradictory required mpv Meson assignments', () => } }); +test('rejects duplicate assignments for every mpv Meson option', () => { + const manifest = createValidManifest(); + manifest.mpv.mesonFlags.push('-Ddrm=enabled', '-Ddrm=enabled'); + assert.match( + validateLinuxRuntimeManifest(manifest).join('\n'), + /mpv\.mesonFlags must assign "-Ddrm" exactly once/ + ); +}); + test('validates safe, unique shared-library metadata', () => { const manifest = createValidManifest([ { @@ -729,32 +876,20 @@ test('rejects control characters in shared-library basenames', () => { assert.match( validateLinuxRuntimeManifest(manifest).join('\n'), - /runtimeFiles\[2\]\.name must be a safe shared-library basename/ + /runtimeFiles\[3\]\.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.rmSync(path.join(fixture.libDir, 'libmpv.so')); 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) - ); - fixture.manifest.runtimeTotalBytes += versionedMpvContents.length; - fixture.manifest.runtimeDependencyClosure.entries.push({ - name: 'libmpv.so', - needed: ['libmpv.so.2'], - rpath: [], - runpath: ['$ORIGIN'], - }); fs.writeFileSync( path.join(fixture.prefix, 'runtime-manifest.json'), `${JSON.stringify(fixture.manifest, null, 2)}\n` @@ -809,6 +944,8 @@ test('stages only declared Linux libraries and materializes source symlinks', (t for (const field of [ 'packages', 'runtimeTotalBytes', + 'runtimeAbi', + 'runtimeExternalConfiguration', 'runtimeDependencyClosure', 'externalSystemLibraries', 'buildHost',