mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
feat(packaging): ship Linux runtime license notices
This commit is contained in:
1 parent
601501a4b0
commit
1477079509
10 files changed
+1548
-27
No files matched your search
@@ -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}" \
|
||||
.
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in new issue
Block a user