mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* docs: design Linux frame-copy packaging * docs: plan Linux frame-copy packaging * feat(packaging): define Linux frame-copy profiles * fix(packaging): reject inherited profile names * feat(embedded-mpv): validate staged Linux runtime * fix(embedded-mpv): require Linux source packages * fix(embedded-mpv): harden Linux runtime staging * feat(embedded-mpv): build LGPL Linux runtime * fix(embedded-mpv): pin Linux runtime inputs * feat(embedded-mpv): build relocatable Linux helper * fix(embedded-mpv): require bundled Linux runtime * fix(embedded-mpv): make Linux runtime portable * feat(packaging): ship Linux frame-copy artifacts * fix(embedded-mpv): verify Linux helper linkage * fix(packaging): enforce Linux frame-copy isolation * fix(embedded-mpv): pin Linux display data * docs(embedded-mpv): document Linux frame-copy packaging * feat(embedded-mpv): probe Linux frame-copy runtime * test(embedded-mpv): smoke packaged Linux frame-copy * docs(embedded-mpv): clarify Linux system runtime baseline * fix(embedded-mpv): harden Linux runtime capability gate * ci: verify Linux frame-copy packages * test(embedded-mpv): harden packaged Linux smoke * test(embedded-mpv): preserve packaged GL mode * test(packaging): harden Linux package probes * fix(embedded-mpv): enable private Snap shared memory * fix(embedded-mpv): sanitize Linux helper environment * fix(packaging): enforce private Snap memory semantics * fix(packaging): reject ambiguous Snap memory metadata * fix(embedded-mpv): prioritize trusted Snap GL * fix(packaging): reject advanced Snap YAML semantics * fix(packaging): reject arbitrary Snap YAML aliases * feat(packaging): ship Linux runtime license notices * docs(embedded-mpv): document Linux runtime distribution * fix(packaging): parse Snap trailing comments safely * fix(release): gate Snap publish on public source release * fix(packaging): strip VCS metadata from source bundle * docs(packaging): clarify Linux source release gate * test(embedded-mpv): smoke missing bundled libmpv * style(embedded-mpv): format final validation inputs * fix(e2e): satisfy fixture index signature typing * fix(ci): declare fontconfig gperf generator * fix(embedded-mpv): hash runtime cache identities * fix(packaging): harden Linux frame-copy delivery * fix(packaging): tighten runtime delivery gates * fix(ci): decouple Linux runtime matrix * fix(packaging): harden Linux frame-copy delivery * fix(packaging): validate Linux frame-copy runtimes * fix(packaging): scope Snap Electron library checks * feat(packaging): ship Linux frame-copy runtimes * fix(packaging): improve Linux runtime smoke diagnostics * fix(packaging): expose bounded helper probe details * test(packaging): trace Snap EGL probe failures * fix(packaging): prefer core22 ABI in Snap helper * fix(packaging): bound helper probe capture * fix(packaging): harden Linux frame-copy releases * fix(packaging): canonicalize libplacebo submodule identity * fix(packaging): make source archive inspection portable * fix(packaging): harden Snap release verification
754 lines
26 KiB
JavaScript
754 lines
26 KiB
JavaScript
#!/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,
|
|
};
|