feat(packaging): ship Linux runtime license notices

This commit is contained in:
4gray committed 2026-07-17 22:42:37 +02:00
1 parent 601501a4b0
commit 1477079509
10 files changed
+1548 -27

No files matched your search

+125 -23
View File
@@ -43,17 +43,19 @@ jobs:
echo 'meson=1.7.2'
} > "${RUNNER_TEMP}/linux-runtime-toolchain.txt"
TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)"
SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/stage-runtime.mjs') }}"
SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/stage-runtime.mjs') }}"
echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}"
echo "key=linux-frame-copy-runtime-v4-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}"
echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}"
- name: Restore pinned Linux runtime and source compliance bundle
- name: Restore pinned Linux runtime and immutable source inputs
id: linux-runtime-cache
uses: actions/cache@v4
with:
path: |
vendor/embedded-mpv/linux-x64
dist/compliance/linux-frame-copy-runtime-sources.tar.xz
vendor/embedded-mpv/linux-x64/include
vendor/embedded-mpv/linux-x64/lib
vendor/embedded-mpv/linux-x64/runtime-manifest.json
dist/linux-frame-copy-runtime-source-inputs
key: ${{ steps.linux-runtime-cache-key.outputs.key }}
- name: Install pinned Linux runtime build dependencies
@@ -99,30 +101,67 @@ jobs:
node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}"
node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}"
export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs"
rm -rf "${SOURCE_INPUT_ROOT}"
mkdir -p \
"${SOURCE_INPUT_ROOT}/archives" \
"${SOURCE_INPUT_ROOT}/git"
git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \
submodule foreach --recursive git clean -ffdqx
git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \
clean -ffdqx
cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/"
cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo"
node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \
--runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \
--source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \
--output-root "${SOURCE_INPUT_ROOT}/license-inputs"
- name: Generate Linux runtime notices and assemble source compliance
shell: bash
run: |
set -euo pipefail
export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64"
export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs"
export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources"
rm -rf "${SOURCE_BUNDLE_ROOT}"
test -f "${RUNTIME_ROOT}/runtime-manifest.json"
test -d "${SOURCE_INPUT_ROOT}/archives"
test -d "${SOURCE_INPUT_ROOT}/git/libplacebo"
test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json"
rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}"
node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \
--runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \
--license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \
--output-root "${RUNTIME_ROOT}/notices"
mkdir -p \
"${SOURCE_BUNDLE_ROOT}/archives" \
"${SOURCE_BUNDLE_ROOT}/git" \
"${SOURCE_BUNDLE_ROOT}/license-inputs" \
"${SOURCE_BUNDLE_ROOT}/metadata" \
"${SOURCE_BUNDLE_ROOT}/notices" \
"${SOURCE_BUNDLE_ROOT}/tooling"
cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/"
cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_BUNDLE_ROOT}/git/libplacebo"
cp "${RUNTIME_PREFIX}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json"
cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/"
cp -a "${SOURCE_INPUT_ROOT}/git/." "${SOURCE_BUNDLE_ROOT}/git/"
cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/"
cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/"
cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json"
cp \
tools/embedded-mpv/build-linux-runtime.cjs \
tools/embedded-mpv/build-linux-runtime.mjs \
tools/embedded-mpv/generate-linux-runtime-notices.cjs \
tools/embedded-mpv/linux-runtime-manifest.cjs \
tools/embedded-mpv/stage-runtime.mjs \
"${SOURCE_BUNDLE_ROOT}/tooling/"
find "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/hwdata" \
-maxdepth 1 -type f -iname 'license*' \
-exec cp '{}' "${SOURCE_BUNDLE_ROOT}/notices/hwdata-LICENSE" ';'
test -f "${SOURCE_BUNDLE_ROOT}/notices/hwdata-LICENSE"
test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt"
test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json"
git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt"
git diff --binary HEAD -- tools/embedded-mpv > "${SOURCE_BUNDLE_ROOT}/metadata/local-embedded-mpv-changes.patch"
git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch"
node <<'NODE'
const childProcess = require('node:child_process');
const crypto = require('node:crypto');
@@ -130,7 +169,7 @@ jobs:
const path = require('node:path');
const manifest = JSON.parse(
fs.readFileSync(path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'), 'utf8')
fs.readFileSync(path.join(process.env.RUNTIME_ROOT, 'runtime-manifest.json'), 'utf8')
);
const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives');
const archives = fs.readdirSync(archivesDirectory).sort().map((name) => {
@@ -140,13 +179,20 @@ jobs:
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
};
});
const archiveHashes = new Set(archives.map(({ sha256 }) => sha256));
for (const [packageName, source] of Object.entries(manifest.packages)) {
if (source.sourceSha256 && !archiveHashes.has(source.sourceSha256)) {
throw new Error(
`Source bundle is missing ${packageName} archive ${source.sourceSha256}.`
);
}
const expectedArchiveHashes = Object.values(manifest.packages)
.map(({ sourceSha256 }) => sourceSha256)
.filter(Boolean)
.sort();
const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort();
if (
new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length ||
new Set(actualArchiveHashes).size !== actualArchiveHashes.length ||
archives.length !== expectedArchiveHashes.length ||
JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes)
) {
throw new Error(
'Source bundle archives must match the exact unique pinned archive hash set.'
);
}
const libplaceboCheckout = path.join(
@@ -172,18 +218,68 @@ jobs:
) {
throw new Error('Bundled libplacebo submodules do not match the runtime manifest.');
}
const assertCleanCheckout = (checkoutPath, label) => {
const status = childProcess.execFileSync(
'git',
[
'-C',
checkoutPath,
'status',
'--porcelain=v1',
'--untracked-files=all',
'--ignore-submodules=none',
],
{ encoding: 'utf8' }
);
if (status.trim()) {
throw new Error(`${label} checkout contains dirty or untracked files.`);
}
};
assertCleanCheckout(libplaceboCheckout, 'libplacebo');
for (const submoduleRecord of sourceSubmodules) {
const match = submoduleRecord.match(/^[+-U]?[a-f0-9]{40,64}\s+([^\s]+)/);
if (!match) {
throw new Error(`Invalid libplacebo submodule record: ${submoduleRecord}`);
}
assertCleanCheckout(
path.join(libplaceboCheckout, match[1]),
`libplacebo submodule ${match[1]}`
);
}
const notices = JSON.parse(
fs.readFileSync(
path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'),
'utf8'
)
);
const repositoryRevision = fs
.readFileSync(
path.join(
process.env.SOURCE_BUNDLE_ROOT,
'metadata',
'iptvnator-git-revision.txt'
),
'utf8'
)
.trim();
fs.writeFileSync(
path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'),
`${JSON.stringify(
{
schemaVersion: 1,
schemaVersion: 2,
repositoryRevision,
sourcePackages: manifest.packages,
archives,
libplacebo: {
sourceGitCommit,
sourceSubmodules,
},
legal: {
manifest: 'notices/embedded-mpv-notices.json',
noticeFile: notices.noticeFile,
packages: notices.packages,
},
},
null,
2
@@ -196,9 +292,15 @@ jobs:
)
mkdir -p dist/compliance
rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz
tar \
--create \
--xz \
--sort=name \
--mtime='UTC 1970-01-01' \
--owner=0 \
--group=0 \
--numeric-owner \
--file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \
--directory "${SOURCE_BUNDLE_ROOT}" \
.
+1
View File
@@ -84,4 +84,5 @@ apps/electron-backend/src/app/options/electron-builder.metadata.generated.json
vendor/embedded-mpv/*/bin/
vendor/embedded-mpv/*/include/
vendor/embedded-mpv/*/lib/
vendor/embedded-mpv/*/notices/
vendor/embedded-mpv/*/runtime-manifest.json
@@ -0,0 +1,753 @@
#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const { isDeepStrictEqual } = require('node:util');
const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs');
const LICENSE_INPUT_MANIFEST = 'linux-runtime-license-inputs.json';
const NOTICE_MANIFEST = 'embedded-mpv-notices.json';
const THIRD_PARTY_NOTICES = 'THIRD_PARTY_NOTICES.txt';
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const compareText = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
const NOTICE_SOURCE_PACKAGES = Object.freeze(
[...SOURCE_PACKAGES].sort(({ id: left }, { id: right }) =>
compareText(left, right)
)
);
const LICENSE_PATHS_BY_PACKAGE = Object.freeze({
freetype: Object.freeze(['LICENSE.TXT', 'docs/FTL.TXT']),
fribidi: Object.freeze(['COPYING']),
harfbuzz: Object.freeze(['COPYING']),
expat: Object.freeze(['COPYING']),
fontconfig: Object.freeze(['COPYING']),
libass: Object.freeze(['COPYING']),
openssl: Object.freeze(['LICENSE.txt']),
ffmpeg: Object.freeze(['LICENSE.md', 'COPYING.LGPLv2.1']),
libplacebo: Object.freeze([
'LICENSE',
'3rdparty/Vulkan-Headers/LICENSE.md',
'3rdparty/fast_float/LICENSE-APACHE',
'3rdparty/fast_float/LICENSE-BOOST',
'3rdparty/fast_float/LICENSE-MIT',
'3rdparty/glad/LICENSE',
'3rdparty/jinja/LICENSE.txt',
'3rdparty/markupsafe/LICENSE.txt',
'demos/3rdparty/nuklear/LICENSE',
]),
hwdata: Object.freeze(['LICENSE', 'COPYING']),
'libdisplay-info': Object.freeze(['LICENSE']),
mpv: Object.freeze(['Copyright', 'LICENSE.LGPL']),
});
function sha256(contents) {
return crypto.createHash('sha256').update(contents).digest('hex');
}
function isObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function hasExactFields(value, fields) {
return (
isObject(value) &&
isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort())
);
}
function isPathInside(root, candidate) {
const relative = path.relative(root, candidate);
return (
relative === '' ||
(relative !== '..' &&
!relative.startsWith(`..${path.sep}`) &&
!path.isAbsolute(relative))
);
}
function assertDirectoryWithoutSymlinks(directoryPath, label) {
let stat;
try {
stat = fs.lstatSync(directoryPath);
} catch {
throw new Error(`Missing ${label}: ${directoryPath}`);
}
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error(`${label} must be a real directory: ${directoryPath}`);
}
return fs.realpathSync(directoryPath);
}
function assertRegularFileInside(root, relativePath, label) {
if (
typeof relativePath !== 'string' ||
relativePath.length === 0 ||
path.isAbsolute(relativePath) ||
relativePath.split(/[\\/]/).some((part) => part === '..' || part === '')
) {
throw new Error(
`${label} has an unsafe relative path: ${relativePath}`
);
}
const realRoot = assertDirectoryWithoutSymlinks(root, `${label} root`);
const candidate = path.resolve(root, ...relativePath.split('/'));
if (!isPathInside(path.resolve(root), candidate)) {
throw new Error(`${label} resolves outside ${root}: ${relativePath}`);
}
let cursor = path.resolve(root);
const parts = path.relative(cursor, candidate).split(path.sep);
for (const [index, part] of parts.entries()) {
cursor = path.join(cursor, part);
let stat;
try {
stat = fs.lstatSync(cursor);
} catch {
throw new Error(`Missing ${label}: ${cursor}`);
}
if (stat.isSymbolicLink()) {
throw new Error(`${label} must not use a symbolic link: ${cursor}`);
}
if (index < parts.length - 1 && !stat.isDirectory()) {
throw new Error(`${label} parent must be a directory: ${cursor}`);
}
if (index === parts.length - 1 && !stat.isFile()) {
throw new Error(`${label} must be a regular file: ${cursor}`);
}
}
const realCandidate = fs.realpathSync(candidate);
if (!isPathInside(realRoot, realCandidate)) {
throw new Error(
`${label} resolves outside its trusted root: ${relativePath}`
);
}
return realCandidate;
}
function sourcePackageMetadata(sourcePackage) {
return {
version: sourcePackage.version,
sourceUrl: sourcePackage.sourceUrl,
...(sourcePackage.sourceTag
? { sourceTag: sourcePackage.sourceTag }
: {}),
...(sourcePackage.sourceKind === 'archive'
? { sourceSha256: sourcePackage.expectedSha256 }
: { sourceGitCommit: sourcePackage.expectedGitCommit }),
license: sourcePackage.license,
};
}
function validateRuntimeManifestPackages(runtimeManifest) {
if (
!isObject(runtimeManifest) ||
runtimeManifest.platform !== 'linux' ||
runtimeManifest.arch !== 'x64' ||
!isObject(runtimeManifest.packages) ||
!isDeepStrictEqual(
Object.keys(runtimeManifest.packages).sort(),
SOURCE_PACKAGES.map(({ id }) => id).sort()
)
) {
throw new Error(
'Linux runtime notice generation requires the exact pinned linux-x64 package manifest.'
);
}
for (const sourcePackage of SOURCE_PACKAGES) {
const actual = runtimeManifest.packages[sourcePackage.id];
const expected = sourcePackageMetadata(sourcePackage);
for (const [field, expectedValue] of Object.entries(expected)) {
if (!isDeepStrictEqual(actual?.[field], expectedValue)) {
throw new Error(
`Linux runtime package ${sourcePackage.id}.${field} does not match its immutable pin.`
);
}
}
if (
sourcePackage.sourceKind === 'git' &&
(!Array.isArray(actual.sourceSubmodules) ||
actual.sourceSubmodules.length === 0)
) {
throw new Error(
`Linux runtime package ${sourcePackage.id} must record pinned submodules.`
);
}
const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id];
if (!Array.isArray(licensePaths) || licensePaths.length === 0) {
throw new Error(
`Linux runtime package ${sourcePackage.id} has no pinned upstream license files.`
);
}
}
}
function fileRecord(filePath, relativePath) {
const contents = fs.readFileSync(filePath);
return {
path: relativePath.split(path.sep).join('/'),
size: contents.length,
sha256: sha256(contents),
};
}
function writeJson(filePath, value) {
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, {
mode: 0o644,
});
}
function replaceDirectory(outputRoot, writer) {
const resolvedOutputRoot = path.resolve(outputRoot);
const temporaryRoot = `${resolvedOutputRoot}.tmp-${process.pid}-${crypto
.randomBytes(8)
.toString('hex')}`;
fs.rmSync(temporaryRoot, { recursive: true, force: true });
fs.mkdirSync(temporaryRoot, { recursive: true });
try {
const result = writer(temporaryRoot);
fs.rmSync(resolvedOutputRoot, { recursive: true, force: true });
fs.renameSync(temporaryRoot, resolvedOutputRoot);
return result;
} catch (error) {
fs.rmSync(temporaryRoot, { recursive: true, force: true });
throw error;
}
}
function expectedLicensePath(packageId, sourceRelativePath) {
return path.posix.join('licenses', packageId, sourceRelativePath);
}
function collectLinuxRuntimeLicenseInputs({
sourceRoot,
outputRoot,
runtimeManifest,
}) {
validateRuntimeManifestPackages(runtimeManifest);
assertDirectoryWithoutSymlinks(sourceRoot, 'Linux runtime source root');
return replaceDirectory(outputRoot, (temporaryRoot) => {
const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage) => {
const files = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map(
(sourceRelativePath) => {
const sourcePath = assertRegularFileInside(
path.join(sourceRoot, sourcePackage.id),
sourceRelativePath,
`${sourcePackage.id} upstream license`
);
const relativeOutputPath = expectedLicensePath(
sourcePackage.id,
sourceRelativePath
);
const destinationPath = path.join(
temporaryRoot,
...relativeOutputPath.split('/')
);
fs.mkdirSync(path.dirname(destinationPath), {
recursive: true,
});
fs.copyFileSync(sourcePath, destinationPath);
fs.chmodSync(destinationPath, 0o644);
return {
sourcePath: sourceRelativePath,
...fileRecord(destinationPath, relativeOutputPath),
};
}
);
return {
id: sourcePackage.id,
...sourcePackageMetadata(sourcePackage),
files,
};
});
const manifest = {
schemaVersion: 1,
origin: 'pinned-linux-runtime-license-inputs',
platform: 'linux',
arch: 'x64',
packages,
};
writeJson(path.join(temporaryRoot, LICENSE_INPUT_MANIFEST), manifest);
return manifest;
});
}
function listFilesRecursively(root) {
const files = [];
function visit(directoryPath) {
for (const entry of fs
.readdirSync(directoryPath, { withFileTypes: true })
.sort((left, right) => compareText(left.name, right.name))) {
const entryPath = path.join(directoryPath, entry.name);
if (entry.isSymbolicLink()) {
throw new Error(
`Linux runtime legal files must not use symbolic links: ${entryPath}`
);
}
if (entry.isDirectory()) {
visit(entryPath);
} else if (entry.isFile()) {
files.push(
path.relative(root, entryPath).split(path.sep).join('/')
);
} else {
throw new Error(
`Linux runtime legal input must be a regular file: ${entryPath}`
);
}
}
}
visit(root);
return files;
}
function readJsonFileInside(root, relativePath, label) {
const filePath = assertRegularFileInside(root, relativePath, label);
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(
`Invalid JSON in ${label}: ${
error instanceof Error ? error.message : String(error)
}`
);
}
}
function validateLicenseInputManifest(inputRoot, runtimeManifest) {
validateRuntimeManifestPackages(runtimeManifest);
const inputManifest = readJsonFileInside(
inputRoot,
LICENSE_INPUT_MANIFEST,
'Linux runtime license input manifest'
);
if (
!hasExactFields(inputManifest, [
'schemaVersion',
'origin',
'platform',
'arch',
'packages',
]) ||
inputManifest.schemaVersion !== 1 ||
inputManifest.origin !== 'pinned-linux-runtime-license-inputs' ||
inputManifest.platform !== 'linux' ||
inputManifest.arch !== 'x64' ||
!Array.isArray(inputManifest.packages) ||
inputManifest.packages.length !== NOTICE_SOURCE_PACKAGES.length
) {
throw new Error('Invalid Linux runtime license input manifest.');
}
const expectedPaths = new Set([LICENSE_INPUT_MANIFEST]);
for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) {
const packageRecord = inputManifest.packages[index];
const expectedMetadata = sourcePackageMetadata(sourcePackage);
if (
!hasExactFields(packageRecord, [
'id',
...Object.keys(expectedMetadata),
'files',
]) ||
packageRecord.id !== sourcePackage.id ||
!Object.entries(expectedMetadata).every(([field, value]) =>
isDeepStrictEqual(packageRecord[field], value)
) ||
!Array.isArray(packageRecord.files) ||
packageRecord.files.length !==
LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].length
) {
throw new Error(
`Invalid cached license inputs for ${sourcePackage.id}.`
);
}
for (const [fileIndex, sourceRelativePath] of LICENSE_PATHS_BY_PACKAGE[
sourcePackage.id
].entries()) {
const record = packageRecord.files[fileIndex];
const expectedPath = expectedLicensePath(
sourcePackage.id,
sourceRelativePath
);
if (
!hasExactFields(record, [
'sourcePath',
'path',
'size',
'sha256',
]) ||
record.sourcePath !== sourceRelativePath ||
record.path !== expectedPath ||
!Number.isSafeInteger(record.size) ||
record.size <= 0 ||
!SHA256_PATTERN.test(record.sha256)
) {
throw new Error(
`Invalid cached license file record for ${sourcePackage.id}.`
);
}
const inputPath = assertRegularFileInside(
inputRoot,
record.path,
`${sourcePackage.id} license input`
);
const actual = fileRecord(inputPath, record.path);
if (actual.size !== record.size) {
throw new Error(
`Size mismatch for cached license input ${record.path}.`
);
}
if (actual.sha256 !== record.sha256) {
throw new Error(
`SHA-256 mismatch for cached license input ${record.path}.`
);
}
expectedPaths.add(record.path);
}
}
for (const actualPath of listFilesRecursively(inputRoot)) {
if (!expectedPaths.has(actualPath)) {
throw new Error(
`Found undeclared license input ${actualPath} in ${inputRoot}.`
);
}
}
return inputManifest;
}
function noticePackageRecord(sourcePackage, inputPackage) {
return {
id: sourcePackage.id,
...sourcePackageMetadata(sourcePackage),
files: inputPackage.files.map(
({ path: filePath, size, sha256: hash }) => ({
path: filePath,
size,
sha256: hash,
})
),
};
}
function createThirdPartyNotices(packages) {
const lines = [
'IPTVnator Linux Embedded MPV Third-Party Notices',
'================================================',
'',
'This file identifies the pinned upstream source packages used by the bundled Linux Embedded MPV runtime.',
'The complete verbatim upstream license files are included at the paths and SHA-256 digests listed below.',
'The exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources.tar.xz.',
'',
];
for (const packageRecord of packages) {
lines.push(
`${packageRecord.id} ${packageRecord.version}`,
`License: ${packageRecord.license}`,
`Source: ${packageRecord.sourceUrl}`,
'Included upstream license files:'
);
for (const file of packageRecord.files) {
lines.push(`- ${file.path} (SHA-256 ${file.sha256})`);
}
lines.push('');
}
return `${lines.join('\n')}\n`;
}
function generateLinuxRuntimeNotices({
licenseInputRoot,
outputRoot,
runtimeManifest,
}) {
const inputManifest = validateLicenseInputManifest(
licenseInputRoot,
runtimeManifest
);
return replaceDirectory(outputRoot, (temporaryRoot) => {
const packages = NOTICE_SOURCE_PACKAGES.map((sourcePackage, index) => {
const inputPackage = inputManifest.packages[index];
for (const file of inputPackage.files) {
const sourcePath = assertRegularFileInside(
licenseInputRoot,
file.path,
`${sourcePackage.id} cached license`
);
const destinationPath = path.join(
temporaryRoot,
...file.path.split('/')
);
fs.mkdirSync(path.dirname(destinationPath), {
recursive: true,
});
fs.copyFileSync(sourcePath, destinationPath);
fs.chmodSync(destinationPath, 0o644);
}
return noticePackageRecord(sourcePackage, inputPackage);
});
const noticeContents = Buffer.from(createThirdPartyNotices(packages));
const noticePath = path.join(temporaryRoot, THIRD_PARTY_NOTICES);
fs.writeFileSync(noticePath, noticeContents, { mode: 0o644 });
const noticeFile = fileRecord(noticePath, THIRD_PARTY_NOTICES);
const licenseTotalBytes = packages
.flatMap(({ files }) => files)
.reduce((total, file) => total + file.size, 0);
const manifest = {
schemaVersion: 1,
origin: 'pinned-linux-runtime-upstream-licenses',
platform: 'linux',
arch: 'x64',
noticeFile,
packages,
totalBytes: noticeFile.size + licenseTotalBytes,
};
writeJson(path.join(temporaryRoot, NOTICE_MANIFEST), manifest);
const errors = validateLinuxRuntimeNotices(
temporaryRoot,
runtimeManifest
);
if (errors.length > 0) {
throw new Error(
[
'Generated Linux runtime notices are invalid.',
...errors.map((error) => `- ${error}`),
].join('\n')
);
}
return manifest;
});
}
function assertValidLinuxRuntimeNotices(
root,
runtimeManifest,
{ allowUnrelatedFiles = false } = {}
) {
validateRuntimeManifestPackages(runtimeManifest);
assertDirectoryWithoutSymlinks(root, 'Linux runtime notices root');
const manifest = readJsonFileInside(
root,
NOTICE_MANIFEST,
'Linux runtime notices manifest'
);
if (
!hasExactFields(manifest, [
'schemaVersion',
'origin',
'platform',
'arch',
'noticeFile',
'packages',
'totalBytes',
]) ||
manifest.schemaVersion !== 1 ||
manifest.origin !== 'pinned-linux-runtime-upstream-licenses' ||
manifest.platform !== 'linux' ||
manifest.arch !== 'x64' ||
!Array.isArray(manifest.packages) ||
manifest.packages.length !== NOTICE_SOURCE_PACKAGES.length
) {
throw new Error('Invalid Linux runtime notices manifest.');
}
const expectedFiles = new Set([NOTICE_MANIFEST, THIRD_PARTY_NOTICES]);
let licenseTotalBytes = 0;
for (const [index, sourcePackage] of NOTICE_SOURCE_PACKAGES.entries()) {
const packageRecord = manifest.packages[index];
const expectedMetadata = sourcePackageMetadata(sourcePackage);
const expectedPaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id].map(
(sourceRelativePath) =>
expectedLicensePath(sourcePackage.id, sourceRelativePath)
);
if (
!hasExactFields(packageRecord, [
'id',
...Object.keys(expectedMetadata),
'files',
]) ||
packageRecord.id !== sourcePackage.id ||
!Object.entries(expectedMetadata).every(([field, value]) =>
isDeepStrictEqual(packageRecord[field], value)
) ||
!Array.isArray(packageRecord.files) ||
!isDeepStrictEqual(
packageRecord.files.map(({ path: filePath }) => filePath),
expectedPaths
)
) {
throw new Error(
`Invalid packaged notice record for ${sourcePackage.id}.`
);
}
for (const record of packageRecord.files) {
if (
!hasExactFields(record, ['path', 'size', 'sha256']) ||
!Number.isSafeInteger(record.size) ||
record.size <= 0 ||
!SHA256_PATTERN.test(record.sha256)
) {
throw new Error(
`Invalid packaged notice file for ${sourcePackage.id}.`
);
}
const filePath = assertRegularFileInside(
root,
record.path,
`${sourcePackage.id} packaged license`
);
const actual = fileRecord(filePath, record.path);
if (actual.size !== record.size) {
throw new Error(
`Size mismatch for packaged license ${record.path}.`
);
}
if (actual.sha256 !== record.sha256) {
throw new Error(
`SHA-256 mismatch for packaged license ${record.path}.`
);
}
licenseTotalBytes += record.size;
expectedFiles.add(record.path);
}
}
if (
!hasExactFields(manifest.noticeFile, ['path', 'size', 'sha256']) ||
manifest.noticeFile.path !== THIRD_PARTY_NOTICES ||
!Number.isSafeInteger(manifest.noticeFile.size) ||
manifest.noticeFile.size <= 0 ||
!SHA256_PATTERN.test(manifest.noticeFile.sha256)
) {
throw new Error('Invalid aggregate third-party notice file record.');
}
const noticePath = assertRegularFileInside(
root,
THIRD_PARTY_NOTICES,
'aggregate third-party notices'
);
const actualNotice = fileRecord(noticePath, THIRD_PARTY_NOTICES);
if (
actualNotice.size !== manifest.noticeFile.size ||
actualNotice.sha256 !== manifest.noticeFile.sha256
) {
throw new Error(
'Aggregate THIRD_PARTY_NOTICES.txt size or SHA-256 mismatch.'
);
}
const expectedNoticeContents = createThirdPartyNotices(manifest.packages);
if (fs.readFileSync(noticePath, 'utf8') !== expectedNoticeContents) {
throw new Error(
'Aggregate THIRD_PARTY_NOTICES.txt does not match the exact notice index.'
);
}
if (manifest.totalBytes !== actualNotice.size + licenseTotalBytes) {
throw new Error(
'Linux runtime notices totalBytes does not match declared legal files.'
);
}
const actualPaths = allowUnrelatedFiles
? [
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
...listFilesRecursively(path.join(root, 'licenses')).map(
(relativePath) => path.posix.join('licenses', relativePath)
),
]
: listFilesRecursively(root);
for (const actualPath of actualPaths) {
if (!expectedFiles.has(actualPath)) {
throw new Error(
`Found undeclared packaged legal file ${actualPath} in ${root}.`
);
}
}
return manifest;
}
function validateLinuxRuntimeNotices(root, runtimeManifest, options) {
try {
assertValidLinuxRuntimeNotices(root, runtimeManifest, options);
return [];
} catch (error) {
return [error instanceof Error ? error.message : String(error)];
}
}
function parseArguments(argv) {
const args = argv[0] === '--' ? argv.slice(1) : [...argv];
const mode = args.shift();
if (!['collect', 'generate'].includes(mode)) {
throw new Error(
'Usage: generate-linux-runtime-notices.cjs <collect|generate> --runtime-manifest <path> --output-root <path> [--source-root <path>|--license-input-root <path>]'
);
}
const values = {};
while (args.length > 0) {
const key = args.shift();
const value = args.shift();
if (!key?.startsWith('--') || !value) {
throw new Error(`Invalid Linux runtime notices argument: ${key}`);
}
const name = key.slice(2);
if (Object.hasOwn(values, name)) {
throw new Error(`Duplicate Linux runtime notices argument: ${key}`);
}
values[name] = value;
}
const required = [
'runtime-manifest',
'output-root',
mode === 'collect' ? 'source-root' : 'license-input-root',
];
for (const name of required) {
if (!values[name]) {
throw new Error(`Missing --${name}.`);
}
}
return { mode, values };
}
function main(argv = process.argv.slice(2)) {
const { mode, values } = parseArguments(argv);
const runtimeManifest = JSON.parse(
fs.readFileSync(path.resolve(values['runtime-manifest']), 'utf8')
);
if (mode === 'collect') {
collectLinuxRuntimeLicenseInputs({
sourceRoot: path.resolve(values['source-root']),
outputRoot: path.resolve(values['output-root']),
runtimeManifest,
});
} else {
generateLinuxRuntimeNotices({
licenseInputRoot: path.resolve(values['license-input-root']),
outputRoot: path.resolve(values['output-root']),
runtimeManifest,
});
}
}
if (require.main === module) {
try {
main();
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`
);
process.exitCode = 1;
}
}
module.exports = {
LICENSE_INPUT_MANIFEST,
LICENSE_PATHS_BY_PACKAGE,
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
collectLinuxRuntimeLicenseInputs,
generateLinuxRuntimeNotices,
validateLinuxRuntimeNotices,
};
@@ -0,0 +1,338 @@
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 { createRequire } from 'node:module';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
const { SOURCE_PACKAGES } = require('./build-linux-runtime.cjs');
const {
LICENSE_PATHS_BY_PACKAGE,
collectLinuxRuntimeLicenseInputs,
generateLinuxRuntimeNotices,
validateLinuxRuntimeNotices,
} = require('./generate-linux-runtime-notices.cjs');
const generatorScript = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'generate-linux-runtime-notices.cjs'
);
function runtimeManifest() {
return {
platform: 'linux',
arch: 'x64',
packages: Object.fromEntries(
SOURCE_PACKAGES.map((sourcePackage) => [
sourcePackage.id,
{
version: sourcePackage.version,
sourceUrl: sourcePackage.sourceUrl,
...(sourcePackage.sourceTag
? { sourceTag: sourcePackage.sourceTag }
: {}),
...(sourcePackage.sourceKind === 'archive'
? { sourceSha256: sourcePackage.expectedSha256 }
: {
sourceGitCommit: sourcePackage.expectedGitCommit,
sourceSubmodules: [
`${'a'.repeat(40)} 3rdparty/example`,
],
}),
license: sourcePackage.license,
...(sourcePackage.buildInput
? { buildInput: sourcePackage.buildInput }
: {}),
},
])
),
};
}
function createSourceFixture() {
const root = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-license-sources-')
);
for (const sourcePackage of SOURCE_PACKAGES) {
const licensePaths = LICENSE_PATHS_BY_PACKAGE[sourcePackage.id];
assert.ok(
Array.isArray(licensePaths) && licensePaths.length > 0,
`missing test mapping for ${sourcePackage.id}`
);
for (const relativePath of licensePaths) {
const filePath = path.join(root, sourcePackage.id, relativePath);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(
filePath,
`verbatim upstream ${sourcePackage.id} ${relativePath}\n`
);
}
}
return root;
}
function fileTree(root) {
const files = [];
function visit(directoryPath) {
for (const entry of fs
.readdirSync(directoryPath, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = path.join(directoryPath, entry.name);
if (entry.isDirectory()) {
visit(entryPath);
} else {
files.push({
path: path
.relative(root, entryPath)
.split(path.sep)
.join('/'),
contents: fs.readFileSync(entryPath),
});
}
}
}
visit(root);
return files;
}
test('collects verbatim pinned licenses and generates deterministic exact notices', (t) => {
const sourceRoot = createSourceFixture();
const fixtureRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-notices-')
);
const inputRoot = path.join(fixtureRoot, 'inputs');
const firstOutput = path.join(fixtureRoot, 'first');
const secondOutput = path.join(fixtureRoot, 'second');
t.after(() => {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
const manifest = runtimeManifest();
collectLinuxRuntimeLicenseInputs({
sourceRoot,
outputRoot: inputRoot,
runtimeManifest: manifest,
});
const first = generateLinuxRuntimeNotices({
licenseInputRoot: inputRoot,
outputRoot: firstOutput,
runtimeManifest: manifest,
});
const second = generateLinuxRuntimeNotices({
licenseInputRoot: inputRoot,
outputRoot: secondOutput,
runtimeManifest: manifest,
});
assert.deepEqual(fileTree(firstOutput), fileTree(secondOutput));
assert.deepEqual(first, second);
assert.equal(first.schemaVersion, 1);
assert.equal(first.origin, 'pinned-linux-runtime-upstream-licenses');
assert.deepEqual(
first.packages.map(({ id }) => id),
SOURCE_PACKAGES.map(({ id }) => id).sort()
);
assert.ok(first.packages.every(({ files }) => files.length >= 1));
assert.deepEqual(LICENSE_PATHS_BY_PACKAGE.libplacebo, [
'LICENSE',
'3rdparty/Vulkan-Headers/LICENSE.md',
'3rdparty/fast_float/LICENSE-APACHE',
'3rdparty/fast_float/LICENSE-BOOST',
'3rdparty/fast_float/LICENSE-MIT',
'3rdparty/glad/LICENSE',
'3rdparty/jinja/LICENSE.txt',
'3rdparty/markupsafe/LICENSE.txt',
'demos/3rdparty/nuklear/LICENSE',
]);
assert.deepEqual(validateLinuxRuntimeNotices(firstOutput, manifest), []);
const noticeContents = fs.readFileSync(
path.join(firstOutput, 'THIRD_PARTY_NOTICES.txt')
);
assert.equal(first.noticeFile.path, 'THIRD_PARTY_NOTICES.txt');
assert.equal(first.noticeFile.size, noticeContents.length);
assert.equal(
first.noticeFile.sha256,
crypto.createHash('sha256').update(noticeContents).digest('hex')
);
assert.match(
noticeContents.toString('utf8'),
/exact corresponding sources and build scripts are distributed alongside the binary release as linux-frame-copy-runtime-sources\.tar\.xz/
);
for (const packageRecord of first.packages) {
for (const fileRecord of packageRecord.files) {
const outputContents = fs.readFileSync(
path.join(firstOutput, fileRecord.path)
);
const sourcePath = fileRecord.path.slice(
`licenses/${packageRecord.id}/`.length
);
assert.ok(
LICENSE_PATHS_BY_PACKAGE[packageRecord.id].includes(sourcePath)
);
assert.deepEqual(
outputContents,
fs.readFileSync(
path.join(sourceRoot, packageRecord.id, sourcePath)
)
);
}
}
});
test('rejects symlinked license sources and path escapes', (t) => {
const sourceRoot = createSourceFixture();
const outputRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-license-inputs-')
);
const outsidePath = path.join(outputRoot, 'outside-license');
fs.writeFileSync(outsidePath, 'outside\n');
t.after(() => {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(outputRoot, { recursive: true, force: true });
});
const freetypeLicense = path.join(
sourceRoot,
'freetype',
LICENSE_PATHS_BY_PACKAGE.freetype[0]
);
fs.rmSync(freetypeLicense);
fs.symlinkSync(outsidePath, freetypeLicense);
assert.throws(
() =>
collectLinuxRuntimeLicenseInputs({
sourceRoot,
outputRoot: path.join(outputRoot, 'collected'),
runtimeManifest: runtimeManifest(),
}),
/symbolic link|outside/i
);
});
test('rejects missing, tampered, and undeclared cached license inputs', (t) => {
const sourceRoot = createSourceFixture();
const fixtureRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-license-cache-')
);
const inputRoot = path.join(fixtureRoot, 'inputs');
t.after(() => {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
const manifest = runtimeManifest();
collectLinuxRuntimeLicenseInputs({
sourceRoot,
outputRoot: inputRoot,
runtimeManifest: manifest,
});
const collectedManifest = JSON.parse(
fs.readFileSync(
path.join(inputRoot, 'linux-runtime-license-inputs.json'),
'utf8'
)
);
const firstLicensePath = path.join(
inputRoot,
collectedManifest.packages[0].files[0].path
);
fs.appendFileSync(firstLicensePath, 'tampered\n');
assert.throws(
() =>
generateLinuxRuntimeNotices({
licenseInputRoot: inputRoot,
outputRoot: path.join(fixtureRoot, 'tampered-output'),
runtimeManifest: manifest,
}),
/(?:Size|SHA-256) mismatch/
);
collectLinuxRuntimeLicenseInputs({
sourceRoot,
outputRoot: inputRoot,
runtimeManifest: manifest,
});
fs.writeFileSync(path.join(inputRoot, 'licenses', 'undeclared.txt'), 'x');
assert.throws(
() =>
generateLinuxRuntimeNotices({
licenseInputRoot: inputRoot,
outputRoot: path.join(fixtureRoot, 'extra-output'),
runtimeManifest: manifest,
}),
/undeclared license input/
);
fs.rmSync(path.join(inputRoot, 'licenses', 'undeclared.txt'));
fs.rmSync(
path.join(inputRoot, collectedManifest.packages[0].files[0].path)
);
assert.throws(
() =>
generateLinuxRuntimeNotices({
licenseInputRoot: inputRoot,
outputRoot: path.join(fixtureRoot, 'missing-output'),
runtimeManifest: manifest,
}),
/Missing .*license input/
);
});
test('CLI collects immutable inputs and regenerates the packaged notice bundle', (t) => {
const sourceRoot = createSourceFixture();
const fixtureRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'iptvnator-linux-notices-cli-')
);
const manifestPath = path.join(fixtureRoot, 'runtime-manifest.json');
const inputRoot = path.join(fixtureRoot, 'inputs');
const outputRoot = path.join(fixtureRoot, 'notices');
fs.writeFileSync(
manifestPath,
`${JSON.stringify(runtimeManifest(), null, 2)}\n`
);
t.after(() => {
fs.rmSync(sourceRoot, { recursive: true, force: true });
fs.rmSync(fixtureRoot, { recursive: true, force: true });
});
for (const args of [
[
'collect',
'--runtime-manifest',
manifestPath,
'--source-root',
sourceRoot,
'--output-root',
inputRoot,
],
[
'generate',
'--runtime-manifest',
manifestPath,
'--license-input-root',
inputRoot,
'--output-root',
outputRoot,
],
]) {
const result = spawnSync(process.execPath, [generatorScript, ...args], {
encoding: 'utf8',
});
assert.equal(
result.status,
0,
[result.stdout, result.stderr].filter(Boolean).join('\n')
);
}
assert.deepEqual(
validateLinuxRuntimeNotices(outputRoot, runtimeManifest()),
[]
);
});
@@ -66,6 +66,15 @@ test('configures portable and flatpak passes without mixing targets', () => {
assert.deepEqual(configuredTargets(flatpak), [
{ target: 'flatpak', arch: ['x64'] },
]);
assert.deepEqual(portable.snap.plugs, [
'default',
{
'shared-memory': {
interface: 'shared-memory',
private: true,
},
},
]);
});
test('configures a separate marker-only foreign DEB pass without libmpv metadata', () => {
@@ -173,6 +182,60 @@ test('Linux CI builds one cached source runtime and packages three isolated prof
buildWorkflow,
/key: \$\{\{ steps\.linux-runtime-cache-key\.outputs\.key \}\}/
);
const cacheStep = workflowStep(
'Restore pinned Linux runtime and immutable source inputs'
);
assert.match(cacheStep, /dist\/linux-frame-copy-runtime-source-inputs/);
assert.doesNotMatch(cacheStep, /linux-frame-copy-runtime-sources\.tar\.xz/);
assert.doesNotMatch(cacheStep, /THIRD_PARTY_NOTICES/);
const complianceStep = workflowStep(
'Generate Linux runtime notices and assemble source compliance'
);
assert.doesNotMatch(complianceStep, /^\s+if:/m);
assert.match(
complianceStep,
/generate-linux-runtime-notices\.cjs generate/
);
assert.match(complianceStep, /git rev-parse HEAD/);
assert.match(complianceStep, /git diff --binary HEAD/);
assert.match(
complianceStep,
/tar[\s\S]*linux-frame-copy-runtime-sources\.tar\.xz/
);
assert.match(complianceStep, /source-index\.json/);
assert.match(complianceStep, /THIRD_PARTY_NOTICES\.txt/);
assert.match(complianceStep, /embedded-mpv-notices\.json/);
assert.match(
complianceStep,
/SOURCE_INPUT_ROOT.*license-inputs[\s\S]*SOURCE_BUNDLE_ROOT.*license-inputs/
);
assert.match(
complianceStep,
/new Set\(expectedArchiveHashes\)\.size[\s\S]*archives\.length !== expectedArchiveHashes\.length/
);
assert.match(
complianceStep,
/status[\s\S]*--porcelain=v1[\s\S]*--untracked-files=all[\s\S]*--ignore-submodules=none/
);
assert.match(
complianceStep,
/for \(const submoduleRecord of sourceSubmodules\)/
);
const buildStep = workflowStep('Build and stage pinned LGPL Linux runtime');
assert.match(buildStep, /generate-linux-runtime-notices\.cjs collect/);
assert.match(
buildStep,
/linux-frame-copy-runtime-source-inputs[\s\S]*archives[\s\S]*git\/libplacebo/
);
assert.ok(
buildStep.indexOf('git clean -ffdqx') <
buildStep.indexOf(
'cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo"'
)
);
assert.doesNotMatch(buildStep, /linux-frame-copy-runtime-sources\.tar\.xz/);
assert.match(
buildWorkflow,
/IPTVNATOR_LINUX_FRAME_COPY_PROFILE: \$\{\{ matrix\.linux_profile/
+14 -1
View File
@@ -54,10 +54,23 @@ function copyEmbeddedMpvNativeOutput(
fs.rmSync(destinationDir, { recursive: true, force: true });
fs.cpSync(sourceDir, destinationDir, { recursive: true });
const resolvedPreparationOptions =
platform === 'linux' && preparationOptions
? {
...preparationOptions,
noticeSourceDir: path.join(
projectDir,
'vendor',
'embedded-mpv',
'linux-x64',
'notices'
),
}
: preparationOptions;
return preparePackagedFrameCopyArtifacts(
destinationDir,
platform,
preparationOptions
resolvedPreparationOptions
);
}
+140 -1
View File
@@ -25,6 +25,11 @@ const {
const {
preparePackagedFrameCopyArtifacts,
} = require('./embedded-mpv-frame-copy-files.cjs');
const {
LICENSE_PATHS_BY_PACKAGE,
collectLinuxRuntimeLicenseInputs,
generateLinuxRuntimeNotices,
} = require('../embedded-mpv/generate-linux-runtime-notices.cjs');
const {
resolveLinuxFrameCopyPackagingContext,
} = require('./electron-after-pack.cjs');
@@ -170,6 +175,33 @@ function createBuildManifest(runtimeContents) {
};
}
function createNoticeFixture(fixtureRoot, sourceRuntime) {
const sourceRoot = join(fixtureRoot, 'upstream-license-sources');
for (const sourcePackage of SOURCE_PACKAGES) {
for (const relativePath of LICENSE_PATHS_BY_PACKAGE[sourcePackage.id]) {
const sourcePath = join(sourceRoot, sourcePackage.id, relativePath);
fs.mkdirSync(dirname(sourcePath), { recursive: true });
fs.writeFileSync(
sourcePath,
`verbatim ${sourcePackage.id} ${relativePath}\n`
);
}
}
const licenseInputRoot = join(fixtureRoot, 'license-inputs');
const noticeSourceDir = join(fixtureRoot, 'generated-notices');
collectLinuxRuntimeLicenseInputs({
sourceRoot,
outputRoot: licenseInputRoot,
runtimeManifest: sourceRuntime,
});
generateLinuxRuntimeNotices({
licenseInputRoot,
outputRoot: noticeSourceDir,
runtimeManifest: sourceRuntime,
});
return noticeSourceDir;
}
function createNativeFixture({
runtimeContents = {
'libavcodec.so.61': 'libavcodec-runtime',
@@ -205,8 +237,19 @@ function createNativeFixture({
for (const [name, contents] of Object.entries(runtimeContents)) {
fs.writeFileSync(join(nativeDir, 'lib', name), contents);
}
const noticeSourceDir = createNoticeFixture(
fixtureRoot,
buildManifest.sourceRuntime
);
fs.cpSync(noticeSourceDir, nativeDir, { recursive: true });
fs.writeFileSync(join(appOutDir, 'iptvnator.bin'), 'electron');
return { fixtureRoot, resourceDir, appOutDir, nativeDir };
return {
fixtureRoot,
resourceDir,
appOutDir,
nativeDir,
noticeSourceDir,
};
}
function readManifest(nativeDir) {
@@ -462,6 +505,13 @@ test('prepares a normalized system profile with no private runtime', (t) => {
fs.existsSync(join(fixture.nativeDir, 'embedded-mpv-unavailable.txt')),
false
);
for (const legalPath of [
'embedded-mpv-notices.json',
'THIRD_PARTY_NOTICES.txt',
'licenses',
]) {
assert.equal(fs.existsSync(join(fixture.nativeDir, legalPath)), false);
}
assert.deepEqual(
validatePackagedEmbeddedMpv(
fixture.resourceDir,
@@ -482,6 +532,16 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', (
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true });
}
});
for (const legalPath of [
'embedded-mpv-notices.json',
'THIRD_PARTY_NOTICES.txt',
'licenses',
]) {
fs.rmSync(join(portable.nativeDir, legalPath), {
recursive: true,
force: true,
});
}
const portableManifest = preparePackagedFrameCopyArtifacts(
portable.nativeDir,
@@ -489,6 +549,7 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', (
{
profile: 'portable',
targetNames: ['AppImage', 'SNAP'],
noticeSourceDir: portable.noticeSourceDir,
}
);
const flatpakManifest = preparePackagedFrameCopyArtifacts(
@@ -526,6 +587,20 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', (
'--disable-gpl'
)
);
const notices = JSON.parse(
fs.readFileSync(
join(portable.nativeDir, 'embedded-mpv-notices.json'),
'utf8'
)
);
assert.equal(notices.schemaVersion, 1);
assert.equal(notices.origin, 'pinned-linux-runtime-upstream-licenses');
assert.equal(notices.noticeFile.path, 'THIRD_PARTY_NOTICES.txt');
assert.deepEqual(
notices.packages.map(({ id }) => id),
SOURCE_PACKAGES.map(({ id }) => id).sort()
);
assert.ok(notices.packages.every(({ files }) => files.length >= 1));
assert.equal(flatpakManifest.profile, 'flatpak');
assert.equal(flatpakManifest.runtimeMode, 'bundled');
assert.equal(flatpakManifest.origin, 'bundled-lgpl-frame-copy');
@@ -551,6 +626,70 @@ test('prepares portable and Flatpak manifests with the exact bundled closure', (
);
});
test('rejects missing, tampered, undeclared, and symlinked bundled legal files', (t) => {
const fixture = createNativeFixture();
t.after(() =>
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
);
const manifest = preparePackagedFrameCopyArtifacts(
fixture.nativeDir,
'linux',
{
profile: 'portable',
targetNames: ['appimage'],
}
);
const options = pureValidationOptions({
profile: 'portable',
targetNames: ['appimage'],
hostPlatform: 'linux',
elfInspector: validElfInspector(fixture.nativeDir, manifest),
});
const notices = JSON.parse(
fs.readFileSync(
join(fixture.nativeDir, 'embedded-mpv-notices.json'),
'utf8'
)
);
const licensePath = join(
fixture.nativeDir,
notices.packages[0].files[0].path
);
const originalContents = fs.readFileSync(licensePath);
fs.appendFileSync(licensePath, 'tampered\n');
assert.match(
validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'),
/(?:Size|SHA-256) mismatch.*packaged license/i
);
fs.writeFileSync(licensePath, originalContents);
fs.rmSync(licensePath);
assert.match(
validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'),
/Missing .*packaged license/i
);
fs.writeFileSync(licensePath, originalContents);
const undeclaredPath = join(fixture.nativeDir, 'licenses', 'stale.txt');
fs.writeFileSync(undeclaredPath, 'stale\n');
assert.match(
validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'),
/undeclared packaged legal file.*stale\.txt/i
);
fs.rmSync(undeclaredPath);
fs.rmSync(licensePath);
fs.symlinkSync(
join(fixture.nativeDir, 'THIRD_PARTY_NOTICES.txt'),
licensePath
);
assert.match(
validatePackagedEmbeddedMpv(fixture.resourceDir, options).join('\n'),
/packaged license.*symbolic link/i
);
});
test('rejects invalid build provenance and incomplete bundled runtime input', (t) => {
const extra = createNativeFixture();
const missing = createNativeFixture();
@@ -7,6 +7,11 @@ const { isDeepStrictEqual } = require('node:util');
const {
validateLinuxRuntimeManifest,
} = require('../embedded-mpv/linux-runtime-manifest.cjs');
const {
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
validateLinuxRuntimeNotices,
} = require('../embedded-mpv/generate-linux-runtime-notices.cjs');
const {
LINUX_SYSTEM_PACKAGE_DEPENDENCIES,
resolveLinuxFrameCopyProfile,
@@ -19,6 +24,7 @@ const FRAME_COPY_READER = 'embedded_mpv_frame_reader.node';
const EMBEDDED_MPV_ADDON = 'embedded_mpv.node';
const RUNTIME_MANIFEST = 'embedded-mpv-runtime.json';
const UNAVAILABLE_MARKER = 'embedded-mpv-unavailable.txt';
const LICENSES_DIRECTORY = 'licenses';
const VERSIONED_LIBMPV_PATTERN = /^libmpv\.so\.\d+(?:\.\d+)*$/;
const EXPECTED_ARTIFACTS = Object.freeze({
addon: EMBEDDED_MPV_ADDON,
@@ -302,6 +308,70 @@ function writeManifest(nativeDir, manifest) {
return manifest;
}
function removeLinuxRuntimeNotices(nativeDir) {
for (const relativePath of [
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
LICENSES_DIRECTORY,
'notices',
]) {
fs.rmSync(path.join(nativeDir, relativePath), {
recursive: true,
force: true,
});
}
}
function prepareBundledLinuxRuntimeNotices(
nativeDir,
sourceRuntime,
noticeSourceDir
) {
if (noticeSourceDir) {
const sourceErrors = validateLinuxRuntimeNotices(
noticeSourceDir,
sourceRuntime
);
if (sourceErrors.length > 0) {
throw new Error(
[
`Invalid Linux runtime notice source at ${noticeSourceDir}.`,
...sourceErrors.map((error) => `- ${error}`),
].join('\n')
);
}
removeLinuxRuntimeNotices(nativeDir);
for (const relativePath of [
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
LICENSES_DIRECTORY,
]) {
fs.cpSync(
path.join(noticeSourceDir, relativePath),
path.join(nativeDir, relativePath),
{
recursive: true,
force: true,
}
);
}
}
const packagedErrors = validateLinuxRuntimeNotices(
nativeDir,
sourceRuntime,
{ allowUnrelatedFiles: true }
);
if (packagedErrors.length > 0) {
throw new Error(
[
`Invalid packaged Linux runtime notices at ${nativeDir}.`,
...packagedErrors.map((error) => `- ${error}`),
].join('\n')
);
}
}
function createPackagedManifest(buildManifest, profile, targetNames) {
const bundled = profile.runtimeMode === 'bundled';
const runtimeFiles = bundled
@@ -390,6 +460,7 @@ function prepareNativeViewOnlyLinuxArtifacts(nativeDir) {
fs.rmSync(path.join(nativeDir, fileName), { force: true });
}
fs.rmSync(path.join(nativeDir, 'lib'), { recursive: true, force: true });
removeLinuxRuntimeNotices(nativeDir);
return writeManifest(nativeDir, {
schemaVersion: 1,
@@ -448,6 +519,13 @@ function prepareLinuxFrameCopyArtifacts(nativeDir, options = {}) {
recursive: true,
force: true,
});
removeLinuxRuntimeNotices(nativeDir);
} else {
prepareBundledLinuxRuntimeNotices(
nativeDir,
buildManifest.sourceRuntime,
options.noticeSourceDir
);
}
return writeManifest(
@@ -481,6 +559,7 @@ function removeStaleFrameCopyArtifacts(nativeDir) {
]) {
fs.rmSync(path.join(nativeDir, fileName), { force: true });
}
removeLinuxRuntimeNotices(nativeDir);
}
module.exports = {
@@ -12,6 +12,11 @@ const {
const {
validateLinuxRuntimeManifest,
} = require('../embedded-mpv/linux-runtime-manifest.cjs');
const {
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
validateLinuxRuntimeNotices,
} = require('../embedded-mpv/generate-linux-runtime-notices.cjs');
const {
LINUX_SYSTEM_PACKAGE_DEPENDENCIES,
resolveLinuxFrameCopyProfile,
@@ -54,6 +59,11 @@ const linuxFrameCopyProcessIsolation = Object.freeze({
const linuxNativeViewFallback = 'process-isolated mpv --wid';
const versionedLinuxLibmpvPattern = /^libmpv\.so\.\d+(?:\.\d+)*$/;
const anyLinuxLibmpvPattern = /^libmpv\.so(?:\.|$)/;
const linuxRuntimeLegalPaths = Object.freeze([
NOTICE_MANIFEST,
THIRD_PARTY_NOTICES,
'licenses',
]);
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
@@ -887,6 +897,14 @@ function validateNativeViewOnlyLinuxPackage(
manifestPath,
errors
) {
for (const legalPath of linuxRuntimeLegalPaths) {
const packagedLegalPath = path.join(nativeDir, legalPath);
if (pathExistsByLstat(packagedLegalPath)) {
errors.push(
`Linux native-view-only packages must not ship bundled runtime legal files: ${packagedLegalPath}`
);
}
}
for (const artifactName of [
'iptvnator_mpv_helper',
'iptvnator_mpv_helper.exe',
@@ -1037,6 +1055,14 @@ function validatePackagedManifestContract(
}
function validateSystemLinuxRuntime(nativeDir, manifest, errors) {
for (const legalPath of linuxRuntimeLegalPaths) {
const packagedLegalPath = path.join(nativeDir, legalPath);
if (pathExistsByLstat(packagedLegalPath)) {
errors.push(
`Linux system frame-copy packages must not ship bundled runtime legal files: ${packagedLegalPath}`
);
}
}
if (
!isDeepStrictEqual(
manifest.packageDependencies,
@@ -1101,6 +1127,11 @@ function validateBundledLinuxRuntime(nativeDir, manifest, errors) {
(error) => `Invalid packaged Linux source runtime: ${error}`
)
);
errors.push(
...validateLinuxRuntimeNotices(nativeDir, manifest.sourceRuntime, {
allowUnrelatedFiles: true,
}).map((error) => `Invalid packaged Linux runtime notices: ${error}`)
);
if (
!isDeepStrictEqual(
manifest.runtimeFiles,
+4 -2
View File
@@ -30,18 +30,20 @@
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs",
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.mjs",
"{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.test.mjs",
"{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.cjs",
"{workspaceRoot}/tools/embedded-mpv/generate-linux-runtime-notices.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/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/linux-runtime-manifest.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/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs",
"cwd": "{workspaceRoot}"
}
},
"lint": {
"command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\""
"command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\""
}
},
"tags": ["scope:tools", "domain:packaging", "type:tool"]