mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
The publish-snap verifier had never run against a real release and encoded three stale expectations that the tag build's own validators do not share: - it required the app under usr/lib/iptvnator inside the snap, while Electron Builder's snap target ships the app at the snap root (/iptvnator.bin, /resources/**) — the layout the packaged smoke tests exercise; - it validated the source archive's runtime manifest with the raw source-build validator, but the archive carries the STAGED manifest (origin "vendored-lgpl" + sourceBuildOrigin) written by stage-runtime.mjs; the staged envelope is now checked explicitly and the remaining fields still go through the shared validator via an origin projection; - it deep-equaled the snap's bundled sourceRuntime against the archive manifest, but the snap bundles the builder view (no staging envelope); the binding now projects the envelope away first. Verified end-to-end in a Linux container against the real v0.23.0 release assets: release-snap-assets.cjs verify now passes and emits the sealed snapshot receipt. Regression tests cover the legacy usr/lib layout and staged-envelope mismatches. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1800 lines
57 KiB
JavaScript
1800 lines
57 KiB
JavaScript
'use strict';
|
|
|
|
const childProcess = require('node:child_process');
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const { isDeepStrictEqual } = require('node:util');
|
|
const {
|
|
validateLinuxRuntimeManifest,
|
|
} = require('../embedded-mpv/linux-runtime-manifest.cjs');
|
|
const {
|
|
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES,
|
|
} = require('../embedded-mpv/build-linux-runtime.cjs');
|
|
const { validatePackagedEmbeddedMpv } = require('./embedded-mpv-packaging.cjs');
|
|
const {
|
|
SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION,
|
|
SOURCE_ARCHIVE_NAME,
|
|
sha256File,
|
|
validateLinuxSourceArchiveBinding,
|
|
} = require('../embedded-mpv/linux-source-archive-contract.cjs');
|
|
const {
|
|
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
|
|
inventoryLinuxRuntimeSourceSnapshot,
|
|
validateLinuxRuntimeSourceSnapshot,
|
|
} = require('./prepare-linux-runtime-source-snapshot.cjs');
|
|
|
|
const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
const SQUASHFS_LIST_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
const SOURCE_MEMBER_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
const COMMAND_TIMEOUT_MS = 120_000;
|
|
const SOURCE_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
const SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
const SNAP_ARCHIVE_MAX_BYTES = 1024 * 1024 * 1024;
|
|
const SNAP_EXTRACTED_MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
const SOURCE_INDEX_SCHEMA_VERSION = 3;
|
|
const SNAP_PAYLOAD_ENTRY_LIMIT = 250_000;
|
|
const SOURCE_ARCHIVE_ENTRY_LIMIT = 250_000;
|
|
const GIT_COMMIT_PATTERN = /^[a-f0-9]{40,64}$/;
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
|
|
const SAFE_BASENAME_PATTERN = /^[A-Za-z0-9_+.-]+$/;
|
|
const FRAME_COPY_MANIFEST_NAME = 'embedded-mpv-runtime.json';
|
|
const FRAME_COPY_UNAVAILABLE_MARKER_NAME = 'embedded-mpv-unavailable.txt';
|
|
// The archive carries the STAGED runtime manifest (`stage-runtime.mjs`):
|
|
// origin "vendored-lgpl", with the from-source builder's origin preserved in
|
|
// sourceBuildOrigin. The shared source-build validator still checks every
|
|
// other field through the projected origin below.
|
|
const STAGED_RUNTIME_ORIGIN = 'vendored-lgpl';
|
|
const SOURCE_BUILD_RUNTIME_ORIGIN = 'vendored-lgpl-source-build';
|
|
const SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION = 1;
|
|
const SNAP_RELEASE_BOUNDARY_HELPER_PATH = path.join(
|
|
__dirname,
|
|
'validate-snap-release-boundary.mjs'
|
|
);
|
|
const NATIVE_PAYLOAD_SUFFIX = [
|
|
'resources',
|
|
'app.asar.unpacked',
|
|
'electron-backend',
|
|
'native',
|
|
].join('/');
|
|
const SOURCE_TOOLING_FILES = Object.freeze([
|
|
{
|
|
archivePath: 'tooling/build-linux-runtime.cjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'build-linux-runtime.cjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/build-linux-runtime.mjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'build-linux-runtime.mjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/download-pinned-source.mjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'download-pinned-source.mjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/generate-linux-runtime-notices.cjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'generate-linux-runtime-notices.cjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/linux-runtime-manifest.cjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'linux-runtime-manifest.cjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/linux-source-archive-contract.cjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'linux-source-archive-contract.cjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/stage-runtime.mjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'..',
|
|
'embedded-mpv',
|
|
'stage-runtime.mjs'
|
|
),
|
|
},
|
|
{
|
|
archivePath: 'tooling/prepare-linux-runtime-source-snapshot.cjs',
|
|
checkoutPath: path.join(
|
|
__dirname,
|
|
'prepare-linux-runtime-source-snapshot.cjs'
|
|
),
|
|
},
|
|
]);
|
|
|
|
function isObject(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function commandFailureDetails(result) {
|
|
const stderr = Buffer.isBuffer(result?.stderr)
|
|
? result.stderr.toString('utf8')
|
|
: String(result?.stderr ?? '');
|
|
return stderr.trim().slice(0, 2048);
|
|
}
|
|
|
|
function defaultRunCommand(
|
|
command,
|
|
args,
|
|
{
|
|
encoding = 'utf8',
|
|
input,
|
|
maxBuffer = COMMAND_OUTPUT_MAX_BUFFER_BYTES,
|
|
timeout = COMMAND_TIMEOUT_MS,
|
|
} = {}
|
|
) {
|
|
const spawnOptions = {
|
|
killSignal: 'SIGKILL',
|
|
maxBuffer,
|
|
stdio: 'pipe',
|
|
timeout,
|
|
windowsHide: true,
|
|
};
|
|
if (input !== undefined) {
|
|
spawnOptions.input = input;
|
|
}
|
|
if (encoding !== null) {
|
|
spawnOptions.encoding = encoding;
|
|
}
|
|
const result = childProcess.spawnSync(command, args, spawnOptions);
|
|
if (result.error) {
|
|
throw new Error(
|
|
`Unable to run ${command}: ${
|
|
result.error instanceof Error
|
|
? result.error.message
|
|
: String(result.error)
|
|
}`
|
|
);
|
|
}
|
|
if (result.status !== 0) {
|
|
const details = commandFailureDetails(result);
|
|
throw new Error(
|
|
`${command} exited with status ${String(result.status)}${
|
|
details ? `: ${details}` : '.'
|
|
}`
|
|
);
|
|
}
|
|
return result.stdout ?? (encoding === null ? Buffer.alloc(0) : '');
|
|
}
|
|
|
|
function normalizeTarMember(rawName) {
|
|
if (
|
|
typeof rawName !== 'string' ||
|
|
rawName.length === 0 ||
|
|
rawName.includes('\\') ||
|
|
[...rawName].some((character) => {
|
|
const codePoint = character.codePointAt(0);
|
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
})
|
|
) {
|
|
throw new Error('Source archive contains an unsafe member name.');
|
|
}
|
|
let memberName = rawName;
|
|
while (memberName.startsWith('./')) {
|
|
memberName = memberName.slice(2);
|
|
}
|
|
const isDirectory = memberName.endsWith('/');
|
|
if (isDirectory) {
|
|
memberName = memberName.slice(0, -1);
|
|
}
|
|
if (memberName === '') {
|
|
return null;
|
|
}
|
|
const parts = memberName.split('/');
|
|
if (
|
|
path.posix.isAbsolute(memberName) ||
|
|
parts.some(
|
|
(part) =>
|
|
part === '' ||
|
|
part === '.' ||
|
|
part === '..' ||
|
|
!SAFE_BASENAME_PATTERN.test(part)
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`Source archive contains an unsafe member name: ${rawName}`
|
|
);
|
|
}
|
|
return {
|
|
isDirectory,
|
|
name: parts.join('/'),
|
|
rawName,
|
|
};
|
|
}
|
|
|
|
function parseVerboseTarMembers(listing) {
|
|
if (typeof listing !== 'string') {
|
|
throw new Error('Unable to read source archive member types.');
|
|
}
|
|
const memberTypes = new Map();
|
|
for (const line of listing.split(/\r?\n/).filter(Boolean)) {
|
|
let type = line[0];
|
|
if (!['-', 'd', 'h', 'l'].includes(type)) {
|
|
throw new Error(
|
|
'Source archive contains an unsupported member type.'
|
|
);
|
|
}
|
|
let identity = line.trimEnd();
|
|
let linkTarget = null;
|
|
for (const marker of [' -> ', ' link to ']) {
|
|
const markerIndex = identity.indexOf(marker);
|
|
if (markerIndex !== -1) {
|
|
linkTarget = identity.slice(markerIndex + marker.length);
|
|
identity = identity.slice(0, markerIndex);
|
|
if (marker === ' link to ') {
|
|
type = 'h';
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
const trimmedIdentity = identity.trim();
|
|
const rawName = trimmedIdentity.split(/\s+/).at(-1);
|
|
const rawNameOffset = trimmedIdentity.lastIndexOf(rawName);
|
|
const metadataTokens = trimmedIdentity
|
|
.slice(0, rawNameOffset)
|
|
.trim()
|
|
.split(/\s+/);
|
|
const dateTokenIndex = metadataTokens.findIndex(
|
|
(token) =>
|
|
/^\d{4}-\d{2}-\d{2}$/.test(token) ||
|
|
/^(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/.test(
|
|
token
|
|
)
|
|
);
|
|
const sizeToken =
|
|
dateTokenIndex > 0 ? metadataTokens[dateTokenIndex - 1] : '';
|
|
const size = Number(sizeToken);
|
|
if (
|
|
!/^\d+$/.test(sizeToken) ||
|
|
!Number.isSafeInteger(size) ||
|
|
size < 0
|
|
) {
|
|
throw new Error(
|
|
'Unable to read a bounded source archive member size.'
|
|
);
|
|
}
|
|
const member = normalizeTarMember(rawName);
|
|
if (!member) {
|
|
continue;
|
|
}
|
|
if (memberTypes.has(member.name)) {
|
|
throw new Error(
|
|
`Source archive contains duplicate verbose member: ${member.name}`
|
|
);
|
|
}
|
|
if (member.isDirectory !== (type === 'd')) {
|
|
throw new Error(
|
|
`Source archive member type does not match its name: ${member.name}`
|
|
);
|
|
}
|
|
if (type === 'l' || type === 'h') {
|
|
if (
|
|
typeof linkTarget !== 'string' ||
|
|
linkTarget.length === 0 ||
|
|
linkTarget.includes('\\') ||
|
|
path.posix.isAbsolute(linkTarget) ||
|
|
[...linkTarget].some((character) => {
|
|
const codePoint = character.codePointAt(0);
|
|
return codePoint <= 0x1f || codePoint === 0x7f;
|
|
})
|
|
) {
|
|
throw new Error(
|
|
`Source archive contains an unsafe link target: ${member.name}`
|
|
);
|
|
}
|
|
const resolvedTarget =
|
|
type === 'h'
|
|
? path.posix.normalize(linkTarget)
|
|
: path.posix.normalize(
|
|
path.posix.join(
|
|
path.posix.dirname(member.name),
|
|
linkTarget
|
|
)
|
|
);
|
|
if (resolvedTarget === '..' || resolvedTarget.startsWith('../')) {
|
|
throw new Error(
|
|
`Source archive link target escapes its root: ${member.name}`
|
|
);
|
|
}
|
|
linkTarget = resolvedTarget.replace(/^\.\/+/, '');
|
|
} else if (linkTarget !== null) {
|
|
throw new Error(
|
|
`Source archive regular member has link metadata: ${member.name}`
|
|
);
|
|
}
|
|
memberTypes.set(member.name, { linkTarget, size, type });
|
|
}
|
|
for (const [memberName, member] of memberTypes) {
|
|
if (
|
|
member.type === 'h' &&
|
|
memberTypes.get(member.linkTarget)?.type !== '-'
|
|
) {
|
|
throw new Error(
|
|
`Source archive hardlink target must be a regular member: ${memberName}`
|
|
);
|
|
}
|
|
}
|
|
return memberTypes;
|
|
}
|
|
|
|
function listTarMembers(archivePath, runCommand) {
|
|
const listing = runCommand(
|
|
'tar',
|
|
['--list', '--ignore-zeros', '--xz', '--file', archivePath],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES,
|
|
}
|
|
);
|
|
if (typeof listing !== 'string') {
|
|
throw new Error('Unable to read source archive member listing.');
|
|
}
|
|
const verboseMembers = parseVerboseTarMembers(
|
|
runCommand(
|
|
'tar',
|
|
[
|
|
'--list',
|
|
'--verbose',
|
|
'--ignore-zeros',
|
|
'--xz',
|
|
'--file',
|
|
archivePath,
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES,
|
|
}
|
|
)
|
|
);
|
|
const members = new Map();
|
|
let extractedBytes = 0;
|
|
let entryCount = 0;
|
|
for (const rawName of listing.split(/\r?\n/).filter(Boolean)) {
|
|
const member = normalizeTarMember(rawName);
|
|
if (!member) {
|
|
continue;
|
|
}
|
|
if (members.has(member.name)) {
|
|
throw new Error(
|
|
`Source archive contains duplicate member: ${member.name}`
|
|
);
|
|
}
|
|
const verboseMember = verboseMembers.get(member.name);
|
|
if (!verboseMember) {
|
|
throw new Error(
|
|
`Source archive member type is missing: ${member.name}`
|
|
);
|
|
}
|
|
entryCount += 1;
|
|
if (entryCount > SOURCE_ARCHIVE_ENTRY_LIMIT) {
|
|
throw new Error(
|
|
'Source archive exceeds the release-verifier entry limit.'
|
|
);
|
|
}
|
|
if (verboseMember.type === '-') {
|
|
if (verboseMember.size > SOURCE_MEMBER_MAX_BUFFER_BYTES) {
|
|
throw new Error(
|
|
`Source archive member exceeds the release-verifier size limit: ${member.name}`
|
|
);
|
|
}
|
|
extractedBytes += verboseMember.size;
|
|
if (
|
|
!Number.isSafeInteger(extractedBytes) ||
|
|
extractedBytes > SOURCE_ARCHIVE_EXTRACTED_MAX_BYTES
|
|
) {
|
|
throw new Error(
|
|
'Source archive exceeds the release-verifier extracted-size limit.'
|
|
);
|
|
}
|
|
}
|
|
members.set(member.name, {
|
|
...member,
|
|
...verboseMember,
|
|
});
|
|
}
|
|
if (verboseMembers.size !== members.size) {
|
|
throw new Error(
|
|
'Source archive member and type listings do not match.'
|
|
);
|
|
}
|
|
return members;
|
|
}
|
|
|
|
function readTarMember(
|
|
archivePath,
|
|
members,
|
|
memberName,
|
|
runCommand,
|
|
maxBuffer
|
|
) {
|
|
const member = members.get(memberName);
|
|
if (!member || member.type !== '-') {
|
|
throw new Error(
|
|
`Source archive required member must be a regular file: ${memberName}`
|
|
);
|
|
}
|
|
const contents = runCommand(
|
|
'tar',
|
|
[
|
|
'--extract',
|
|
'--xz',
|
|
'--to-stdout',
|
|
'--file',
|
|
archivePath,
|
|
'--',
|
|
member.rawName,
|
|
],
|
|
{
|
|
encoding: null,
|
|
maxBuffer,
|
|
}
|
|
);
|
|
const buffer = Buffer.isBuffer(contents) ? contents : Buffer.from(contents);
|
|
if (buffer.length !== member.size) {
|
|
throw new Error(
|
|
`Source archive member size changed during inspection: ${memberName}`
|
|
);
|
|
}
|
|
return buffer;
|
|
}
|
|
|
|
function parseJsonMember(contents, memberName) {
|
|
try {
|
|
return JSON.parse(contents.toString('utf8'));
|
|
} catch {
|
|
throw new Error(
|
|
`Source archive member is not valid JSON: ${memberName}`
|
|
);
|
|
}
|
|
}
|
|
|
|
function sourceMemberRecord(
|
|
archivePath,
|
|
members,
|
|
memberName,
|
|
runCommand,
|
|
relativePath
|
|
) {
|
|
const contents = readTarMember(
|
|
archivePath,
|
|
members,
|
|
memberName,
|
|
runCommand,
|
|
COMMAND_OUTPUT_MAX_BUFFER_BYTES
|
|
);
|
|
return {
|
|
path: relativePath,
|
|
sha256: crypto.createHash('sha256').update(contents).digest('hex'),
|
|
size: contents.length,
|
|
};
|
|
}
|
|
|
|
function inspectLegalFiles(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
rootName,
|
|
rootFiles
|
|
) {
|
|
const rootPrefix = `${rootName}/`;
|
|
const licensePrefix = `${rootPrefix}licenses/`;
|
|
const allowedRootFiles = new Set(
|
|
rootFiles.map((name) => `${rootPrefix}${name}`)
|
|
);
|
|
const records = [];
|
|
for (const member of members.values()) {
|
|
if (member.isDirectory || !member.name.startsWith(rootPrefix)) {
|
|
continue;
|
|
}
|
|
if (
|
|
!member.name.startsWith(licensePrefix) &&
|
|
!allowedRootFiles.has(member.name)
|
|
) {
|
|
throw new Error(
|
|
`Source archive contains an undeclared ${rootName} file: ${member.name}`
|
|
);
|
|
}
|
|
if (member.name.startsWith(licensePrefix)) {
|
|
records.push(
|
|
sourceMemberRecord(
|
|
archivePath,
|
|
members,
|
|
member.name,
|
|
runCommand,
|
|
member.name.slice(rootPrefix.length)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
return records.sort(({ path: left }, { path: right }) =>
|
|
left.localeCompare(right)
|
|
);
|
|
}
|
|
|
|
function inspectLibplaceboSourceSnapshot(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
expectedSourceSnapshotSha256
|
|
) {
|
|
const sourceRootName = 'git/libplacebo';
|
|
const sourceRootMember = members.get(sourceRootName);
|
|
if (!sourceRootMember || sourceRootMember.type !== 'd') {
|
|
throw new Error(
|
|
'Source archive must contain a regular libplacebo source directory.'
|
|
);
|
|
}
|
|
const sourceMembers = [...members.values()].filter(
|
|
({ name }) =>
|
|
name === sourceRootName || name.startsWith(`${sourceRootName}/`)
|
|
);
|
|
if (sourceMembers.length <= 1) {
|
|
throw new Error(
|
|
'Source archive must contain the prepared libplacebo source snapshot.'
|
|
);
|
|
}
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-libplacebo-source-verifier-')
|
|
);
|
|
try {
|
|
const memberListPath = path.join(
|
|
temporaryRoot,
|
|
'libplacebo-members.txt'
|
|
);
|
|
fs.writeFileSync(
|
|
memberListPath,
|
|
`${sourceMembers.map(({ rawName }) => rawName).join('\n')}\n`,
|
|
{
|
|
encoding: 'utf8',
|
|
flag: 'wx',
|
|
mode: 0o600,
|
|
}
|
|
);
|
|
runCommand(
|
|
'tar',
|
|
[
|
|
'--extract',
|
|
'--xz',
|
|
'--file',
|
|
archivePath,
|
|
'--directory',
|
|
temporaryRoot,
|
|
'--no-same-owner',
|
|
'--no-same-permissions',
|
|
'--no-recursion',
|
|
'-T',
|
|
memberListPath,
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES,
|
|
}
|
|
);
|
|
const sourceSnapshot = inventoryLinuxRuntimeSourceSnapshot(
|
|
path.join(temporaryRoot, 'git', 'libplacebo')
|
|
);
|
|
return validateLinuxRuntimeSourceSnapshot(sourceSnapshot, {
|
|
expectedSha256: expectedSourceSnapshotSha256,
|
|
});
|
|
} finally {
|
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function inspectSourceCompliance(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
expectedSourceSnapshotSha256
|
|
) {
|
|
if ([...members.keys()].some((name) => name.split('/').includes('.git'))) {
|
|
throw new Error(
|
|
'Source archive must not contain VCS metadata directories.'
|
|
);
|
|
}
|
|
const expectedToolingNames = SOURCE_TOOLING_FILES.map(
|
|
({ archivePath: memberName }) => memberName
|
|
).sort();
|
|
const actualToolingNames = [...members.values()]
|
|
.filter(
|
|
({ isDirectory, name }) =>
|
|
!isDirectory && name.startsWith('tooling/')
|
|
)
|
|
.map(({ name }) => name)
|
|
.sort();
|
|
if (!isDeepStrictEqual(actualToolingNames, expectedToolingNames)) {
|
|
throw new Error(
|
|
'Source archive must contain the exact released runtime tooling set.'
|
|
);
|
|
}
|
|
for (const toolingFile of SOURCE_TOOLING_FILES) {
|
|
const archivedContents = readTarMember(
|
|
archivePath,
|
|
members,
|
|
toolingFile.archivePath,
|
|
runCommand,
|
|
COMMAND_OUTPUT_MAX_BUFFER_BYTES
|
|
);
|
|
const checkoutContents = fs.readFileSync(toolingFile.checkoutPath);
|
|
if (!archivedContents.equals(checkoutContents)) {
|
|
throw new Error(
|
|
`Source archive tooling does not match the released tag: ${toolingFile.archivePath}`
|
|
);
|
|
}
|
|
}
|
|
const libplaceboSourceSnapshot = inspectLibplaceboSourceSnapshot(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
expectedSourceSnapshotSha256
|
|
);
|
|
const licenseInputManifestName =
|
|
'license-inputs/linux-runtime-license-inputs.json';
|
|
const noticeManifestName = 'notices/embedded-mpv-notices.json';
|
|
const noticeFileName = 'notices/THIRD_PARTY_NOTICES.txt';
|
|
const licenseInputs = parseJsonMember(
|
|
readTarMember(
|
|
archivePath,
|
|
members,
|
|
licenseInputManifestName,
|
|
runCommand,
|
|
COMMAND_OUTPUT_MAX_BUFFER_BYTES
|
|
),
|
|
licenseInputManifestName
|
|
);
|
|
const notices = parseJsonMember(
|
|
readTarMember(
|
|
archivePath,
|
|
members,
|
|
noticeManifestName,
|
|
runCommand,
|
|
COMMAND_OUTPUT_MAX_BUFFER_BYTES
|
|
),
|
|
noticeManifestName
|
|
);
|
|
const noticeFile = sourceMemberRecord(
|
|
archivePath,
|
|
members,
|
|
noticeFileName,
|
|
runCommand,
|
|
'THIRD_PARTY_NOTICES.txt'
|
|
);
|
|
return {
|
|
libplaceboSourceSnapshot,
|
|
licenseInputFiles: inspectLegalFiles(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
'license-inputs',
|
|
['linux-runtime-license-inputs.json']
|
|
),
|
|
licenseInputs,
|
|
noticeFile,
|
|
noticeLicenseFiles: inspectLegalFiles(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
'notices',
|
|
['embedded-mpv-notices.json', 'THIRD_PARTY_NOTICES.txt']
|
|
),
|
|
notices,
|
|
toolingValidated: true,
|
|
};
|
|
}
|
|
|
|
function parseArchiveChecksumRecords(contents) {
|
|
const text = contents.toString('utf8');
|
|
if (!text.endsWith('\n') || text.includes('\r')) {
|
|
throw new Error(
|
|
'Source archive checksum manifest must use canonical LF-terminated sha256sum records.'
|
|
);
|
|
}
|
|
const lines = text.slice(0, -1).split('\n');
|
|
const records = lines.map((line) => {
|
|
const match = /^([a-f0-9]{64}) {2}([A-Za-z0-9_+.-]+)$/.exec(line);
|
|
if (!match) {
|
|
throw new Error(
|
|
'Source archive checksum manifest contains an invalid record.'
|
|
);
|
|
}
|
|
return {
|
|
name: match[2],
|
|
sha256: match[1],
|
|
};
|
|
});
|
|
return normalizeArchiveRecords(records, 'Source archive checksum manifest');
|
|
}
|
|
|
|
function assertExactSourceArchiveLayout(members, archiveFiles, compliance) {
|
|
const expectedFiles = new Set([
|
|
...archiveFiles.map(({ name }) => `archives/${name}`),
|
|
...SOURCE_TOOLING_FILES.map(({ archivePath }) => archivePath),
|
|
'license-inputs/linux-runtime-license-inputs.json',
|
|
...compliance.licenseInputFiles.map(
|
|
({ path: relativePath }) => `license-inputs/${relativePath}`
|
|
),
|
|
'metadata/archive-sha256.txt',
|
|
'metadata/iptvnator-git-revision.txt',
|
|
'metadata/local-changes.patch',
|
|
'metadata/runtime-manifest.json',
|
|
'metadata/source-index.json',
|
|
'notices/THIRD_PARTY_NOTICES.txt',
|
|
'notices/embedded-mpv-notices.json',
|
|
...compliance.noticeLicenseFiles.map(
|
|
({ path: relativePath }) => `notices/${relativePath}`
|
|
),
|
|
]);
|
|
const expectedDirectories = new Set(['git']);
|
|
for (const fileName of expectedFiles) {
|
|
const parts = fileName.split('/');
|
|
parts.pop();
|
|
while (parts.length > 0) {
|
|
expectedDirectories.add(parts.join('/'));
|
|
parts.pop();
|
|
}
|
|
}
|
|
|
|
const sourceRootName = 'git/libplacebo';
|
|
for (const member of members.values()) {
|
|
if (
|
|
member.name === sourceRootName ||
|
|
member.name.startsWith(`${sourceRootName}/`)
|
|
) {
|
|
if (
|
|
member.type === 'h' &&
|
|
!member.linkTarget.startsWith(`${sourceRootName}/`)
|
|
) {
|
|
throw new Error(
|
|
`Source archive hardlink leaves the libplacebo snapshot: ${member.name}`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (expectedFiles.has(member.name)) {
|
|
if (member.type !== '-') {
|
|
throw new Error(
|
|
`Canonical source archive file must be regular: ${member.name}`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (expectedDirectories.has(member.name)) {
|
|
if (member.type !== 'd') {
|
|
throw new Error(
|
|
`Canonical source archive directory has the wrong type: ${member.name}`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
throw new Error(
|
|
`Source archive contains an undeclared source archive member: ${member.name}`
|
|
);
|
|
}
|
|
|
|
for (const fileName of expectedFiles) {
|
|
if (members.get(fileName)?.type !== '-') {
|
|
throw new Error(
|
|
`Source archive is missing canonical regular file: ${fileName}`
|
|
);
|
|
}
|
|
}
|
|
for (const directoryName of expectedDirectories) {
|
|
if (members.get(directoryName)?.type !== 'd') {
|
|
throw new Error(
|
|
`Source archive is missing canonical directory: ${directoryName}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
function inspectSourceArchive(
|
|
archivePath,
|
|
{
|
|
expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
|
|
runCommand = defaultRunCommand,
|
|
} = {}
|
|
) {
|
|
const archiveStat = fs.lstatSync(archivePath);
|
|
if (
|
|
!archiveStat.isFile() ||
|
|
archiveStat.isSymbolicLink() ||
|
|
archiveStat.size === 0 ||
|
|
archiveStat.size > SOURCE_ARCHIVE_MAX_BYTES
|
|
) {
|
|
throw new Error(
|
|
'Linux source archive must be a bounded non-empty regular file.'
|
|
);
|
|
}
|
|
const members = listTarMembers(archivePath, runCommand);
|
|
const readMetadata = (memberName) =>
|
|
readTarMember(
|
|
archivePath,
|
|
members,
|
|
memberName,
|
|
runCommand,
|
|
COMMAND_OUTPUT_MAX_BUFFER_BYTES
|
|
);
|
|
const sourceRuntime = parseJsonMember(
|
|
readMetadata('metadata/runtime-manifest.json'),
|
|
'metadata/runtime-manifest.json'
|
|
);
|
|
const sourceIndex = parseJsonMember(
|
|
readMetadata('metadata/source-index.json'),
|
|
'metadata/source-index.json'
|
|
);
|
|
const repositoryRevision = readMetadata(
|
|
'metadata/iptvnator-git-revision.txt'
|
|
)
|
|
.toString('utf8')
|
|
.trim();
|
|
const localChanges = readMetadata('metadata/local-changes.patch');
|
|
const archiveMemberNames = [...members.values()]
|
|
.filter(
|
|
({ isDirectory, name }) =>
|
|
!isDirectory &&
|
|
name.startsWith('archives/') &&
|
|
name.split('/').length === 2
|
|
)
|
|
.map(({ name }) => name.slice('archives/'.length))
|
|
.sort();
|
|
const archiveFiles = archiveMemberNames.map((name) => ({
|
|
name,
|
|
sha256: crypto
|
|
.createHash('sha256')
|
|
.update(
|
|
readTarMember(
|
|
archivePath,
|
|
members,
|
|
`archives/${name}`,
|
|
runCommand,
|
|
SOURCE_MEMBER_MAX_BUFFER_BYTES
|
|
)
|
|
)
|
|
.digest('hex'),
|
|
}));
|
|
const archiveChecksums = parseArchiveChecksumRecords(
|
|
readMetadata('metadata/archive-sha256.txt')
|
|
);
|
|
if (
|
|
!isDeepStrictEqual(
|
|
archiveChecksums,
|
|
normalizeArchiveRecords(
|
|
archiveFiles,
|
|
'Source archive inspected archives'
|
|
)
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Source archive checksum manifest does not match the inspected archives.'
|
|
);
|
|
}
|
|
const compliance = inspectSourceCompliance(
|
|
archivePath,
|
|
members,
|
|
runCommand,
|
|
expectedSourceSnapshotSha256
|
|
);
|
|
assertExactSourceArchiveLayout(members, archiveFiles, compliance);
|
|
return {
|
|
archiveSha256: sha256File(archivePath),
|
|
archiveFiles,
|
|
compliance,
|
|
localChanges,
|
|
repositoryRevision,
|
|
sourceIndex,
|
|
sourceRuntime,
|
|
};
|
|
}
|
|
|
|
function readElfArchitecture(binaryPath) {
|
|
const descriptor = fs.openSync(binaryPath, 'r');
|
|
try {
|
|
const header = Buffer.alloc(20);
|
|
const bytesRead = fs.readSync(descriptor, header, 0, header.length, 0);
|
|
if (
|
|
bytesRead !== header.length ||
|
|
header[0] !== 0x7f ||
|
|
header[1] !== 0x45 ||
|
|
header[2] !== 0x4c ||
|
|
header[3] !== 0x46 ||
|
|
![1, 2].includes(header[4]) ||
|
|
header[5] !== 1
|
|
) {
|
|
throw new Error('Snap Electron executable is not a supported ELF.');
|
|
}
|
|
const machine = header.readUInt16LE(18);
|
|
if (machine === 62 && header[4] === 2) {
|
|
return 'x64';
|
|
}
|
|
if (machine === 183 && header[4] === 2) {
|
|
return 'arm64';
|
|
}
|
|
if (machine === 40 && header[4] === 1) {
|
|
return 'arm';
|
|
}
|
|
throw new Error(
|
|
`Snap Electron executable uses unsupported ELF machine ${machine}.`
|
|
);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function collectSnapNativePayloads(extractionRoot) {
|
|
const candidates = [];
|
|
const pendingDirectories = [extractionRoot];
|
|
let entryCount = 0;
|
|
while (pendingDirectories.length > 0) {
|
|
const directoryPath = pendingDirectories.pop();
|
|
const entries = fs
|
|
.readdirSync(directoryPath, { withFileTypes: true })
|
|
.sort(({ name: left }, { name: right }) =>
|
|
left.localeCompare(right)
|
|
);
|
|
for (const entry of entries) {
|
|
entryCount += 1;
|
|
if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) {
|
|
throw new Error(
|
|
'Extracted Snap exceeds the release-verifier entry limit.'
|
|
);
|
|
}
|
|
const entryPath = path.join(directoryPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
pendingDirectories.push(entryPath);
|
|
continue;
|
|
}
|
|
if (
|
|
!entry.isFile() ||
|
|
![
|
|
FRAME_COPY_MANIFEST_NAME,
|
|
FRAME_COPY_UNAVAILABLE_MARKER_NAME,
|
|
].includes(entry.name)
|
|
) {
|
|
continue;
|
|
}
|
|
const relativePath = path
|
|
.relative(extractionRoot, entryPath)
|
|
.split(path.sep)
|
|
.join('/');
|
|
const expectedSuffix = `${NATIVE_PAYLOAD_SUFFIX}/${entry.name}`;
|
|
if (
|
|
relativePath === expectedSuffix ||
|
|
relativePath.endsWith(`/${expectedSuffix}`)
|
|
) {
|
|
candidates.push(entryPath);
|
|
}
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function inspectSquashfsListing(snapPath, runCommand) {
|
|
const listing = runCommand('unsquashfs', ['-lln', snapPath], {
|
|
encoding: 'utf8',
|
|
maxBuffer: SQUASHFS_LIST_MAX_BUFFER_BYTES,
|
|
timeout: COMMAND_TIMEOUT_MS,
|
|
});
|
|
if (typeof listing !== 'string') {
|
|
throw new Error('Unable to inspect the Snap filesystem listing.');
|
|
}
|
|
let entryCount = 0;
|
|
let extractedBytes = 0;
|
|
for (const line of listing.split(/\r?\n/).filter(Boolean)) {
|
|
const match =
|
|
/^([bcdlps-])\S*\s+\d+\/\d+\s+(\d+)\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+.+$/.exec(
|
|
line
|
|
);
|
|
if (!match || !['-', 'd', 'l'].includes(match[1])) {
|
|
throw new Error(
|
|
'Snap filesystem listing contains an invalid entry.'
|
|
);
|
|
}
|
|
entryCount += 1;
|
|
if (entryCount > SNAP_PAYLOAD_ENTRY_LIMIT) {
|
|
throw new Error('Snap exceeds the release-verifier entry limit.');
|
|
}
|
|
if (match[1] === '-') {
|
|
extractedBytes += Number(match[2]);
|
|
if (
|
|
!Number.isSafeInteger(extractedBytes) ||
|
|
extractedBytes > SNAP_EXTRACTED_MAX_BYTES
|
|
) {
|
|
throw new Error(
|
|
'Snap exceeds the release-verifier extracted-size limit.'
|
|
);
|
|
}
|
|
}
|
|
}
|
|
if (entryCount === 0) {
|
|
throw new Error('Snap filesystem listing must not be empty.');
|
|
}
|
|
}
|
|
|
|
function validateSnapReleaseBoundary(extractionRoot) {
|
|
const output = defaultRunCommand(
|
|
process.execPath,
|
|
[SNAP_RELEASE_BOUNDARY_HELPER_PATH, extractionRoot],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES,
|
|
timeout: COMMAND_TIMEOUT_MS,
|
|
}
|
|
);
|
|
if (typeof output !== 'string' || !/^[^\r\n]+\n$/.test(output)) {
|
|
throw new Error(
|
|
'Snap release-boundary validator must emit exactly one newline-terminated JSON line.'
|
|
);
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(output.slice(0, -1));
|
|
} catch {
|
|
throw new Error(
|
|
'Snap release-boundary validator emitted malformed JSON.'
|
|
);
|
|
}
|
|
if (
|
|
!isObject(payload) ||
|
|
!isDeepStrictEqual(Object.keys(payload).sort(), [
|
|
'errors',
|
|
'schemaVersion',
|
|
]) ||
|
|
payload.schemaVersion !== SNAP_RELEASE_BOUNDARY_SCHEMA_VERSION ||
|
|
!Array.isArray(payload.errors) ||
|
|
payload.errors.some(
|
|
(error) =>
|
|
typeof error !== 'string' ||
|
|
error.length === 0 ||
|
|
error.length > 4096
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Snap release-boundary validator emitted an invalid result.'
|
|
);
|
|
}
|
|
return payload.errors;
|
|
}
|
|
|
|
function inspectSnapPayload(
|
|
snapPath,
|
|
asset,
|
|
{
|
|
runCommand = defaultRunCommand,
|
|
validatePackagedEmbeddedMpv:
|
|
validatePackaged = validatePackagedEmbeddedMpv,
|
|
} = {}
|
|
) {
|
|
const snapStat = fs.lstatSync(snapPath);
|
|
if (
|
|
!snapStat.isFile() ||
|
|
snapStat.isSymbolicLink() ||
|
|
snapStat.size === 0 ||
|
|
snapStat.size > SNAP_ARCHIVE_MAX_BYTES
|
|
) {
|
|
throw new Error(
|
|
`Snap ${asset.name} must be a bounded non-empty regular file.`
|
|
);
|
|
}
|
|
inspectSquashfsListing(snapPath, runCommand);
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-snap-source-verifier-')
|
|
);
|
|
try {
|
|
const extractionRoot = path.join(temporaryRoot, 'payload');
|
|
runCommand(
|
|
'unsquashfs',
|
|
['-no-progress', '-dest', extractionRoot, snapPath],
|
|
{
|
|
encoding: 'utf8',
|
|
maxBuffer: COMMAND_OUTPUT_MAX_BUFFER_BYTES,
|
|
timeout: COMMAND_TIMEOUT_MS,
|
|
}
|
|
);
|
|
const payloads = collectSnapNativePayloads(extractionRoot);
|
|
const canonicalNativeDirectory = path.join(
|
|
extractionRoot,
|
|
'resources',
|
|
'app.asar.unpacked',
|
|
'electron-backend',
|
|
'native'
|
|
);
|
|
const canonicalPayloads = [
|
|
FRAME_COPY_MANIFEST_NAME,
|
|
FRAME_COPY_UNAVAILABLE_MARKER_NAME,
|
|
]
|
|
.map((name) => path.join(canonicalNativeDirectory, name))
|
|
.filter((candidate) => payloads.includes(candidate));
|
|
if (
|
|
payloads.length !== 1 ||
|
|
canonicalPayloads.length !== 1 ||
|
|
payloads[0] !== canonicalPayloads[0]
|
|
) {
|
|
throw new Error(
|
|
`Snap ${asset.name} must contain exactly one canonical frame-copy manifest or unavailable marker.`
|
|
);
|
|
}
|
|
const boundaryErrors = validateSnapReleaseBoundary(extractionRoot);
|
|
if (boundaryErrors.length > 0) {
|
|
throw new Error(
|
|
`Snap ${asset.name} failed public-release boundary validation: ${boundaryErrors.join(
|
|
'; '
|
|
)}`
|
|
);
|
|
}
|
|
const payloadPath = canonicalPayloads[0];
|
|
const resourcesDirectory = path.join(extractionRoot, 'resources');
|
|
const electronPath = path.join(extractionRoot, 'iptvnator.bin');
|
|
const electronStat = fs.lstatSync(electronPath);
|
|
if (
|
|
!electronStat.isFile() ||
|
|
electronStat.isSymbolicLink() ||
|
|
electronStat.size < 20
|
|
) {
|
|
throw new Error(
|
|
`Snap ${asset.name} is missing its regular Electron executable.`
|
|
);
|
|
}
|
|
const architecture = readElfArchitecture(electronPath);
|
|
if (path.basename(payloadPath) === FRAME_COPY_MANIFEST_NAME) {
|
|
const manifestStat = fs.lstatSync(payloadPath);
|
|
if (
|
|
!manifestStat.isFile() ||
|
|
manifestStat.isSymbolicLink() ||
|
|
manifestStat.size === 0 ||
|
|
manifestStat.size > COMMAND_OUTPUT_MAX_BUFFER_BYTES
|
|
) {
|
|
throw new Error(
|
|
`Snap ${asset.name} contains an invalid frame-copy manifest.`
|
|
);
|
|
}
|
|
}
|
|
const staticErrors = validatePackaged(resourcesDirectory, {
|
|
artifactFormat: 'snap',
|
|
executableName: 'iptvnator',
|
|
foreignArch: architecture !== 'x64',
|
|
hostPlatform: 'linux',
|
|
platform: 'linux',
|
|
profile: 'portable',
|
|
required: true,
|
|
targetArch: architecture,
|
|
targetNames: ['appimage', 'snap'],
|
|
});
|
|
if (!Array.isArray(staticErrors) || staticErrors.length > 0) {
|
|
throw new Error(
|
|
`Snap ${asset.name} failed static frame-copy validation${
|
|
Array.isArray(staticErrors) && staticErrors.length > 0
|
|
? `: ${staticErrors.join('; ')}`
|
|
: '.'
|
|
}`
|
|
);
|
|
}
|
|
if (path.basename(payloadPath) === FRAME_COPY_UNAVAILABLE_MARKER_NAME) {
|
|
return {
|
|
architecture,
|
|
assetName: asset.name,
|
|
manifest: null,
|
|
markerOnly: true,
|
|
};
|
|
}
|
|
let manifest;
|
|
try {
|
|
manifest = JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
|
|
} catch {
|
|
throw new Error(
|
|
`Snap ${asset.name} contains malformed frame-copy manifest JSON.`
|
|
);
|
|
}
|
|
return {
|
|
architecture,
|
|
assetName: asset.name,
|
|
manifest,
|
|
markerOnly: false,
|
|
};
|
|
} finally {
|
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function normalizeArchiveRecords(records, label) {
|
|
if (!Array.isArray(records) || records.length === 0) {
|
|
throw new Error(`${label} must contain source archive checksums.`);
|
|
}
|
|
const normalized = records.map((record) => {
|
|
if (
|
|
!isObject(record) ||
|
|
!isDeepStrictEqual(Object.keys(record).sort(), [
|
|
'name',
|
|
'sha256',
|
|
]) ||
|
|
typeof record.name !== 'string' ||
|
|
!SAFE_BASENAME_PATTERN.test(record.name) ||
|
|
typeof record.sha256 !== 'string' ||
|
|
!SHA256_PATTERN.test(record.sha256)
|
|
) {
|
|
throw new Error(`${label} contains an invalid archive record.`);
|
|
}
|
|
return {
|
|
name: record.name,
|
|
sha256: record.sha256,
|
|
};
|
|
});
|
|
const names = normalized.map(({ name }) => name);
|
|
const hashes = normalized.map(({ sha256 }) => sha256);
|
|
if (
|
|
new Set(names).size !== names.length ||
|
|
new Set(hashes).size !== hashes.length
|
|
) {
|
|
throw new Error(`${label} contains duplicate archive records.`);
|
|
}
|
|
return normalized.sort(({ name: left }, { name: right }) =>
|
|
left.localeCompare(right)
|
|
);
|
|
}
|
|
|
|
function hasExactFields(value, fields) {
|
|
return (
|
|
isObject(value) &&
|
|
isDeepStrictEqual(Object.keys(value).sort(), [...fields].sort())
|
|
);
|
|
}
|
|
|
|
function safeRelativePath(value) {
|
|
return (
|
|
typeof value === 'string' &&
|
|
value.length > 0 &&
|
|
!value.includes('\\') &&
|
|
!path.posix.isAbsolute(value) &&
|
|
value
|
|
.split('/')
|
|
.every(
|
|
(part) =>
|
|
part !== '' &&
|
|
part !== '.' &&
|
|
part !== '..' &&
|
|
SAFE_BASENAME_PATTERN.test(part)
|
|
)
|
|
);
|
|
}
|
|
|
|
function normalizeLegalFileRecords(records, label) {
|
|
if (!Array.isArray(records) || records.length === 0) {
|
|
throw new Error(`${label} must contain legal files.`);
|
|
}
|
|
const normalized = records.map((record) => {
|
|
if (
|
|
!hasExactFields(record, ['path', 'sha256', 'size']) ||
|
|
!safeRelativePath(record.path) ||
|
|
!record.path.startsWith('licenses/') ||
|
|
!Number.isSafeInteger(record.size) ||
|
|
record.size <= 0 ||
|
|
typeof record.sha256 !== 'string' ||
|
|
!SHA256_PATTERN.test(record.sha256)
|
|
) {
|
|
throw new Error(`${label} contains an invalid legal file.`);
|
|
}
|
|
return { ...record };
|
|
});
|
|
const paths = normalized.map(({ path: filePath }) => filePath);
|
|
if (new Set(paths).size !== paths.length) {
|
|
throw new Error(`${label} contains duplicate legal files.`);
|
|
}
|
|
return normalized.sort(({ path: left }, { path: right }) =>
|
|
left.localeCompare(right)
|
|
);
|
|
}
|
|
|
|
function sourcePackageLegalIdentity(sourcePackage) {
|
|
return Object.fromEntries(
|
|
[
|
|
'version',
|
|
'sourceUrl',
|
|
'sourceTag',
|
|
'sourceSha256',
|
|
'sourceGitCommit',
|
|
'license',
|
|
]
|
|
.filter((field) => Object.hasOwn(sourcePackage, field))
|
|
.map((field) => [field, sourcePackage[field]])
|
|
);
|
|
}
|
|
|
|
function verifySourceArchiveCompliance({
|
|
compliance,
|
|
sourceIndex,
|
|
sourceRuntime,
|
|
}) {
|
|
if (
|
|
!hasExactFields(compliance, [
|
|
'libplaceboSourceSnapshot',
|
|
'licenseInputFiles',
|
|
'licenseInputs',
|
|
'noticeFile',
|
|
'noticeLicenseFiles',
|
|
'notices',
|
|
'toolingValidated',
|
|
]) ||
|
|
compliance.toolingValidated !== true
|
|
) {
|
|
throw new Error('Source archive compliance payload is incomplete.');
|
|
}
|
|
const { licenseInputs, notices } = compliance;
|
|
if (
|
|
!hasExactFields(licenseInputs, [
|
|
'schemaVersion',
|
|
'origin',
|
|
'platform',
|
|
'arch',
|
|
'packages',
|
|
]) ||
|
|
licenseInputs.schemaVersion !== 1 ||
|
|
licenseInputs.origin !== 'pinned-linux-runtime-license-inputs' ||
|
|
licenseInputs.platform !== 'linux' ||
|
|
licenseInputs.arch !== 'x64' ||
|
|
!Array.isArray(licenseInputs.packages) ||
|
|
!hasExactFields(notices, [
|
|
'schemaVersion',
|
|
'origin',
|
|
'platform',
|
|
'arch',
|
|
'noticeFile',
|
|
'packages',
|
|
'totalBytes',
|
|
]) ||
|
|
notices.schemaVersion !== 1 ||
|
|
notices.origin !== 'pinned-linux-runtime-upstream-licenses' ||
|
|
notices.platform !== 'linux' ||
|
|
notices.arch !== 'x64' ||
|
|
!Array.isArray(notices.packages)
|
|
) {
|
|
throw new Error('Source archive compliance manifests are invalid.');
|
|
}
|
|
const runtimePackages = sourceRuntime.packages;
|
|
const runtimePackageIds = Object.keys(runtimePackages).sort();
|
|
const inputPackages = new Map(
|
|
licenseInputs.packages.map((record) => [record?.id, record])
|
|
);
|
|
const noticePackages = new Map(
|
|
notices.packages.map((record) => [record?.id, record])
|
|
);
|
|
if (
|
|
inputPackages.size !== licenseInputs.packages.length ||
|
|
noticePackages.size !== notices.packages.length ||
|
|
!isDeepStrictEqual(
|
|
[...inputPackages.keys()].sort(),
|
|
runtimePackageIds
|
|
) ||
|
|
!isDeepStrictEqual([...noticePackages.keys()].sort(), runtimePackageIds)
|
|
) {
|
|
throw new Error(
|
|
'Source archive legal package set does not match the runtime.'
|
|
);
|
|
}
|
|
const declaredLegalFiles = [];
|
|
for (const packageId of runtimePackageIds) {
|
|
if (!SAFE_BASENAME_PATTERN.test(packageId)) {
|
|
throw new Error(
|
|
'Source archive runtime contains an unsafe package id.'
|
|
);
|
|
}
|
|
const identity = sourcePackageLegalIdentity(runtimePackages[packageId]);
|
|
const inputPackage = inputPackages.get(packageId);
|
|
const noticePackage = noticePackages.get(packageId);
|
|
if (
|
|
!hasExactFields(inputPackage, [
|
|
'id',
|
|
...Object.keys(identity),
|
|
'files',
|
|
]) ||
|
|
!hasExactFields(noticePackage, [
|
|
'id',
|
|
...Object.keys(identity),
|
|
'files',
|
|
]) ||
|
|
!Object.entries(identity).every(
|
|
([field, value]) =>
|
|
isDeepStrictEqual(inputPackage[field], value) &&
|
|
isDeepStrictEqual(noticePackage[field], value)
|
|
) ||
|
|
!Array.isArray(inputPackage.files) ||
|
|
!Array.isArray(noticePackage.files) ||
|
|
inputPackage.files.length === 0 ||
|
|
inputPackage.files.length !== noticePackage.files.length
|
|
) {
|
|
throw new Error(
|
|
`Source archive legal identity is invalid for ${packageId}.`
|
|
);
|
|
}
|
|
const inputFiles = inputPackage.files
|
|
.map((record) => {
|
|
if (
|
|
!hasExactFields(record, [
|
|
'sourcePath',
|
|
'path',
|
|
'size',
|
|
'sha256',
|
|
]) ||
|
|
!safeRelativePath(record.sourcePath) ||
|
|
record.path !== `licenses/${packageId}/${record.sourcePath}`
|
|
) {
|
|
throw new Error(
|
|
`Source archive license input is invalid for ${packageId}.`
|
|
);
|
|
}
|
|
return {
|
|
path: record.path,
|
|
sha256: record.sha256,
|
|
size: record.size,
|
|
};
|
|
})
|
|
.sort(({ path: left }, { path: right }) =>
|
|
left.localeCompare(right)
|
|
);
|
|
const noticeFiles = normalizeLegalFileRecords(
|
|
noticePackage.files,
|
|
`Source archive notices for ${packageId}`
|
|
);
|
|
const normalizedInputFiles = normalizeLegalFileRecords(
|
|
inputFiles,
|
|
`Source archive license inputs for ${packageId}`
|
|
);
|
|
if (!isDeepStrictEqual(normalizedInputFiles, noticeFiles)) {
|
|
throw new Error(
|
|
`Source archive legal files diverge for ${packageId}.`
|
|
);
|
|
}
|
|
declaredLegalFiles.push(...noticeFiles);
|
|
}
|
|
const normalizedDeclaredFiles = normalizeLegalFileRecords(
|
|
declaredLegalFiles,
|
|
'Source archive declared legal files'
|
|
);
|
|
if (
|
|
!isDeepStrictEqual(
|
|
normalizeLegalFileRecords(
|
|
compliance.licenseInputFiles,
|
|
'Source archive license input contents'
|
|
),
|
|
normalizedDeclaredFiles
|
|
) ||
|
|
!isDeepStrictEqual(
|
|
normalizeLegalFileRecords(
|
|
compliance.noticeLicenseFiles,
|
|
'Source archive notice contents'
|
|
),
|
|
normalizedDeclaredFiles
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Source archive legal contents do not match their manifests.'
|
|
);
|
|
}
|
|
if (
|
|
!hasExactFields(compliance.noticeFile, ['path', 'sha256', 'size']) ||
|
|
compliance.noticeFile.path !== 'THIRD_PARTY_NOTICES.txt' ||
|
|
!Number.isSafeInteger(compliance.noticeFile.size) ||
|
|
compliance.noticeFile.size <= 0 ||
|
|
!SHA256_PATTERN.test(compliance.noticeFile.sha256) ||
|
|
!isDeepStrictEqual(notices.noticeFile, compliance.noticeFile) ||
|
|
notices.totalBytes !==
|
|
compliance.noticeFile.size +
|
|
normalizedDeclaredFiles.reduce(
|
|
(total, file) => total + file.size,
|
|
0
|
|
)
|
|
) {
|
|
throw new Error('Source archive aggregate notices are invalid.');
|
|
}
|
|
if (
|
|
!isDeepStrictEqual(sourceIndex.legal, {
|
|
manifest: 'notices/embedded-mpv-notices.json',
|
|
noticeFile: notices.noticeFile,
|
|
packages: notices.packages,
|
|
})
|
|
) {
|
|
throw new Error(
|
|
'Source archive legal index does not match its notices.'
|
|
);
|
|
}
|
|
}
|
|
|
|
function verifySnapReleaseSourceBinding(
|
|
{ expectedRepositoryRevision, sourceInspection, snapPayloads },
|
|
{
|
|
expectedSourceSnapshotSha256 = EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256,
|
|
validateRuntimeManifest = validateLinuxRuntimeManifest,
|
|
} = {}
|
|
) {
|
|
if (
|
|
typeof expectedRepositoryRevision !== 'string' ||
|
|
!GIT_COMMIT_PATTERN.test(expectedRepositoryRevision)
|
|
) {
|
|
throw new Error(
|
|
'Expected release repository revision must be a full Git commit.'
|
|
);
|
|
}
|
|
if (!isObject(sourceInspection)) {
|
|
throw new Error('Source archive inspection is missing.');
|
|
}
|
|
const {
|
|
archiveFiles,
|
|
archiveSha256,
|
|
compliance,
|
|
localChanges,
|
|
repositoryRevision,
|
|
sourceIndex,
|
|
sourceRuntime,
|
|
} = sourceInspection;
|
|
const stagedRuntimeErrors = [];
|
|
if (sourceRuntime?.origin !== STAGED_RUNTIME_ORIGIN) {
|
|
stagedRuntimeErrors.push(
|
|
`Linux runtime manifest origin must be "${STAGED_RUNTIME_ORIGIN}".`
|
|
);
|
|
}
|
|
if (sourceRuntime?.sourceBuildOrigin !== SOURCE_BUILD_RUNTIME_ORIGIN) {
|
|
stagedRuntimeErrors.push(
|
|
`Linux runtime manifest sourceBuildOrigin must be "${SOURCE_BUILD_RUNTIME_ORIGIN}".`
|
|
);
|
|
}
|
|
const validatorErrors = isObject(sourceRuntime)
|
|
? validateRuntimeManifest({
|
|
...sourceRuntime,
|
|
origin: SOURCE_BUILD_RUNTIME_ORIGIN,
|
|
})
|
|
: validateRuntimeManifest(sourceRuntime);
|
|
const runtimeErrors = Array.isArray(validatorErrors)
|
|
? [...stagedRuntimeErrors, ...validatorErrors]
|
|
: validatorErrors;
|
|
if (!Array.isArray(runtimeErrors) || runtimeErrors.length > 0) {
|
|
throw new Error(
|
|
`Source archive runtime manifest is invalid${
|
|
Array.isArray(runtimeErrors) && runtimeErrors.length > 0
|
|
? `: ${runtimeErrors.join('; ')}`
|
|
: '.'
|
|
}`
|
|
);
|
|
}
|
|
if (
|
|
repositoryRevision !== expectedRepositoryRevision ||
|
|
!isObject(sourceIndex) ||
|
|
sourceIndex.repositoryRevision !== expectedRepositoryRevision
|
|
) {
|
|
throw new Error(
|
|
'Source archive repository revision does not match the released tag.'
|
|
);
|
|
}
|
|
const expectedSourceArchiveBinding = {
|
|
schemaVersion: SOURCE_ARCHIVE_BINDING_SCHEMA_VERSION,
|
|
name: SOURCE_ARCHIVE_NAME,
|
|
sha256: archiveSha256,
|
|
repositoryRevision,
|
|
};
|
|
const sourceArchiveBindingErrors = validateLinuxSourceArchiveBinding(
|
|
expectedSourceArchiveBinding,
|
|
{
|
|
expectedRepositoryRevision,
|
|
expectedSha256: archiveSha256,
|
|
}
|
|
);
|
|
if (sourceArchiveBindingErrors.length > 0) {
|
|
throw new Error(
|
|
`Source archive byte binding is invalid: ${sourceArchiveBindingErrors.join(
|
|
'; '
|
|
)}`
|
|
);
|
|
}
|
|
const localChangesLength =
|
|
typeof localChanges === 'string'
|
|
? Buffer.byteLength(localChanges)
|
|
: localChanges instanceof Uint8Array
|
|
? localChanges.byteLength
|
|
: -1;
|
|
if (localChangesLength !== 0) {
|
|
throw new Error(
|
|
'Source archive must describe a clean released repository revision.'
|
|
);
|
|
}
|
|
if (
|
|
sourceIndex.schemaVersion !== SOURCE_INDEX_SCHEMA_VERSION ||
|
|
!isDeepStrictEqual(Object.keys(sourceIndex).sort(), [
|
|
'archives',
|
|
'legal',
|
|
'libplacebo',
|
|
'repositoryRevision',
|
|
'schemaVersion',
|
|
'sourcePackages',
|
|
]) ||
|
|
!isObject(sourceIndex.legal) ||
|
|
!isDeepStrictEqual(sourceIndex.sourcePackages, sourceRuntime.packages)
|
|
) {
|
|
throw new Error(
|
|
'Source archive index does not match its runtime manifest.'
|
|
);
|
|
}
|
|
verifySourceArchiveCompliance({
|
|
compliance,
|
|
sourceIndex,
|
|
sourceRuntime,
|
|
});
|
|
const libplaceboPackage = sourceRuntime.packages?.libplacebo;
|
|
const sourceSnapshot = validateLinuxRuntimeSourceSnapshot(
|
|
compliance.libplaceboSourceSnapshot,
|
|
{
|
|
expectedSha256: expectedSourceSnapshotSha256,
|
|
}
|
|
);
|
|
const expectedLibplaceboRecord = {
|
|
sourceGitCommit: libplaceboPackage?.sourceGitCommit,
|
|
sourceSubmodules: [...EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES],
|
|
sourceSnapshot,
|
|
};
|
|
if (
|
|
!isDeepStrictEqual(
|
|
libplaceboPackage?.sourceSubmodules,
|
|
EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SUBMODULES
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Source archive libplacebo submodule records do not match the pinned source identity.'
|
|
);
|
|
}
|
|
if (
|
|
!GIT_COMMIT_PATTERN.test(
|
|
expectedLibplaceboRecord.sourceGitCommit ?? ''
|
|
) ||
|
|
!isDeepStrictEqual(sourceIndex.libplacebo, expectedLibplaceboRecord)
|
|
) {
|
|
throw new Error(
|
|
'Source archive libplacebo identity does not match its runtime manifest.'
|
|
);
|
|
}
|
|
const indexedArchives = normalizeArchiveRecords(
|
|
sourceIndex.archives,
|
|
'Source archive index'
|
|
);
|
|
const inspectedArchives = normalizeArchiveRecords(
|
|
archiveFiles,
|
|
'Source archive contents'
|
|
);
|
|
if (!isDeepStrictEqual(inspectedArchives, indexedArchives)) {
|
|
throw new Error(
|
|
'Source archive checksums do not match its source index.'
|
|
);
|
|
}
|
|
const expectedArchiveHashes = Object.values(sourceRuntime.packages ?? {})
|
|
.filter(
|
|
(sourcePackage) =>
|
|
isObject(sourcePackage) &&
|
|
Object.hasOwn(sourcePackage, 'sourceSha256')
|
|
)
|
|
.map((sourcePackage) => sourcePackage.sourceSha256)
|
|
.sort();
|
|
const indexedArchiveHashes = indexedArchives
|
|
.map(({ sha256 }) => sha256)
|
|
.sort();
|
|
if (
|
|
expectedArchiveHashes.length === 0 ||
|
|
expectedArchiveHashes.some(
|
|
(sha256) =>
|
|
typeof sha256 !== 'string' || !SHA256_PATTERN.test(sha256)
|
|
) ||
|
|
new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length ||
|
|
!isDeepStrictEqual(indexedArchiveHashes, expectedArchiveHashes)
|
|
) {
|
|
throw new Error(
|
|
'Source archive checksums do not match the pinned runtime packages.'
|
|
);
|
|
}
|
|
if (!Array.isArray(snapPayloads) || snapPayloads.length === 0) {
|
|
throw new Error('Every selected Snap must be inspected.');
|
|
}
|
|
const assetNames = new Set();
|
|
let x64SnapCount = 0;
|
|
for (const payload of snapPayloads) {
|
|
if (
|
|
!isObject(payload) ||
|
|
typeof payload.assetName !== 'string' ||
|
|
payload.assetName.length === 0 ||
|
|
assetNames.has(payload.assetName)
|
|
) {
|
|
throw new Error('Selected Snap inspections must be unique.');
|
|
}
|
|
assetNames.add(payload.assetName);
|
|
if (payload.architecture === 'x64') {
|
|
x64SnapCount += 1;
|
|
if (payload.markerOnly !== false || !isObject(payload.manifest)) {
|
|
throw new Error(
|
|
`x64 Snap ${payload.assetName} must contain the frame-copy runtime.`
|
|
);
|
|
}
|
|
const manifest = payload.manifest;
|
|
if (
|
|
manifest.platform !== 'linux' ||
|
|
manifest.arch !== 'x64' ||
|
|
manifest.profile !== 'portable' ||
|
|
manifest.runtimeMode !== 'bundled' ||
|
|
!Array.isArray(manifest.targets) ||
|
|
!manifest.targets.includes('snap') ||
|
|
!isDeepStrictEqual(
|
|
manifest.sourceArchive,
|
|
expectedSourceArchiveBinding
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`x64 Snap ${payload.assetName} has an invalid frame-copy source archive binding.`
|
|
);
|
|
}
|
|
// The snap bundles the from-source builder view of the runtime
|
|
// manifest, while the archive carries its staged envelope
|
|
// (origin renamed to "vendored-lgpl", sourceBuildOrigin and
|
|
// stagedAt added). Project the envelope away before binding.
|
|
const expectedSnapSourceRuntime = {
|
|
...sourceRuntime,
|
|
origin: SOURCE_BUILD_RUNTIME_ORIGIN,
|
|
};
|
|
delete expectedSnapSourceRuntime.sourceBuildOrigin;
|
|
delete expectedSnapSourceRuntime.stagedAt;
|
|
if (
|
|
!isDeepStrictEqual(
|
|
manifest.sourceRuntime,
|
|
expectedSnapSourceRuntime
|
|
)
|
|
) {
|
|
throw new Error(
|
|
`Snap source runtime does not match source archive: ${payload.assetName}`
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if (
|
|
!['arm', 'arm64'].includes(payload.architecture) ||
|
|
payload.markerOnly !== true ||
|
|
payload.manifest !== null
|
|
) {
|
|
throw new Error(
|
|
`Non-x64 Snap ${payload.assetName} must remain marker-only.`
|
|
);
|
|
}
|
|
}
|
|
if (x64SnapCount !== 1) {
|
|
throw new Error(
|
|
`Public release must contain exactly one x64 Snap; received ${x64SnapCount}.`
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
module.exports = {
|
|
inspectSnapPayload,
|
|
inspectSourceArchive,
|
|
verifySnapReleaseSourceBinding,
|
|
};
|