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