mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* fix(packaging): restore Snap desktop runtime * docs(packaging): publish Snap launch repair note * fix(packaging): declare Node 22.12 floor * docs(architecture): update SQLite pin rationale * fix(tooling): align Node engine floor * fix(tooling): constrain supported Node releases * docs(architecture): correct node-abi consumer
1828 lines
58 KiB
JavaScript
1828 lines
58 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createRequire } from 'node:module';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
collectEmbeddedMpvNativeArchiveEntries,
|
|
listAsarPackageEntries,
|
|
} from './asar-dependency-closure.mjs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const {
|
|
parseReadelfDynamic,
|
|
} = require('../embedded-mpv/build-linux-runtime.cjs');
|
|
const {
|
|
listElectronShippedLinuxLibraries,
|
|
validatePackagedEmbeddedMpv,
|
|
} = require('./embedded-mpv-packaging.cjs');
|
|
const {
|
|
LINUX_SYSTEM_PACKAGE_DEPENDENCIES,
|
|
resolveLinuxFrameCopyProfile,
|
|
} = require('./linux-frame-copy-profile.cjs');
|
|
const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs');
|
|
const {
|
|
PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS,
|
|
PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS,
|
|
RUNTIME_PROBE_MAX_BUFFER_BYTES,
|
|
} = require('../embedded-mpv/runtime-probe-contract.cjs');
|
|
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const LIBMPV_DEPENDENCY_PATTERN = /^libmpv\.so(?:\.|$)/;
|
|
const SNAP_METADATA_MAX_BYTES = 256 * 1024;
|
|
const SNAP_DESKTOP_RUNTIME_SCRIPTS = [
|
|
'desktop-init.sh',
|
|
'desktop-common.sh',
|
|
'desktop-gnome-specific.sh',
|
|
];
|
|
// Keep this set identical to the packaged helper sanitizer in
|
|
// embedded-mpv-frame-copy-runtime/helper-environment.ts. Feature/debug
|
|
// selectors such as LIBGL_ALWAYS_SOFTWARE remain intentionally available.
|
|
const UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES = [
|
|
'BASH_ENV',
|
|
'ENV',
|
|
'BASHOPTS',
|
|
'SHELLOPTS',
|
|
'PS4',
|
|
'BASH_XTRACEFD',
|
|
'CDPATH',
|
|
'LD_AUDIT',
|
|
'LD_LIBRARY_PATH',
|
|
'LD_ORIGIN_PATH',
|
|
'LD_PRELOAD',
|
|
'__EGL_EXTERNAL_PLATFORM_CONFIG_DIRS',
|
|
'__EGL_EXTERNAL_PLATFORM_CONFIG_FILENAMES',
|
|
'__EGL_VENDOR_LIBRARY_DIRS',
|
|
'__EGL_VENDOR_LIBRARY_FILENAMES',
|
|
'GBM_BACKEND',
|
|
'GBM_BACKENDS_PATH',
|
|
'LIBGL_DRIVERS_PATH',
|
|
'MESA_LOADER_DRIVER_OVERRIDE',
|
|
'LIBVA_DRIVER_NAME',
|
|
'LIBVA_DRIVERS_PATH',
|
|
'VDPAU_DRIVER_PATH',
|
|
'VK_DRIVER_FILES',
|
|
'VK_ICD_FILENAMES',
|
|
'VK_ADD_DRIVER_FILES',
|
|
'VK_ADD_LAYER_PATH',
|
|
'VK_IMPLICIT_LAYER_PATH',
|
|
'VK_ADD_IMPLICIT_LAYER_PATH',
|
|
'VK_LAYER_PATH',
|
|
];
|
|
|
|
export function runVerifierCommand(command, args, options = {}) {
|
|
const spawnOptions = {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
encoding: 'utf8',
|
|
stdio: 'pipe',
|
|
timeout: options.timeout,
|
|
killSignal: options.killSignal,
|
|
windowsHide: true,
|
|
};
|
|
if (options.maxBuffer !== undefined) {
|
|
spawnOptions.maxBuffer = options.maxBuffer;
|
|
}
|
|
return spawnSync(command, args, spawnOptions);
|
|
}
|
|
|
|
function assertCommandSucceeded(command, args, result) {
|
|
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 = [result?.stdout, result?.stderr]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.trim();
|
|
throw new Error(
|
|
`${command} ${args.join(' ')} failed with status ${
|
|
result?.status ?? 'unknown'
|
|
}.${details ? `\n${details}` : ''}`
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function detectLinuxArtifactFormat(artifactPath) {
|
|
const fileName = path.basename(artifactPath);
|
|
if (/\.AppImage$/i.test(fileName)) {
|
|
return 'appimage';
|
|
}
|
|
if (/\.deb$/i.test(fileName)) {
|
|
return 'deb';
|
|
}
|
|
if (/\.rpm$/i.test(fileName)) {
|
|
return 'rpm';
|
|
}
|
|
if (/\.(?:pacman|pkg\.tar(?:\.[A-Za-z0-9]+)*)$/i.test(fileName)) {
|
|
return 'pacman';
|
|
}
|
|
if (/\.snap$/i.test(fileName)) {
|
|
return 'snap';
|
|
}
|
|
if (/\.flatpak$/i.test(fileName)) {
|
|
return 'flatpak';
|
|
}
|
|
throw new Error(`Unsupported Linux package artifact: ${artifactPath}`);
|
|
}
|
|
|
|
export function parseVerifierArguments(argv) {
|
|
const normalizedArgs = argv[0] === '--' ? argv.slice(1) : [...argv];
|
|
let artifactValue;
|
|
let profileValue;
|
|
for (let index = 0; index < normalizedArgs.length; index += 1) {
|
|
const argument = normalizedArgs[index];
|
|
if (argument === '--artifact') {
|
|
if (artifactValue !== undefined) {
|
|
throw new Error('Received duplicate --artifact argument.');
|
|
}
|
|
artifactValue = normalizedArgs[++index];
|
|
continue;
|
|
}
|
|
if (argument === '--profile') {
|
|
if (profileValue !== undefined) {
|
|
throw new Error('Received duplicate --profile argument.');
|
|
}
|
|
profileValue = normalizedArgs[++index];
|
|
continue;
|
|
}
|
|
throw new Error(`Unsupported verifier argument: ${argument}`);
|
|
}
|
|
if (!artifactValue) {
|
|
throw new Error('--artifact is required.');
|
|
}
|
|
if (!profileValue) {
|
|
throw new Error('--profile is required.');
|
|
}
|
|
const artifactPath = path.resolve(artifactValue);
|
|
const stat = fs.lstatSync(artifactPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Linux package artifact must be a regular file: ${artifactPath}`
|
|
);
|
|
}
|
|
const profile = resolveLinuxFrameCopyProfile(profileValue);
|
|
detectLinuxArtifactFormat(artifactPath);
|
|
return {
|
|
artifactPath,
|
|
profileName: profile.name,
|
|
};
|
|
}
|
|
|
|
export function findAppImageSquashfsOffsets(artifactPath) {
|
|
const magic = Buffer.from('hsqs');
|
|
const chunkSize = 1024 * 1024;
|
|
const descriptor = fs.openSync(artifactPath, 'r');
|
|
const offsets = [];
|
|
let position = 0;
|
|
let overlap = Buffer.alloc(0);
|
|
try {
|
|
while (true) {
|
|
const chunk = Buffer.alloc(chunkSize);
|
|
const bytesRead = fs.readSync(
|
|
descriptor,
|
|
chunk,
|
|
0,
|
|
chunk.length,
|
|
position
|
|
);
|
|
if (bytesRead === 0) {
|
|
break;
|
|
}
|
|
const contents = Buffer.concat([
|
|
overlap,
|
|
chunk.subarray(0, bytesRead),
|
|
]);
|
|
const contentsStart = position - overlap.length;
|
|
let searchOffset = 0;
|
|
while (searchOffset <= contents.length - magic.length) {
|
|
const matchOffset = contents.indexOf(magic, searchOffset);
|
|
if (matchOffset === -1) {
|
|
break;
|
|
}
|
|
const absoluteOffset = contentsStart + matchOffset;
|
|
if (offsets.at(-1) !== absoluteOffset) {
|
|
offsets.push(absoluteOffset);
|
|
}
|
|
searchOffset = matchOffset + 1;
|
|
}
|
|
overlap = contents.subarray(
|
|
Math.max(0, contents.length - (magic.length - 1))
|
|
);
|
|
position += bytesRead;
|
|
}
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
export function extractLinuxArtifact({
|
|
artifactPath,
|
|
format,
|
|
destination,
|
|
runCommand = runVerifierCommand,
|
|
}) {
|
|
fs.rmSync(destination, { recursive: true, force: true });
|
|
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
const run = (command, args, options = {}) =>
|
|
assertCommandSucceeded(
|
|
command,
|
|
args,
|
|
runCommand(command, args, options)
|
|
);
|
|
|
|
switch (format) {
|
|
case 'appimage': {
|
|
const squashfsOffsets = findAppImageSquashfsOffsets(artifactPath);
|
|
if (squashfsOffsets.length === 0) {
|
|
throw new Error(
|
|
`AppImage contains no SquashFS payload: ${artifactPath}`
|
|
);
|
|
}
|
|
const failures = [];
|
|
for (const offset of squashfsOffsets) {
|
|
fs.rmSync(destination, { recursive: true, force: true });
|
|
const args = [
|
|
'-no-progress',
|
|
'-offset',
|
|
String(offset),
|
|
'-dest',
|
|
destination,
|
|
artifactPath,
|
|
];
|
|
const result = runCommand('unsquashfs', args);
|
|
if (!result?.error && result?.status === 0) {
|
|
return destination;
|
|
}
|
|
failures.push(
|
|
[result?.stdout, result?.stderr]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.trim()
|
|
);
|
|
}
|
|
throw new Error(
|
|
[
|
|
`Unable to extract the AppImage SquashFS payload at any candidate offset: ${artifactPath}`,
|
|
...failures.filter(Boolean),
|
|
].join('\n')
|
|
);
|
|
}
|
|
case 'deb':
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
run('dpkg-deb', ['--extract', artifactPath, destination]);
|
|
return destination;
|
|
case 'rpm':
|
|
case 'pacman':
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
run('bsdtar', [
|
|
'--extract',
|
|
'--file',
|
|
artifactPath,
|
|
'--directory',
|
|
destination,
|
|
]);
|
|
return destination;
|
|
case 'snap':
|
|
run('unsquashfs', [
|
|
'-no-progress',
|
|
'-dest',
|
|
destination,
|
|
artifactPath,
|
|
]);
|
|
return destination;
|
|
case 'flatpak': {
|
|
fs.mkdirSync(destination, { recursive: true });
|
|
const repository = path.join(destination, '.ostree-repository');
|
|
const checkout = path.join(destination, 'checkout');
|
|
fs.mkdirSync(repository, { recursive: true });
|
|
run('ostree', [
|
|
`--repo=${repository}`,
|
|
'init',
|
|
'--mode=archive-z2',
|
|
]);
|
|
run('flatpak', ['build-import-bundle', repository, artifactPath]);
|
|
const refsResult = run('ostree', ['refs', `--repo=${repository}`]);
|
|
const refs = String(refsResult.stdout ?? '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.startsWith('app/'));
|
|
if (refs.length !== 1) {
|
|
throw new Error(
|
|
`Flatpak bundle must import exactly one application ref; received ${
|
|
refs.length > 0 ? refs.join(', ') : '<none>'
|
|
}.`
|
|
);
|
|
}
|
|
run('ostree', [
|
|
'checkout',
|
|
'-U',
|
|
`--repo=${repository}`,
|
|
refs[0],
|
|
checkout,
|
|
]);
|
|
return checkout;
|
|
}
|
|
default:
|
|
throw new Error(`Unsupported Linux package format: ${format}`);
|
|
}
|
|
}
|
|
|
|
export function findExtractedResourceDir(extractionRoot) {
|
|
const candidates = [];
|
|
|
|
function visit(directoryPath) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(directoryPath, { withFileTypes: true });
|
|
} catch (error) {
|
|
throw new Error(
|
|
`Unable to inspect extracted package directory ${directoryPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(directoryPath, entry.name);
|
|
if (entry.name === 'resources') {
|
|
const nativeDir = path.join(
|
|
entryPath,
|
|
'app.asar.unpacked',
|
|
'electron-backend',
|
|
'native'
|
|
);
|
|
let nativeStat;
|
|
try {
|
|
nativeStat = fs.lstatSync(nativeDir);
|
|
} catch {
|
|
nativeStat = null;
|
|
}
|
|
if (nativeStat?.isDirectory() && !nativeStat.isSymbolicLink()) {
|
|
candidates.push(entryPath);
|
|
continue;
|
|
}
|
|
}
|
|
visit(entryPath);
|
|
}
|
|
}
|
|
|
|
visit(extractionRoot);
|
|
if (candidates.length !== 1) {
|
|
throw new Error(
|
|
`Expected exactly one embedded MPV native payload in ${extractionRoot}; found ${candidates.length}.`
|
|
);
|
|
}
|
|
return candidates[0];
|
|
}
|
|
|
|
function stripYamlTrailingComment(value) {
|
|
let quote = null;
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const character = value[index];
|
|
if (quote === "'") {
|
|
if (character === "'" && value[index + 1] === "'") {
|
|
index += 1;
|
|
} else if (character === "'") {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (quote === '"') {
|
|
if (character === '\\') {
|
|
index += 1;
|
|
} else if (character === '"') {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (character === "'" || character === '"') {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
if (
|
|
character === '#' &&
|
|
(index === 0 || /[ \t]/.test(value[index - 1]))
|
|
) {
|
|
return value.slice(0, index).trimEnd();
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function yamlMappingEntries(lines, key, indent, start = 0, end = lines.length) {
|
|
const prefix = `${' '.repeat(indent)}${key}:`;
|
|
return lines
|
|
.map((line, index) => ({ index, line }))
|
|
.filter(
|
|
({ index, line }) =>
|
|
index >= start &&
|
|
index < end &&
|
|
line.startsWith(prefix) &&
|
|
line.slice(prefix.length).match(/^(?:\s|$)/) &&
|
|
line.length - line.trimStart().length === indent
|
|
)
|
|
.map(({ index, line }) => {
|
|
let blockEnd = end;
|
|
for (
|
|
let candidateIndex = index + 1;
|
|
candidateIndex < end;
|
|
candidateIndex += 1
|
|
) {
|
|
const candidate = lines[candidateIndex];
|
|
if (
|
|
!candidate.trim() ||
|
|
candidate.trimStart().startsWith('#')
|
|
) {
|
|
continue;
|
|
}
|
|
const candidateIndent =
|
|
candidate.length - candidate.trimStart().length;
|
|
if (candidateIndent <= indent) {
|
|
blockEnd = candidateIndex;
|
|
break;
|
|
}
|
|
}
|
|
return {
|
|
index,
|
|
end: blockEnd,
|
|
value: stripYamlTrailingComment(
|
|
line.slice(prefix.length).trim()
|
|
),
|
|
};
|
|
});
|
|
}
|
|
|
|
function singleYamlMappingEntry(lines, key, indent, start, end) {
|
|
const entries = yamlMappingEntries(lines, key, indent, start, end);
|
|
return entries.length === 1 ? entries[0] : null;
|
|
}
|
|
|
|
function directYamlMappingEntries(lines, parent, indent) {
|
|
const entries = [];
|
|
for (let index = parent.index + 1; index < parent.end; index += 1) {
|
|
const line = lines[index];
|
|
if (!line.trim() || line.trimStart().startsWith('#')) {
|
|
continue;
|
|
}
|
|
const lineIndent = line.length - line.trimStart().length;
|
|
if (lineIndent !== indent) {
|
|
continue;
|
|
}
|
|
const match = line
|
|
.slice(indent)
|
|
.match(/^([A-Za-z0-9][A-Za-z0-9_-]*):(?:\s*(.*))?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
let blockEnd = parent.end;
|
|
for (
|
|
let candidateIndex = index + 1;
|
|
candidateIndex < parent.end;
|
|
candidateIndex += 1
|
|
) {
|
|
const candidate = lines[candidateIndex];
|
|
if (!candidate.trim() || candidate.trimStart().startsWith('#')) {
|
|
continue;
|
|
}
|
|
const candidateIndent =
|
|
candidate.length - candidate.trimStart().length;
|
|
if (candidateIndent <= indent) {
|
|
blockEnd = candidateIndex;
|
|
break;
|
|
}
|
|
}
|
|
entries.push({
|
|
key: match[1],
|
|
value: stripYamlTrailingComment(match[2] ?? ''),
|
|
index,
|
|
end: blockEnd,
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function directYamlAbsolutePathMappingEntries(lines, parent, indent) {
|
|
const entries = [];
|
|
for (let index = parent.index + 1; index < parent.end; index += 1) {
|
|
const line = lines[index];
|
|
if (!line.trim() || line.trimStart().startsWith('#')) {
|
|
continue;
|
|
}
|
|
const lineIndent = line.length - line.trimStart().length;
|
|
if (lineIndent !== indent) {
|
|
continue;
|
|
}
|
|
const match = line
|
|
.slice(indent)
|
|
.match(/^(\/[A-Za-z0-9._/-]+):(?:\s*(.*))?$/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
let blockEnd = parent.end;
|
|
for (
|
|
let candidateIndex = index + 1;
|
|
candidateIndex < parent.end;
|
|
candidateIndex += 1
|
|
) {
|
|
const candidate = lines[candidateIndex];
|
|
if (!candidate.trim() || candidate.trimStart().startsWith('#')) {
|
|
continue;
|
|
}
|
|
const candidateIndent =
|
|
candidate.length - candidate.trimStart().length;
|
|
if (candidateIndent <= indent) {
|
|
blockEnd = candidateIndex;
|
|
break;
|
|
}
|
|
}
|
|
entries.push({
|
|
key: match[1],
|
|
value: stripYamlTrailingComment(match[2] ?? ''),
|
|
index,
|
|
end: blockEnd,
|
|
});
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function yamlScalarEquals(value, expected) {
|
|
return (
|
|
value === expected ||
|
|
value === JSON.stringify(expected) ||
|
|
value === `'${expected.replaceAll("'", "''")}'`
|
|
);
|
|
}
|
|
|
|
function yamlSequenceIncludes(lines, entry, expected) {
|
|
if (entry.value) {
|
|
if (!entry.value.startsWith('[') || !entry.value.endsWith(']')) {
|
|
return false;
|
|
}
|
|
const values = entry.value
|
|
.slice(1, -1)
|
|
.split(',')
|
|
.map((value) => value.trim());
|
|
return (
|
|
values.length > 0 &&
|
|
values.every(
|
|
(value) =>
|
|
value.length > 0 &&
|
|
!/[:[\]{}&*]/.test(value) &&
|
|
!value.startsWith('-')
|
|
) &&
|
|
values.some((value) => yamlScalarEquals(value, expected))
|
|
);
|
|
}
|
|
const sequenceLines = lines
|
|
.slice(entry.index + 1, entry.end)
|
|
.filter((line) => line.trim() && !line.trimStart().startsWith('#'));
|
|
const itemIndent = 6;
|
|
const values = sequenceLines.map((line) => {
|
|
const lineIndent = line.length - line.trimStart().length;
|
|
if (lineIndent !== itemIndent) {
|
|
return null;
|
|
}
|
|
const match = line.trim().match(/^-\s+([^:[\]{}&*]+)$/);
|
|
return match ? stripYamlTrailingComment(match[1].trim()) : null;
|
|
});
|
|
return (
|
|
values.length > 0 &&
|
|
values.every((value) => value !== null) &&
|
|
values.some((value) => yamlScalarEquals(value, expected))
|
|
);
|
|
}
|
|
|
|
function yamlSyntaxOutsideQuotedScalars(line) {
|
|
let result = '';
|
|
let quote = null;
|
|
for (let index = 0; index < line.length; index += 1) {
|
|
const character = line[index];
|
|
if (quote === "'") {
|
|
if (character === "'" && line[index + 1] === "'") {
|
|
result += ' ';
|
|
index += 1;
|
|
} else {
|
|
result += ' ';
|
|
if (character === "'") {
|
|
quote = null;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if (quote === '"') {
|
|
result += ' ';
|
|
if (character === '\\') {
|
|
result += ' ';
|
|
index += 1;
|
|
} else if (character === '"') {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (character === '#') {
|
|
break;
|
|
}
|
|
if (character === "'" || character === '"') {
|
|
quote = character;
|
|
result += ' ';
|
|
continue;
|
|
}
|
|
result += character;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function containsUnsupportedYamlSemantics(lines) {
|
|
let blockScalarParentIndent = null;
|
|
for (const line of lines) {
|
|
if (!line.trim()) {
|
|
continue;
|
|
}
|
|
const lineIndent = line.length - line.trimStart().length;
|
|
if (
|
|
blockScalarParentIndent !== null &&
|
|
lineIndent > blockScalarParentIndent
|
|
) {
|
|
continue;
|
|
}
|
|
blockScalarParentIndent = null;
|
|
const syntax = yamlSyntaxOutsideQuotedScalars(line);
|
|
if (
|
|
/(?:^|[\s:[\]{},])(?:[&*][^\s,[\]{}]+|![^\s,[\]{}]+)(?=$|[\s,\]}])/.test(
|
|
syntax
|
|
) ||
|
|
/(?:^|\s)<<\s*:/.test(syntax)
|
|
) {
|
|
return true;
|
|
}
|
|
if (/:\s*[>|][+-]?\d?\s*$/.test(syntax)) {
|
|
blockScalarParentIndent = lineIndent;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function validateExtractedSnapMetadata(extractionRoot) {
|
|
const snapYamlPath = path.join(extractionRoot, 'meta', 'snap.yaml');
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(snapYamlPath);
|
|
} catch {
|
|
return [`Missing extracted Snap metadata: ${snapYamlPath}`];
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
return [
|
|
`Extracted Snap metadata must be a regular file: ${snapYamlPath}`,
|
|
];
|
|
}
|
|
if (stat.size > SNAP_METADATA_MAX_BYTES) {
|
|
return [
|
|
`Extracted Snap metadata exceeds the ${String(
|
|
SNAP_METADATA_MAX_BYTES
|
|
)}-byte size limit: ${snapYamlPath}`,
|
|
];
|
|
}
|
|
|
|
let contents;
|
|
try {
|
|
contents = fs.readFileSync(snapYamlPath, 'utf8');
|
|
} catch (error) {
|
|
return [
|
|
`Unable to read extracted Snap metadata at ${snapYamlPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
];
|
|
}
|
|
if (contents.includes('\t')) {
|
|
return [
|
|
`Extracted Snap metadata must use space indentation: ${snapYamlPath}`,
|
|
];
|
|
}
|
|
const lines = contents.split(/\r?\n/);
|
|
if (containsUnsupportedYamlSemantics(lines)) {
|
|
return [
|
|
'Extracted Snap metadata must not use YAML anchors, aliases, merge keys, or custom tags.',
|
|
];
|
|
}
|
|
const errors = [];
|
|
for (const script of SNAP_DESKTOP_RUNTIME_SCRIPTS) {
|
|
const desktopScriptPath = path.join(extractionRoot, script);
|
|
let desktopScriptStat;
|
|
try {
|
|
desktopScriptStat = fs.lstatSync(desktopScriptPath);
|
|
} catch {
|
|
errors.push(
|
|
`Missing required desktop runtime script: ${desktopScriptPath}`
|
|
);
|
|
continue;
|
|
}
|
|
if (
|
|
!desktopScriptStat.isFile() ||
|
|
desktopScriptStat.isSymbolicLink()
|
|
) {
|
|
errors.push(
|
|
`Extracted Snap desktop runtime script must be a regular file: ${desktopScriptPath}`
|
|
);
|
|
continue;
|
|
}
|
|
if (
|
|
script === 'desktop-init.sh' &&
|
|
(desktopScriptStat.mode & 0o111) === 0
|
|
) {
|
|
errors.push(
|
|
`Extracted Snap desktop-init.sh must be executable: ${desktopScriptPath}`
|
|
);
|
|
}
|
|
}
|
|
const graphicsMountPath = path.join(extractionRoot, 'graphics');
|
|
let graphicsMountStat;
|
|
try {
|
|
graphicsMountStat = fs.lstatSync(graphicsMountPath);
|
|
} catch {
|
|
errors.push(
|
|
`Extracted Snap must contain an empty graphics content mount directory: ${graphicsMountPath}`
|
|
);
|
|
}
|
|
if (
|
|
graphicsMountStat &&
|
|
(!graphicsMountStat.isDirectory() || graphicsMountStat.isSymbolicLink())
|
|
) {
|
|
errors.push(
|
|
`Extracted Snap graphics content mount must be a real directory: ${graphicsMountPath}`
|
|
);
|
|
} else if (graphicsMountStat) {
|
|
if ((graphicsMountStat.mode & 0o777) !== 0o755) {
|
|
errors.push(
|
|
`Extracted Snap graphics content mount directory must have mode 0755: ${graphicsMountPath}`
|
|
);
|
|
}
|
|
try {
|
|
if (fs.readdirSync(graphicsMountPath).length > 0) {
|
|
errors.push(
|
|
`Extracted Snap graphics content mount directory must be empty: ${graphicsMountPath}`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to inspect extracted Snap graphics content mount at ${graphicsMountPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
}
|
|
const base = singleYamlMappingEntry(lines, 'base', 0, 0, lines.length);
|
|
if (!base || !yamlScalarEquals(base.value, 'core22')) {
|
|
errors.push('Extracted Snap metadata must declare base: core22.');
|
|
}
|
|
const confinement = singleYamlMappingEntry(
|
|
lines,
|
|
'confinement',
|
|
0,
|
|
0,
|
|
lines.length
|
|
);
|
|
if (!confinement || !yamlScalarEquals(confinement.value, 'strict')) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare confinement: strict.'
|
|
);
|
|
}
|
|
const layout = singleYamlMappingEntry(lines, 'layout', 0, 0, lines.length);
|
|
if (!layout || layout.value) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare the exact Snap graphics layout contract.'
|
|
);
|
|
} else {
|
|
const layoutEntries = directYamlAbsolutePathMappingEntries(
|
|
lines,
|
|
layout,
|
|
2
|
|
);
|
|
const expectedLayouts = [
|
|
{
|
|
path: '/usr/share/libdrm',
|
|
field: 'bind',
|
|
target: '$SNAP/graphics/libdrm',
|
|
label: 'libdrm',
|
|
},
|
|
{
|
|
path: '/usr/share/drirc.d',
|
|
field: 'symlink',
|
|
target: '$SNAP/graphics/drirc.d',
|
|
label: 'drirc.d',
|
|
},
|
|
];
|
|
if (
|
|
layoutEntries &&
|
|
new Set(layoutEntries.map(({ key }) => key)).size !==
|
|
layoutEntries.length
|
|
) {
|
|
errors.push('Extracted Snap layout paths must be unique.');
|
|
}
|
|
if (
|
|
!layoutEntries ||
|
|
layoutEntries.length !== expectedLayouts.length ||
|
|
expectedLayouts.some(
|
|
({ path: expectedPath }) =>
|
|
layoutEntries.filter(({ key }) => key === expectedPath)
|
|
.length !== 1
|
|
)
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap graphics layout must contain exactly the libdrm and drirc.d entries.'
|
|
);
|
|
}
|
|
for (const expected of expectedLayouts) {
|
|
const entry = layoutEntries?.find(
|
|
({ key }) => key === expected.path
|
|
);
|
|
const fields =
|
|
entry && !entry.value
|
|
? directYamlMappingEntries(lines, entry, 4)
|
|
: null;
|
|
if (
|
|
!fields ||
|
|
fields.length !== 1 ||
|
|
fields[0].key !== expected.field
|
|
) {
|
|
errors.push(
|
|
`Extracted Snap ${expected.label} layout must contain exactly ${expected.field}.`
|
|
);
|
|
}
|
|
if (
|
|
!fields ||
|
|
fields.filter(
|
|
({ key, value }) =>
|
|
key === expected.field &&
|
|
yamlScalarEquals(value, expected.target)
|
|
).length !== 1
|
|
) {
|
|
errors.push(
|
|
`Extracted Snap ${expected.label} layout must declare ${expected.field}: ${expected.target}.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const plugs = singleYamlMappingEntry(lines, 'plugs', 0, 0, lines.length);
|
|
const sharedMemory =
|
|
plugs &&
|
|
singleYamlMappingEntry(
|
|
lines,
|
|
'shared-memory',
|
|
2,
|
|
plugs.index + 1,
|
|
plugs.end
|
|
);
|
|
const graphicsCore22 =
|
|
plugs &&
|
|
singleYamlMappingEntry(
|
|
lines,
|
|
'graphics-core22',
|
|
2,
|
|
plugs.index + 1,
|
|
plugs.end
|
|
);
|
|
if (!plugs || plugs.value || !sharedMemory || sharedMemory.value) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare exactly one top-level shared-memory plug.'
|
|
);
|
|
} else {
|
|
const fields = directYamlMappingEntries(lines, sharedMemory, 4);
|
|
const interfaceEntries =
|
|
fields?.filter(({ key }) => key === 'interface') ?? [];
|
|
const privateEntries =
|
|
fields?.filter(({ key }) => key === 'private') ?? [];
|
|
const interfaceEntry = interfaceEntries[0];
|
|
const privateEntry = privateEntries[0];
|
|
if (
|
|
!fields ||
|
|
fields.length !== 2 ||
|
|
interfaceEntries.length !== 1 ||
|
|
privateEntries.length !== 1
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap private shared-memory plug must contain exactly interface and private.'
|
|
);
|
|
}
|
|
if (
|
|
!interfaceEntry ||
|
|
!yamlScalarEquals(interfaceEntry.value, 'shared-memory')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap top-level shared-memory plug must declare interface: shared-memory.'
|
|
);
|
|
}
|
|
if (!privateEntry || privateEntry.value !== 'true') {
|
|
errors.push(
|
|
'Extracted Snap top-level shared-memory plug must declare private: true.'
|
|
);
|
|
}
|
|
|
|
const plugEntries = directYamlMappingEntries(lines, plugs, 2);
|
|
if (!plugEntries || plugEntries.some(({ value }) => value.length > 0)) {
|
|
errors.push(
|
|
'Extracted Snap plug declarations must use block mappings.'
|
|
);
|
|
}
|
|
if (
|
|
plugEntries &&
|
|
new Set(plugEntries.map(({ key }) => key)).size !==
|
|
plugEntries.length
|
|
) {
|
|
errors.push('Extracted Snap direct plug keys must be unique.');
|
|
}
|
|
const sharedMemoryInterfaces =
|
|
plugEntries?.filter((plugEntry) => {
|
|
const plugFields = directYamlMappingEntries(
|
|
lines,
|
|
plugEntry,
|
|
4
|
|
);
|
|
const interfaceFields =
|
|
plugFields?.filter(({ key }) => key === 'interface') ?? [];
|
|
return (
|
|
interfaceFields.length === 1 &&
|
|
yamlScalarEquals(interfaceFields[0].value, 'shared-memory')
|
|
);
|
|
}) ?? [];
|
|
if (
|
|
!plugEntries ||
|
|
sharedMemoryInterfaces.length !== 1 ||
|
|
sharedMemoryInterfaces[0].key !== 'shared-memory'
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare exactly one plug with the shared-memory interface.'
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!plugs || plugs.value || !graphicsCore22 || graphicsCore22.value) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare exactly one top-level graphics-core22 plug.'
|
|
);
|
|
} else {
|
|
const fields = directYamlMappingEntries(lines, graphicsCore22, 4);
|
|
const interfaceEntries =
|
|
fields?.filter(({ key }) => key === 'interface') ?? [];
|
|
const targetEntries =
|
|
fields?.filter(({ key }) => key === 'target') ?? [];
|
|
const providerEntries =
|
|
fields?.filter(({ key }) => key === 'default-provider') ?? [];
|
|
const interfaceEntry = interfaceEntries[0];
|
|
const targetEntry = targetEntries[0];
|
|
const providerEntry = providerEntries[0];
|
|
if (
|
|
!fields ||
|
|
fields.length !== 3 ||
|
|
interfaceEntries.length !== 1 ||
|
|
targetEntries.length !== 1 ||
|
|
providerEntries.length !== 1
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap graphics-core22 plug must contain exactly interface, target, and default-provider.'
|
|
);
|
|
}
|
|
if (
|
|
!interfaceEntry ||
|
|
!yamlScalarEquals(interfaceEntry.value, 'content')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap graphics-core22 plug must declare interface: content.'
|
|
);
|
|
}
|
|
if (
|
|
!targetEntry ||
|
|
!yamlScalarEquals(targetEntry.value, '$SNAP/graphics')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap graphics-core22 plug must declare target: $SNAP/graphics.'
|
|
);
|
|
}
|
|
if (
|
|
!providerEntry ||
|
|
!yamlScalarEquals(providerEntry.value, 'mesa-core22')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap graphics-core22 plug must declare default-provider: mesa-core22.'
|
|
);
|
|
}
|
|
|
|
const plugEntries = directYamlMappingEntries(lines, plugs, 2);
|
|
const graphicsContracts =
|
|
plugEntries?.filter((plugEntry) => {
|
|
const plugFields = directYamlMappingEntries(
|
|
lines,
|
|
plugEntry,
|
|
4
|
|
);
|
|
if (!plugFields || plugFields.length !== 3) {
|
|
return false;
|
|
}
|
|
return [
|
|
['interface', 'content'],
|
|
['target', '$SNAP/graphics'],
|
|
['default-provider', 'mesa-core22'],
|
|
].every(
|
|
([key, expected]) =>
|
|
plugFields.filter(
|
|
({ key: fieldKey, value }) =>
|
|
fieldKey === key &&
|
|
yamlScalarEquals(value, expected)
|
|
).length === 1
|
|
);
|
|
}) ?? [];
|
|
if (
|
|
!plugEntries ||
|
|
graphicsContracts.length !== 1 ||
|
|
graphicsContracts[0].key !== 'graphics-core22'
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare exactly one plug with the graphics-core22 contract.'
|
|
);
|
|
}
|
|
const graphicsTargets =
|
|
plugEntries?.filter((plugEntry) => {
|
|
const plugFields = directYamlMappingEntries(
|
|
lines,
|
|
plugEntry,
|
|
4
|
|
);
|
|
return (
|
|
plugFields?.some(
|
|
({ key, value }) =>
|
|
key === 'target' &&
|
|
yamlScalarEquals(value, '$SNAP/graphics')
|
|
) ?? false
|
|
);
|
|
}) ?? [];
|
|
if (
|
|
!plugEntries ||
|
|
graphicsTargets.length !== 1 ||
|
|
graphicsTargets[0].key !== 'graphics-core22'
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap metadata must declare exactly one plug with target $SNAP/graphics.'
|
|
);
|
|
}
|
|
}
|
|
|
|
const slotsEntries = yamlMappingEntries(lines, 'slots', 0, 0, lines.length);
|
|
let hasSharedMemorySlot = slotsEntries.length > 1;
|
|
let invalidSlotsShape = slotsEntries.some(({ value }) => value.length > 0);
|
|
for (const slots of slotsEntries) {
|
|
const slotEntries = directYamlMappingEntries(lines, slots, 2);
|
|
if (!slotEntries) {
|
|
invalidSlotsShape = true;
|
|
continue;
|
|
}
|
|
invalidSlotsShape ||= slotEntries.some(({ value }) => value.length > 0);
|
|
hasSharedMemorySlot ||= slotEntries.some((slotEntry) => {
|
|
if (slotEntry.key === 'shared-memory') {
|
|
return true;
|
|
}
|
|
const slotFields = directYamlMappingEntries(lines, slotEntry, 4);
|
|
return (
|
|
slotFields?.some(
|
|
({ key, value }) =>
|
|
key === 'interface' &&
|
|
yamlScalarEquals(value, 'shared-memory')
|
|
) ?? false
|
|
);
|
|
});
|
|
}
|
|
if (invalidSlotsShape) {
|
|
errors.push(
|
|
'Extracted Snap slots must use block mappings with scalar fields.'
|
|
);
|
|
}
|
|
if (hasSharedMemorySlot) {
|
|
errors.push(
|
|
'Extracted Snap metadata must not declare a shared-memory slot.'
|
|
);
|
|
}
|
|
|
|
const apps = singleYamlMappingEntry(lines, 'apps', 0, 0, lines.length);
|
|
const app =
|
|
apps &&
|
|
singleYamlMappingEntry(lines, 'iptvnator', 2, apps.index + 1, apps.end);
|
|
const appPlugs =
|
|
app &&
|
|
singleYamlMappingEntry(lines, 'plugs', 4, app.index + 1, app.end);
|
|
const appEnvironment =
|
|
app &&
|
|
singleYamlMappingEntry(lines, 'environment', 4, app.index + 1, app.end);
|
|
const snapDesktopRuntime =
|
|
appEnvironment &&
|
|
singleYamlMappingEntry(
|
|
lines,
|
|
'SNAP_DESKTOP_RUNTIME',
|
|
6,
|
|
appEnvironment.index + 1,
|
|
appEnvironment.end
|
|
);
|
|
if (
|
|
!apps ||
|
|
apps.value ||
|
|
!app ||
|
|
app.value ||
|
|
!appPlugs ||
|
|
!yamlSequenceIncludes(lines, appPlugs, 'shared-memory')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap iptvnator app scalar sequence must contain the shared-memory plug.'
|
|
);
|
|
}
|
|
if (
|
|
!apps ||
|
|
apps.value ||
|
|
!app ||
|
|
app.value ||
|
|
!appPlugs ||
|
|
!yamlSequenceIncludes(lines, appPlugs, 'graphics-core22')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap iptvnator app scalar sequence must contain the graphics-core22 plug.'
|
|
);
|
|
}
|
|
if (
|
|
!appEnvironment ||
|
|
appEnvironment.value ||
|
|
!snapDesktopRuntime ||
|
|
!yamlScalarEquals(snapDesktopRuntime.value, '$SNAP/gnome-platform')
|
|
) {
|
|
errors.push(
|
|
'Extracted Snap iptvnator app environment must declare SNAP_DESKTOP_RUNTIME: $SNAP/gnome-platform.'
|
|
);
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
export 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.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
|
) {
|
|
throw new Error(`Not an ELF binary: ${binaryPath}`);
|
|
}
|
|
const byteOrder = header[5];
|
|
const machine =
|
|
byteOrder === 1
|
|
? header.readUInt16LE(18)
|
|
: byteOrder === 2
|
|
? header.readUInt16BE(18)
|
|
: null;
|
|
const architecture = new Map([
|
|
[62, 'x64'],
|
|
[183, 'arm64'],
|
|
[40, 'armv7l'],
|
|
]).get(machine);
|
|
if (!architecture) {
|
|
throw new Error(
|
|
`Unsupported ELF machine ${String(machine)} in ${binaryPath}.`
|
|
);
|
|
}
|
|
return architecture;
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function normalizePackageArchitecture(value) {
|
|
const normalized = String(value ?? '')
|
|
.trim()
|
|
.toLowerCase();
|
|
const architecture = {
|
|
amd64: 'x64',
|
|
x86_64: 'x64',
|
|
arm64: 'arm64',
|
|
aarch64: 'arm64',
|
|
armhf: 'armv7l',
|
|
armv7h: 'armv7l',
|
|
armv7hl: 'armv7l',
|
|
}[normalized];
|
|
if (!architecture) {
|
|
throw new Error(
|
|
`Unsupported Linux package architecture: ${
|
|
normalized || '<empty>'
|
|
}.`
|
|
);
|
|
}
|
|
return architecture;
|
|
}
|
|
|
|
function splitNonEmptyLines(value) {
|
|
return String(value ?? '')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function findPackageInfoFiles(extractionRoot) {
|
|
const packageInfoPaths = [];
|
|
|
|
function visit(directoryPath) {
|
|
for (const entry of fs.readdirSync(directoryPath, {
|
|
withFileTypes: true,
|
|
})) {
|
|
const entryPath = path.join(directoryPath, entry.name);
|
|
if (entry.isDirectory() && !entry.isSymbolicLink()) {
|
|
visit(entryPath);
|
|
} else if (entry.isFile() && entry.name === '.PKGINFO') {
|
|
packageInfoPaths.push(entryPath);
|
|
}
|
|
}
|
|
}
|
|
|
|
visit(extractionRoot);
|
|
return packageInfoPaths;
|
|
}
|
|
|
|
export function readLinuxArtifactMetadata({
|
|
artifactPath,
|
|
format,
|
|
extractionRoot,
|
|
runCommand = runVerifierCommand,
|
|
}) {
|
|
const run = (command, args) =>
|
|
assertCommandSucceeded(command, args, runCommand(command, args));
|
|
if (format === 'deb') {
|
|
const architectureResult = run('dpkg-deb', [
|
|
'--field',
|
|
artifactPath,
|
|
'Architecture',
|
|
]);
|
|
const dependencyResult = run('dpkg-deb', [
|
|
'--field',
|
|
artifactPath,
|
|
'Depends',
|
|
]);
|
|
return {
|
|
declaredArch: normalizePackageArchitecture(
|
|
architectureResult.stdout
|
|
),
|
|
dependencies: String(dependencyResult.stdout ?? '')
|
|
.split(',')
|
|
.map((dependency) => dependency.trim())
|
|
.filter(Boolean),
|
|
};
|
|
}
|
|
if (format === 'rpm') {
|
|
const architectureResult = run('rpm', [
|
|
'-qp',
|
|
'--queryformat',
|
|
'%{ARCH}\\n',
|
|
artifactPath,
|
|
]);
|
|
const dependencyResult = run('rpm', [
|
|
'-qp',
|
|
'--requires',
|
|
artifactPath,
|
|
]);
|
|
return {
|
|
declaredArch: normalizePackageArchitecture(
|
|
architectureResult.stdout
|
|
),
|
|
dependencies: splitNonEmptyLines(dependencyResult.stdout),
|
|
};
|
|
}
|
|
if (format === 'pacman') {
|
|
const packageInfoPaths = findPackageInfoFiles(extractionRoot);
|
|
if (packageInfoPaths.length !== 1) {
|
|
throw new Error(
|
|
`Pacman payload must contain exactly one .PKGINFO; found ${packageInfoPaths.length}.`
|
|
);
|
|
}
|
|
const fields = fs
|
|
.readFileSync(packageInfoPaths[0], 'utf8')
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(/^([^=]+?)\s*=\s*(.*)$/))
|
|
.filter(Boolean)
|
|
.map((match) => ({
|
|
name: match[1].trim(),
|
|
value: match[2].trim(),
|
|
}));
|
|
const architectures = fields
|
|
.filter(({ name }) => name === 'arch')
|
|
.map(({ value }) => value);
|
|
if (architectures.length !== 1) {
|
|
throw new Error(
|
|
`Pacman .PKGINFO must declare exactly one architecture; found ${architectures.length}.`
|
|
);
|
|
}
|
|
return {
|
|
declaredArch: normalizePackageArchitecture(architectures[0]),
|
|
dependencies: fields
|
|
.filter(({ name }) => name === 'depend')
|
|
.map(({ value }) => value),
|
|
};
|
|
}
|
|
return {
|
|
declaredArch: null,
|
|
dependencies: [],
|
|
};
|
|
}
|
|
|
|
function dependencyMatches(dependency, expected) {
|
|
const escapedExpected = expected.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
return new RegExp(`^${escapedExpected}(?:$|\\s|[<>=])`).test(
|
|
dependency.trim()
|
|
);
|
|
}
|
|
|
|
export function validateSystemPackageDependencies(format, dependencies) {
|
|
const expectedDependencies = LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format];
|
|
if (!expectedDependencies) {
|
|
return [];
|
|
}
|
|
if (!Array.isArray(dependencies)) {
|
|
return [
|
|
`Linux ${format} package dependency metadata must be an array.`,
|
|
];
|
|
}
|
|
return expectedDependencies.flatMap((expectedDependency) =>
|
|
dependencies.some((dependency) =>
|
|
dependencyMatches(String(dependency), expectedDependency)
|
|
)
|
|
? []
|
|
: [
|
|
`Linux x64 ${format} package must declare ${expectedDependency}.`,
|
|
]
|
|
);
|
|
}
|
|
|
|
function validateForeignPackageDependencies(format, dependencies) {
|
|
const forbiddenDependencies =
|
|
LINUX_SYSTEM_PACKAGE_DEPENDENCIES[format] ?? [];
|
|
return forbiddenDependencies.flatMap((forbiddenDependency) =>
|
|
dependencies.some((dependency) =>
|
|
dependencyMatches(String(dependency), forbiddenDependency)
|
|
)
|
|
? [
|
|
`Linux foreign-architecture ${format} package must not declare frame-copy dependency ${forbiddenDependency}.`,
|
|
]
|
|
: []
|
|
);
|
|
}
|
|
|
|
function dependencyFileName(dependencyName) {
|
|
return String(dependencyName).replaceAll('\\', '/').split('/').at(-1) ?? '';
|
|
}
|
|
|
|
function resolveElectronBinaryPath(resourceDir, artifactFormat) {
|
|
const layout = resolveLinuxLauncherLayout([artifactFormat]);
|
|
return path.join(path.dirname(resourceDir), layout.electronBinaryName);
|
|
}
|
|
|
|
function validateElectronIsolation(resourceDir, artifactFormat, elfInspector) {
|
|
const errors = [];
|
|
const electronPath = resolveElectronBinaryPath(resourceDir, artifactFormat);
|
|
const binaries = [
|
|
{ label: 'Electron binary', binaryPath: electronPath },
|
|
...listElectronShippedLinuxLibraries(resourceDir, {
|
|
artifactFormat,
|
|
}).map((binaryPath) => ({
|
|
label: 'Electron library',
|
|
binaryPath,
|
|
})),
|
|
];
|
|
for (const { label, binaryPath } of binaries) {
|
|
let stat;
|
|
try {
|
|
stat = fs.lstatSync(binaryPath);
|
|
} catch {
|
|
errors.push(`Missing ${label}: ${binaryPath}`);
|
|
continue;
|
|
}
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
errors.push(`${label} must be a regular file: ${binaryPath}`);
|
|
continue;
|
|
}
|
|
let dynamic;
|
|
try {
|
|
dynamic = elfInspector(binaryPath);
|
|
} catch (error) {
|
|
errors.push(
|
|
`Unable to inspect ${label} at ${binaryPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
continue;
|
|
}
|
|
if (!dynamic || !Array.isArray(dynamic.needed)) {
|
|
errors.push(
|
|
`ELF inspection for ${label} must provide a needed array: ${binaryPath}`
|
|
);
|
|
continue;
|
|
}
|
|
const libmpvDependencies = dynamic.needed.filter((dependencyName) =>
|
|
LIBMPV_DEPENDENCY_PATTERN.test(dependencyFileName(dependencyName))
|
|
);
|
|
if (libmpvDependencies.length > 0) {
|
|
errors.push(
|
|
`${label} must not link libmpv; found ${libmpvDependencies.join(
|
|
', '
|
|
)} in ${binaryPath}.`
|
|
);
|
|
}
|
|
}
|
|
return errors;
|
|
}
|
|
|
|
function defaultElfInspector(binaryPath) {
|
|
const result = assertCommandSucceeded(
|
|
'readelf',
|
|
['-d', binaryPath],
|
|
runVerifierCommand('readelf', ['-d', binaryPath])
|
|
);
|
|
return parseReadelfDynamic(result.stdout);
|
|
}
|
|
|
|
function validateProbeResult(result) {
|
|
if (result?.error) {
|
|
return [
|
|
`Unable to execute Linux frame-copy runtime probe: ${
|
|
result.error instanceof Error
|
|
? result.error.message
|
|
: String(result.error)
|
|
}`,
|
|
];
|
|
}
|
|
if (result?.signal) {
|
|
return [
|
|
`Linux frame-copy runtime probe terminated by signal ${result.signal}.`,
|
|
];
|
|
}
|
|
if (result?.status !== 0) {
|
|
const details = [result?.stdout, result?.stderr]
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
.trim();
|
|
return [
|
|
`Linux frame-copy runtime probe failed with status ${
|
|
result?.status ?? 'unknown'
|
|
}.${details ? `\n${details}` : ''}`,
|
|
];
|
|
}
|
|
const stdout = result.stdout;
|
|
if (typeof stdout !== 'string' || !/^[^\r\n]+\n$/.test(stdout)) {
|
|
return [
|
|
'Linux frame-copy runtime probe must emit exactly one newline-terminated JSON line.',
|
|
];
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(stdout.slice(0, -1));
|
|
} catch (error) {
|
|
return [
|
|
`Linux frame-copy runtime probe emitted invalid JSON: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
];
|
|
}
|
|
if (
|
|
!payload ||
|
|
typeof payload !== 'object' ||
|
|
Array.isArray(payload) ||
|
|
JSON.stringify(Object.keys(payload).sort()) !==
|
|
JSON.stringify(['libmpv', 'protocol', 'renderApi', 'usable']) ||
|
|
payload.protocol !== 1 ||
|
|
payload.usable !== true ||
|
|
typeof payload.libmpv !== 'string' ||
|
|
payload.libmpv.trim() === '' ||
|
|
payload.renderApi !== 'egl'
|
|
) {
|
|
return [
|
|
'Linux frame-copy runtime probe did not return protocol 1 usable EGL capability.',
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export function createRuntimeProbeEnvironment({
|
|
environment,
|
|
nativeDir,
|
|
runtimeMode,
|
|
}) {
|
|
const probeEnvironment = { ...(environment ?? {}) };
|
|
for (const variableName of UNSAFE_RUNTIME_PROBE_ENVIRONMENT_VARIABLES) {
|
|
delete probeEnvironment[variableName];
|
|
}
|
|
for (const variableName of Object.keys(probeEnvironment)) {
|
|
if (variableName.startsWith('BASH_FUNC_')) {
|
|
delete probeEnvironment[variableName];
|
|
}
|
|
}
|
|
if (runtimeMode === 'bundled') {
|
|
probeEnvironment.LD_LIBRARY_PATH = path.join(nativeDir, 'lib');
|
|
}
|
|
return probeEnvironment;
|
|
}
|
|
|
|
function writeVerifierWarning(message) {
|
|
process.stderr.write(message);
|
|
}
|
|
|
|
function isProbeTimeoutResult(result) {
|
|
const error = result?.error;
|
|
return Boolean(
|
|
error && typeof error === 'object' && error.code === 'ETIMEDOUT'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Executes the bounded packaged helper capability probe.
|
|
*
|
|
* A hard timeout is the only retried outcome: it proves nothing about the
|
|
* payload, and a cold sandbox can spend seconds merely loading libmpv plus the
|
|
* EGL/GL/GBM stack. Every other outcome — spawn error, signal, nonzero exit,
|
|
* malformed protocol line — is a real capability verdict and is returned
|
|
* unchanged after the first attempt. A helper that genuinely hangs still fails
|
|
* verification once the bounded attempts are exhausted.
|
|
*/
|
|
export function runBoundedRuntimeProbe({
|
|
probeRunner,
|
|
helperPath,
|
|
probeEnvironment,
|
|
warn = writeVerifierWarning,
|
|
}) {
|
|
const probeOptions = {
|
|
encoding: 'utf8',
|
|
env: probeEnvironment,
|
|
killSignal: 'SIGKILL',
|
|
maxBuffer: RUNTIME_PROBE_MAX_BUFFER_BYTES,
|
|
timeout: PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS,
|
|
windowsHide: true,
|
|
};
|
|
let result = probeRunner(helperPath, ['--runtime-probe'], probeOptions);
|
|
for (
|
|
let attempt = 2;
|
|
attempt <= PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS &&
|
|
isProbeTimeoutResult(result);
|
|
attempt += 1
|
|
) {
|
|
warn(
|
|
`Linux frame-copy runtime probe timed out after ${PACKAGE_VERIFICATION_PROBE_TIMEOUT_MS} ms; retrying (attempt ${attempt} of ${PACKAGE_VERIFICATION_PROBE_MAX_ATTEMPTS}).\n`
|
|
);
|
|
result = probeRunner(helperPath, ['--runtime-probe'], probeOptions);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function validateNoEmbeddedMpvNativeArchiveEntries(
|
|
resourceDir,
|
|
asarListPackage
|
|
) {
|
|
const asarPath = path.join(resourceDir, 'app.asar');
|
|
if (!fs.existsSync(asarPath)) {
|
|
return [];
|
|
}
|
|
|
|
let nativeEntries;
|
|
try {
|
|
nativeEntries = collectEmbeddedMpvNativeArchiveEntries(
|
|
asarListPackage(asarPath)
|
|
);
|
|
} catch (error) {
|
|
return [
|
|
`Unable to inspect embedded MPV archive ownership in ${asarPath}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
];
|
|
}
|
|
if (nativeEntries.length === 0) {
|
|
return [];
|
|
}
|
|
return [
|
|
[
|
|
`Packaged app.asar must not contain embedded MPV native payloads; afterPack exclusively owns the profile-specific unpacked directory in ${asarPath}.`,
|
|
...nativeEntries.map((entry) => `- ${entry}`),
|
|
].join('\n'),
|
|
];
|
|
}
|
|
|
|
export function verifyExtractedLinuxFrameCopyRuntime({
|
|
resourceDir,
|
|
artifactFormat,
|
|
profileName,
|
|
packageDependencies = [],
|
|
declaredArch = null,
|
|
elfInspector = defaultElfInspector,
|
|
probeRunner = runVerifierCommand,
|
|
environment = process.env,
|
|
warn = writeVerifierWarning,
|
|
asarListPackage = listAsarPackageEntries,
|
|
}) {
|
|
const errors = [];
|
|
let profile;
|
|
try {
|
|
profile = resolveLinuxFrameCopyProfile(profileName);
|
|
} catch (error) {
|
|
return [error instanceof Error ? error.message : String(error)];
|
|
}
|
|
if (!profile.targets.includes(artifactFormat)) {
|
|
return [
|
|
`Linux frame-copy profile "${profile.name}" does not include target "${artifactFormat}".`,
|
|
];
|
|
}
|
|
errors.push(
|
|
...validateNoEmbeddedMpvNativeArchiveEntries(
|
|
resourceDir,
|
|
asarListPackage
|
|
)
|
|
);
|
|
|
|
const electronPath = resolveElectronBinaryPath(resourceDir, artifactFormat);
|
|
let packageArch;
|
|
try {
|
|
packageArch = readElfArchitecture(electronPath);
|
|
} catch (error) {
|
|
return [
|
|
...errors,
|
|
error instanceof Error ? error.message : String(error),
|
|
];
|
|
}
|
|
const foreignArch = packageArch !== 'x64';
|
|
if (declaredArch && declaredArch !== packageArch) {
|
|
errors.push(
|
|
`Package metadata architecture ${declaredArch} does not match Electron ELF architecture ${packageArch}.`
|
|
);
|
|
}
|
|
if (profile.runtimeMode === 'system') {
|
|
errors.push(
|
|
...(foreignArch
|
|
? validateForeignPackageDependencies(
|
|
artifactFormat,
|
|
packageDependencies
|
|
)
|
|
: validateSystemPackageDependencies(
|
|
artifactFormat,
|
|
packageDependencies
|
|
))
|
|
);
|
|
}
|
|
|
|
errors.push(
|
|
...validatePackagedEmbeddedMpv(resourceDir, {
|
|
platform: 'linux',
|
|
required: true,
|
|
foreignArch,
|
|
targetArch: packageArch,
|
|
profile: profile.name,
|
|
targetNames: profile.targets,
|
|
artifactFormat,
|
|
hostPlatform: 'linux',
|
|
executableName: 'iptvnator',
|
|
elfInspector,
|
|
})
|
|
);
|
|
errors.push(
|
|
...validateElectronIsolation(resourceDir, artifactFormat, elfInspector)
|
|
);
|
|
if (foreignArch || errors.length > 0) {
|
|
return errors;
|
|
}
|
|
|
|
const nativeDir = path.join(
|
|
resourceDir,
|
|
'app.asar.unpacked',
|
|
'electron-backend',
|
|
'native'
|
|
);
|
|
const probeEnvironment = createRuntimeProbeEnvironment({
|
|
environment,
|
|
nativeDir,
|
|
runtimeMode: profile.runtimeMode,
|
|
});
|
|
const probeResult = runBoundedRuntimeProbe({
|
|
probeRunner,
|
|
helperPath: path.join(nativeDir, 'iptvnator_mpv_helper'),
|
|
probeEnvironment,
|
|
warn,
|
|
});
|
|
errors.push(...validateProbeResult(probeResult));
|
|
return errors;
|
|
}
|
|
|
|
export function verifyLinuxFrameCopyArtifact({
|
|
artifactPath,
|
|
profileName,
|
|
runCommand = runVerifierCommand,
|
|
extractArtifact = extractLinuxArtifact,
|
|
metadataReader = readLinuxArtifactMetadata,
|
|
payloadVerifier = verifyExtractedLinuxFrameCopyRuntime,
|
|
elfInspector = defaultElfInspector,
|
|
probeRunner = runVerifierCommand,
|
|
environment = process.env,
|
|
}) {
|
|
const resolvedArtifactPath = path.resolve(artifactPath);
|
|
const artifactStat = fs.lstatSync(resolvedArtifactPath);
|
|
if (!artifactStat.isFile() || artifactStat.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Linux package artifact must be a regular file: ${resolvedArtifactPath}`
|
|
);
|
|
}
|
|
const profile = resolveLinuxFrameCopyProfile(profileName);
|
|
const format = detectLinuxArtifactFormat(resolvedArtifactPath);
|
|
if (!profile.targets.includes(format)) {
|
|
throw new Error(
|
|
`Linux frame-copy profile "${profile.name}" does not include target "${format}".`
|
|
);
|
|
}
|
|
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-linux-package-verifier-')
|
|
);
|
|
try {
|
|
const extractionDestination = path.join(temporaryRoot, 'payload');
|
|
const extractionRoot = extractArtifact({
|
|
artifactPath: resolvedArtifactPath,
|
|
format,
|
|
destination: extractionDestination,
|
|
runCommand,
|
|
});
|
|
if (format === 'snap') {
|
|
const snapMetadataErrors =
|
|
validateExtractedSnapMetadata(extractionRoot);
|
|
if (snapMetadataErrors.length > 0) {
|
|
throw new Error(
|
|
[
|
|
`Linux frame-copy package verification failed for ${resolvedArtifactPath}:`,
|
|
...snapMetadataErrors.map((error) => `- ${error}`),
|
|
].join('\n')
|
|
);
|
|
}
|
|
}
|
|
const resourceDir = findExtractedResourceDir(extractionRoot);
|
|
const metadata = metadataReader({
|
|
artifactPath: resolvedArtifactPath,
|
|
format,
|
|
extractionRoot,
|
|
runCommand,
|
|
});
|
|
const errors = payloadVerifier({
|
|
resourceDir,
|
|
artifactFormat: format,
|
|
profileName: profile.name,
|
|
packageDependencies: metadata.dependencies,
|
|
declaredArch: metadata.declaredArch,
|
|
elfInspector,
|
|
probeRunner,
|
|
environment,
|
|
});
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
[
|
|
`Linux frame-copy package verification failed for ${resolvedArtifactPath}:`,
|
|
...errors.map((error) => `- ${error}`),
|
|
].join('\n')
|
|
);
|
|
}
|
|
const electronPath = resolveElectronBinaryPath(resourceDir, format);
|
|
return {
|
|
artifactPath: resolvedArtifactPath,
|
|
format,
|
|
profileName: profile.name,
|
|
architecture: readElfArchitecture(electronPath),
|
|
};
|
|
} finally {
|
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function main() {
|
|
const options = parseVerifierArguments(process.argv.slice(2));
|
|
const result = verifyLinuxFrameCopyArtifact(options);
|
|
process.stdout.write(
|
|
`Verified ${result.format} ${result.architecture} Linux frame-copy package (${result.profileName}): ${result.artifactPath}\n`
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${error instanceof Error ? error.message : String(error)}\n`
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|