fix(embedded-mpv): make Linux runtime portable

This commit is contained in:
4gray committed 2026-07-17 22:42:36 +02:00
1 parent 84ff8de04a
commit 904fc6d3ec
5 files changed
+1584 -69

No files matched your search

+507 -5
View File
@@ -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,
};
+222 -36
View File
@@ -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]
+498 -3
View File
@@ -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/);
+204 -9
View File
@@ -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,
@@ -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',