Files
iptvnator/tools/packaging/prepare-linux-runtime-source-snapshot.cjs
4gray 8fdac824fd feat(packaging): ship Linux embedded MPV frame-copy runtime (#1200)
* docs: design Linux frame-copy packaging

* docs: plan Linux frame-copy packaging

* feat(packaging): define Linux frame-copy profiles

* fix(packaging): reject inherited profile names

* feat(embedded-mpv): validate staged Linux runtime

* fix(embedded-mpv): require Linux source packages

* fix(embedded-mpv): harden Linux runtime staging

* feat(embedded-mpv): build LGPL Linux runtime

* fix(embedded-mpv): pin Linux runtime inputs

* feat(embedded-mpv): build relocatable Linux helper

* fix(embedded-mpv): require bundled Linux runtime

* fix(embedded-mpv): make Linux runtime portable

* feat(packaging): ship Linux frame-copy artifacts

* fix(embedded-mpv): verify Linux helper linkage

* fix(packaging): enforce Linux frame-copy isolation

* fix(embedded-mpv): pin Linux display data

* docs(embedded-mpv): document Linux frame-copy packaging

* feat(embedded-mpv): probe Linux frame-copy runtime

* test(embedded-mpv): smoke packaged Linux frame-copy

* docs(embedded-mpv): clarify Linux system runtime baseline

* fix(embedded-mpv): harden Linux runtime capability gate

* ci: verify Linux frame-copy packages

* test(embedded-mpv): harden packaged Linux smoke

* test(embedded-mpv): preserve packaged GL mode

* test(packaging): harden Linux package probes

* fix(embedded-mpv): enable private Snap shared memory

* fix(embedded-mpv): sanitize Linux helper environment

* fix(packaging): enforce private Snap memory semantics

* fix(packaging): reject ambiguous Snap memory metadata

* fix(embedded-mpv): prioritize trusted Snap GL

* fix(packaging): reject advanced Snap YAML semantics

* fix(packaging): reject arbitrary Snap YAML aliases

* feat(packaging): ship Linux runtime license notices

* docs(embedded-mpv): document Linux runtime distribution

* fix(packaging): parse Snap trailing comments safely

* fix(release): gate Snap publish on public source release

* fix(packaging): strip VCS metadata from source bundle

* docs(packaging): clarify Linux source release gate

* test(embedded-mpv): smoke missing bundled libmpv

* style(embedded-mpv): format final validation inputs

* fix(e2e): satisfy fixture index signature typing

* fix(ci): declare fontconfig gperf generator

* fix(embedded-mpv): hash runtime cache identities

* fix(packaging): harden Linux frame-copy delivery

* fix(packaging): tighten runtime delivery gates

* fix(ci): decouple Linux runtime matrix

* fix(packaging): harden Linux frame-copy delivery

* fix(packaging): validate Linux frame-copy runtimes

* fix(packaging): scope Snap Electron library checks

* feat(packaging): ship Linux frame-copy runtimes

* fix(packaging): improve Linux runtime smoke diagnostics

* fix(packaging): expose bounded helper probe details

* test(packaging): trace Snap EGL probe failures

* fix(packaging): prefer core22 ABI in Snap helper

* fix(packaging): bound helper probe capture

* fix(packaging): harden Linux frame-copy releases

* fix(packaging): canonicalize libplacebo submodule identity

* fix(packaging): make source archive inspection portable

* fix(packaging): harden Snap release verification
2026-07-18 17:28:22 +02:00

754 lines
24 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const childProcess = require('node:child_process');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { isDeepStrictEqual } = require('node:util');
const {
canonicalizeGitSubmoduleStatus,
} = require('../embedded-mpv/build-linux-runtime.cjs');
const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT = Object.freeze({
schemaVersion: 1,
hashAlgorithm: 'sha256',
canonicalEncoding: 'utf8-json-line-v1',
});
// Derived from a clean recursive checkout of libplacebo v7.360.1 at
// cee9b076f2c63104ccfd497fa79c39a867293ec4 with every recorded submodule at
// its pinned commit. The inventory contract above excludes all .git entries.
// Derivation: git clone --branch v7.360.1 --single-branch --recurse-submodules
// https://github.com/haasn/libplacebo.git, then globally sort and inventory the
// VCS-free copy under LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.
// The trusted snapshot has 1,456 entries and 54,312,340 regular-file bytes.
const EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256 =
'0db67c1523411255244186af437e9fbfe7ccac04a5ac1b3dc9275dd0806f6f0c';
function gitOutput(checkoutPath, ...args) {
try {
return childProcess
.execFileSync('git', ['-C', checkoutPath, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
.trim();
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error
? String(error.stderr).trim()
: '';
throw new Error(
`Unable to inspect source checkout with git ${args.join(' ')}${stderr ? `: ${stderr}` : '.'}`
);
}
}
function assertExpectedGitRecord(expected) {
if (
expected === null ||
typeof expected !== 'object' ||
typeof expected.sourceGitCommit !== 'string' ||
!GIT_COMMIT_PATTERN.test(expected.sourceGitCommit) ||
!Array.isArray(expected.sourceSubmodules) ||
expected.sourceSubmodules.some(
(record) => typeof record !== 'string' || record.length === 0
)
) {
throw new Error(
'Expected source identity must contain one commit and a submodule record array.'
);
}
}
function assertCleanCheckout(checkoutPath, label) {
const status = gitOutput(
checkoutPath,
'status',
'--porcelain=v1',
'--untracked-files=all',
'--ignore-submodules=none'
);
if (status) {
throw new Error(`${label} checkout contains dirty or untracked files.`);
}
}
function sourceSubmoduleIdentity(record) {
const match = record.match(/^([a-f0-9]{40,64})\s+([A-Za-z0-9_+./-]+)$/);
if (!match) {
throw new Error(`Invalid source submodule record: ${record}`);
}
const submodulePath = match[2];
if (
path.isAbsolute(submodulePath) ||
submodulePath
.split('/')
.some((part) => part === '' || part === '.' || part === '..')
) {
throw new Error(`Unsafe source submodule path: ${submodulePath}`);
}
return {
commit: match[1],
path: submodulePath,
};
}
function inspectCleanGitSource(checkoutPath, expected) {
assertExpectedGitRecord(expected);
const sourceGitCommit = gitOutput(checkoutPath, 'rev-parse', 'HEAD');
if (sourceGitCommit !== expected.sourceGitCommit) {
throw new Error(
'Source checkout commit does not match the runtime manifest.'
);
}
const submoduleOutput = gitOutput(
checkoutPath,
'submodule',
'status',
'--recursive'
);
const sourceSubmodules = canonicalizeGitSubmoduleStatus(submoduleOutput);
if (!isDeepStrictEqual(sourceSubmodules, expected.sourceSubmodules)) {
throw new Error(
'Source checkout submodules do not match the runtime manifest.'
);
}
assertCleanCheckout(checkoutPath, 'Source');
for (const submoduleRecord of sourceSubmodules) {
const submodule = sourceSubmoduleIdentity(submoduleRecord);
const submoduleCheckout = path.join(
checkoutPath,
...submodule.path.split('/')
);
if (
gitOutput(submoduleCheckout, 'rev-parse', 'HEAD') !==
submodule.commit
) {
throw new Error(
`Source submodule ${submodule.path} commit does not match its recorded identity.`
);
}
assertCleanCheckout(
submoduleCheckout,
`Source submodule ${submodule.path}`
);
}
return {
sourceGitCommit,
sourceSubmodules,
};
}
function gitMetadataEntries(rootPath) {
const entries = [];
function visit(directoryPath, relativeDirectory = '') {
for (const entry of fs.readdirSync(directoryPath, {
withFileTypes: true,
})) {
const relativePath = path.posix.join(relativeDirectory, entry.name);
const absolutePath = path.join(directoryPath, entry.name);
if (entry.name === '.git') {
entries.push(relativePath);
} else if (entry.isDirectory()) {
visit(absolutePath, relativePath);
}
}
}
visit(rootPath);
return entries.sort();
}
function assertNoGitMetadata(rootPath) {
const entries = gitMetadataEntries(rootPath);
if (entries.length > 0) {
throw new Error(
`Prepared source snapshot must not contain VCS metadata: ${entries.join(', ')}`
);
}
}
function compareCanonicalPaths(left, right) {
if (left < right) {
return -1;
}
if (left > right) {
return 1;
}
return 0;
}
function sha256File(filePath) {
const descriptor = fs.openSync(filePath, 'r');
try {
const stat = fs.fstatSync(descriptor);
if (!stat.isFile()) {
throw new Error(
`Unsupported source snapshot entry (not a regular file): ${filePath}`
);
}
if (!Number.isSafeInteger(stat.size) || stat.size < 0) {
throw new Error(
`Source snapshot file has an unsupported size: ${filePath}`
);
}
const hash = crypto.createHash('sha256');
const buffer = Buffer.allocUnsafe(1024 * 1024);
let offset = 0;
while (offset < stat.size) {
const bytesRead = fs.readSync(
descriptor,
buffer,
0,
Math.min(buffer.length, stat.size - offset),
offset
);
if (bytesRead === 0) {
throw new Error(
`Source snapshot file changed while hashing: ${filePath}`
);
}
hash.update(buffer.subarray(0, bytesRead));
offset += bytesRead;
}
const finalStat = fs.fstatSync(descriptor);
if (
!finalStat.isFile() ||
finalStat.size !== stat.size ||
finalStat.mtimeMs !== stat.mtimeMs
) {
throw new Error(
`Source snapshot file changed while hashing: ${filePath}`
);
}
return {
size: stat.size,
executable: (stat.mode & 0o111) !== 0,
sha256: hash.digest('hex'),
};
} finally {
fs.closeSync(descriptor);
}
}
function assertSafeSnapshotPath(relativePath) {
if (
typeof relativePath !== 'string' ||
relativePath.length === 0 ||
relativePath.includes('\\') ||
path.posix.isAbsolute(relativePath) ||
path.win32.isAbsolute(relativePath) ||
[...relativePath].some((character) => {
const codePoint = character.codePointAt(0);
return codePoint <= 0x1f || codePoint === 0x7f;
}) ||
relativePath
.split('/')
.some(
(part) =>
part === '' ||
part === '.' ||
part === '..' ||
part === '.git'
)
) {
throw new Error(`Unsafe source snapshot path: ${relativePath}`);
}
}
function assertSafeSymlinkTarget(relativePath, target) {
const targetSegments = target.split('/');
const resolvedTarget = path.posix.normalize(
path.posix.join(path.posix.dirname(relativePath), target)
);
if (
target.length === 0 ||
target.includes('\\') ||
path.posix.isAbsolute(target) ||
path.win32.isAbsolute(target) ||
[...target].some((character) => {
const codePoint = character.codePointAt(0);
return codePoint <= 0x1f || codePoint === 0x7f;
}) ||
resolvedTarget === '..' ||
resolvedTarget.startsWith('../') ||
path.posix.isAbsolute(resolvedTarget) ||
targetSegments.includes('.git')
) {
throw new Error(
`Unsafe source snapshot symlink ${relativePath}: ${target}`
);
}
}
function hasExactFields(value, expectedFields) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
isDeepStrictEqual(
Object.keys(value).sort(compareCanonicalPaths),
[...expectedFields].sort(compareCanonicalPaths)
)
);
}
function canonicalSourceSnapshotSha256({
schemaVersion,
entryCount,
totalBytes,
entries,
}) {
return crypto
.createHash('sha256')
.update(
`${JSON.stringify({
schemaVersion,
entryCount,
totalBytes,
entries,
})}\n`,
'utf8'
)
.digest('hex');
}
function invalidSourceSnapshot(detail) {
throw new Error(`Invalid source snapshot: ${detail}`);
}
function validateLinuxRuntimeSourceSnapshot(snapshot, { expectedSha256 } = {}) {
if (
!hasExactFields(snapshot, [
'schemaVersion',
'sha256',
'entryCount',
'totalBytes',
'entries',
]) ||
snapshot.schemaVersion !==
LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion ||
typeof snapshot.sha256 !== 'string' ||
!SHA256_PATTERN.test(snapshot.sha256) ||
!Number.isSafeInteger(snapshot.entryCount) ||
snapshot.entryCount < 0 ||
!Number.isSafeInteger(snapshot.totalBytes) ||
snapshot.totalBytes < 0 ||
!Array.isArray(snapshot.entries)
) {
invalidSourceSnapshot('top-level fields do not match the contract.');
}
const entries = [];
const entryTypes = new Map();
let previousPath = null;
let totalBytes = 0;
for (const entry of snapshot.entries) {
if (
entry === null ||
typeof entry !== 'object' ||
Array.isArray(entry) ||
typeof entry.path !== 'string'
) {
invalidSourceSnapshot('an entry is not an exact object.');
}
assertSafeSnapshotPath(entry.path);
if (
previousPath !== null &&
compareCanonicalPaths(previousPath, entry.path) >= 0
) {
throw new Error(
'Invalid source snapshot: entry paths must be sorted and unique.'
);
}
previousPath = entry.path;
let normalizedEntry;
if (entry.type === 'directory') {
if (!hasExactFields(entry, ['path', 'type'])) {
invalidSourceSnapshot(
`directory entry ${entry.path} has invalid fields.`
);
}
normalizedEntry = {
path: entry.path,
type: 'directory',
};
} else if (entry.type === 'file') {
if (
!hasExactFields(entry, [
'path',
'type',
'size',
'executable',
'sha256',
]) ||
!Number.isSafeInteger(entry.size) ||
entry.size < 0 ||
typeof entry.executable !== 'boolean' ||
typeof entry.sha256 !== 'string' ||
!SHA256_PATTERN.test(entry.sha256)
) {
invalidSourceSnapshot(
`file entry ${entry.path} has invalid fields.`
);
}
totalBytes += entry.size;
if (!Number.isSafeInteger(totalBytes)) {
invalidSourceSnapshot(
'regular-file byte total exceeds the supported range.'
);
}
normalizedEntry = {
path: entry.path,
type: 'file',
size: entry.size,
executable: entry.executable,
sha256: entry.sha256,
};
} else if (entry.type === 'symlink') {
if (
!hasExactFields(entry, ['path', 'type', 'target']) ||
typeof entry.target !== 'string'
) {
invalidSourceSnapshot(
`symlink entry ${entry.path} has invalid fields.`
);
}
assertSafeSymlinkTarget(entry.path, entry.target);
normalizedEntry = {
path: entry.path,
type: 'symlink',
target: entry.target,
};
} else {
invalidSourceSnapshot(
`entry ${entry.path} has an unsupported type.`
);
}
const parentPath = path.posix.dirname(entry.path);
if (parentPath !== '.' && entryTypes.get(parentPath) !== 'directory') {
throw new Error(
`Invalid source snapshot: parent ${parentPath} of ${entry.path} must be a directory entry.`
);
}
entryTypes.set(entry.path, entry.type);
entries.push(normalizedEntry);
}
if (
snapshot.entryCount !== entries.length ||
snapshot.totalBytes !== totalBytes
) {
invalidSourceSnapshot(
'entryCount or totalBytes does not match the entries.'
);
}
const normalizedSnapshot = {
schemaVersion: LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion,
sha256: snapshot.sha256,
entryCount: entries.length,
totalBytes,
entries,
};
const canonicalSha256 = canonicalSourceSnapshotSha256(normalizedSnapshot);
if (snapshot.sha256 !== canonicalSha256) {
invalidSourceSnapshot('canonical SHA-256 does not match the entries.');
}
assertExpectedSourceSnapshot(normalizedSnapshot, expectedSha256);
return normalizedSnapshot;
}
function inventoryLinuxRuntimeSourceSnapshot(rootPath) {
const rootStat = fs.lstatSync(rootPath);
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
throw new Error('Source snapshot root must be a real directory.');
}
assertNoGitMetadata(rootPath);
const entries = [];
let totalBytes = 0;
function visit(directoryPath, relativeDirectory = '') {
const childNames = fs
.readdirSync(directoryPath)
.sort(compareCanonicalPaths);
for (const childName of childNames) {
const relativePath = relativeDirectory
? `${relativeDirectory}/${childName}`
: childName;
assertSafeSnapshotPath(relativePath);
const absolutePath = path.join(directoryPath, childName);
const stat = fs.lstatSync(absolutePath);
if (stat.isDirectory()) {
entries.push({
path: relativePath,
type: 'directory',
});
visit(absolutePath, relativePath);
continue;
}
if (stat.isFile()) {
const file = sha256File(absolutePath);
totalBytes += file.size;
if (!Number.isSafeInteger(totalBytes)) {
throw new Error(
'Source snapshot total byte count exceeds the supported range.'
);
}
entries.push({
path: relativePath,
type: 'file',
size: file.size,
executable: file.executable,
sha256: file.sha256,
});
continue;
}
if (stat.isSymbolicLink()) {
const target = fs.readlinkSync(absolutePath);
assertSafeSymlinkTarget(relativePath, target);
entries.push({
path: relativePath,
type: 'symlink',
target,
});
continue;
}
throw new Error(
`Unsupported source snapshot entry: ${relativePath}`
);
}
}
visit(rootPath);
entries.sort(({ path: left }, { path: right }) =>
compareCanonicalPaths(left, right)
);
const canonicalInventory = {
schemaVersion: LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT.schemaVersion,
entryCount: entries.length,
totalBytes,
entries,
};
const sha256 = canonicalSourceSnapshotSha256(canonicalInventory);
return {
schemaVersion: canonicalInventory.schemaVersion,
sha256,
entryCount: canonicalInventory.entryCount,
totalBytes: canonicalInventory.totalBytes,
entries,
};
}
function lstatIfExists(candidatePath) {
try {
return fs.lstatSync(candidatePath);
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
return null;
}
throw error;
}
}
function assertExpectedSourceSnapshot(
sourceSnapshot,
expectedSourceSnapshotSha256
) {
if (expectedSourceSnapshotSha256 === undefined) {
return;
}
if (
typeof expectedSourceSnapshotSha256 !== 'string' ||
!SHA256_PATTERN.test(expectedSourceSnapshotSha256)
) {
throw new Error(
'Expected source snapshot digest must be a lowercase SHA-256 digest.'
);
}
if (sourceSnapshot.sha256 !== expectedSourceSnapshotSha256) {
throw new Error(
`Source snapshot digest mismatch: expected ${expectedSourceSnapshotSha256}, received ${sourceSnapshot.sha256}.`
);
}
}
function copyWorkingTreeWithoutGitMetadata(
checkoutPath,
outputPath,
expectedSourceSnapshotSha256
) {
const outputParent = path.dirname(outputPath);
const temporaryPath = fs.mkdtempSync(
path.join(outputParent, `.${path.basename(outputPath)}-`)
);
try {
fs.cpSync(checkoutPath, temporaryPath, {
recursive: true,
dereference: false,
verbatimSymlinks: true,
filter: (sourcePath) => path.basename(sourcePath) !== '.git',
});
assertNoGitMetadata(temporaryPath);
const sourceSnapshot =
inventoryLinuxRuntimeSourceSnapshot(temporaryPath);
assertExpectedSourceSnapshot(
sourceSnapshot,
expectedSourceSnapshotSha256
);
if (lstatIfExists(outputPath)) {
throw new Error(
`Prepared source snapshot output must not already exist: ${outputPath}`
);
}
fs.renameSync(temporaryPath, outputPath);
return sourceSnapshot;
} catch (error) {
fs.rmSync(temporaryPath, { recursive: true, force: true });
throw error;
}
}
function prepareLinuxRuntimeSourceSnapshot({
checkoutPath,
outputPath,
expected,
expectedSourceSnapshotSha256,
}) {
const checkoutStat = fs.lstatSync(checkoutPath);
if (!checkoutStat.isDirectory() || checkoutStat.isSymbolicLink()) {
throw new Error('Source checkout must be a real directory.');
}
const checkoutRoot = fs.realpathSync(checkoutPath);
const outputRoot = path.resolve(outputPath);
const outputParent = path.dirname(outputRoot);
const outputParentStat = fs.lstatSync(outputParent);
if (!outputParentStat.isDirectory() || outputParentStat.isSymbolicLink()) {
throw new Error(
'Prepared source snapshot parent must be a real directory.'
);
}
if (lstatIfExists(outputRoot)) {
throw new Error(
`Prepared source snapshot output must not already exist: ${outputRoot}`
);
}
const realOutputRoot = path.join(
fs.realpathSync(outputParent),
path.basename(outputRoot)
);
const isInside = (root, candidate) => {
const relative = path.relative(root, candidate);
return (
relative === '' ||
(relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
};
if (
isInside(checkoutRoot, realOutputRoot) ||
isInside(realOutputRoot, checkoutRoot)
) {
throw new Error(
'Prepared source snapshot and checkout must be separate trees.'
);
}
const record = inspectCleanGitSource(checkoutRoot, expected);
const sourceSnapshot = copyWorkingTreeWithoutGitMetadata(
checkoutRoot,
realOutputRoot,
expectedSourceSnapshotSha256
);
assertNoGitMetadata(realOutputRoot);
return {
...record,
sourceSnapshot,
};
}
function parseArguments(argv) {
const [command, ...tokens] = argv;
const options = {};
for (let index = 0; index < tokens.length; index += 2) {
const name = tokens[index];
const value = tokens[index + 1];
if (!name?.startsWith('--') || value === undefined) {
throw new Error(`Invalid command-line argument: ${name ?? ''}`);
}
options[name.slice(2)] = value;
}
return { command, options };
}
function readJson(filePath) {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
function main(argv = process.argv.slice(2)) {
const { command, options } = parseArguments(argv);
if (
command === 'prepare' &&
options['runtime-manifest'] &&
options.checkout &&
options.output &&
options['record-output']
) {
const runtimeManifest = readJson(options['runtime-manifest']);
const sourcePackage = runtimeManifest?.packages?.libplacebo;
const record = prepareLinuxRuntimeSourceSnapshot({
checkoutPath: options.checkout,
outputPath: options.output,
expected: {
sourceGitCommit: sourcePackage?.sourceGitCommit,
sourceSubmodules: sourcePackage?.sourceSubmodules,
},
expectedSourceSnapshotSha256:
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
});
writeJson(options['record-output'], record);
return;
}
if (command === 'assert-vcs-free' && options.directory) {
assertNoGitMetadata(options.directory);
return;
}
throw new Error(
'Usage: prepare-linux-runtime-source-snapshot.cjs prepare --runtime-manifest <path> --checkout <path> --output <path> --record-output <path> | assert-vcs-free --directory <path>'
);
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
module.exports = {
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
LINUX_RUNTIME_SOURCE_SNAPSHOT_CONTRACT,
assertNoGitMetadata,
inspectCleanGitSource,
inventoryLinuxRuntimeSourceSnapshot,
prepareLinuxRuntimeSourceSnapshot,
validateLinuxRuntimeSourceSnapshot,
};