mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
* docs: design Linux frame-copy packaging * docs: plan Linux frame-copy packaging * feat(packaging): define Linux frame-copy profiles * fix(packaging): reject inherited profile names * feat(embedded-mpv): validate staged Linux runtime * fix(embedded-mpv): require Linux source packages * fix(embedded-mpv): harden Linux runtime staging * feat(embedded-mpv): build LGPL Linux runtime * fix(embedded-mpv): pin Linux runtime inputs * feat(embedded-mpv): build relocatable Linux helper * fix(embedded-mpv): require bundled Linux runtime * fix(embedded-mpv): make Linux runtime portable * feat(packaging): ship Linux frame-copy artifacts * fix(embedded-mpv): verify Linux helper linkage * fix(packaging): enforce Linux frame-copy isolation * fix(embedded-mpv): pin Linux display data * docs(embedded-mpv): document Linux frame-copy packaging * feat(embedded-mpv): probe Linux frame-copy runtime * test(embedded-mpv): smoke packaged Linux frame-copy * docs(embedded-mpv): clarify Linux system runtime baseline * fix(embedded-mpv): harden Linux runtime capability gate * ci: verify Linux frame-copy packages * test(embedded-mpv): harden packaged Linux smoke * test(embedded-mpv): preserve packaged GL mode * test(packaging): harden Linux package probes * fix(embedded-mpv): enable private Snap shared memory * fix(embedded-mpv): sanitize Linux helper environment * fix(packaging): enforce private Snap memory semantics * fix(packaging): reject ambiguous Snap memory metadata * fix(embedded-mpv): prioritize trusted Snap GL * fix(packaging): reject advanced Snap YAML semantics * fix(packaging): reject arbitrary Snap YAML aliases * feat(packaging): ship Linux runtime license notices * docs(embedded-mpv): document Linux runtime distribution * fix(packaging): parse Snap trailing comments safely * fix(release): gate Snap publish on public source release * fix(packaging): strip VCS metadata from source bundle * docs(packaging): clarify Linux source release gate * test(embedded-mpv): smoke missing bundled libmpv * style(embedded-mpv): format final validation inputs * fix(e2e): satisfy fixture index signature typing * fix(ci): declare fontconfig gperf generator * fix(embedded-mpv): hash runtime cache identities * fix(packaging): harden Linux frame-copy delivery * fix(packaging): tighten runtime delivery gates * fix(ci): decouple Linux runtime matrix * fix(packaging): harden Linux frame-copy delivery * fix(packaging): validate Linux frame-copy runtimes * fix(packaging): scope Snap Electron library checks * feat(packaging): ship Linux frame-copy runtimes * fix(packaging): improve Linux runtime smoke diagnostics * fix(packaging): expose bounded helper probe details * test(packaging): trace Snap EGL probe failures * fix(packaging): prefer core22 ABI in Snap helper * fix(packaging): bound helper probe capture * fix(packaging): harden Linux frame-copy releases * fix(packaging): canonicalize libplacebo submodule identity * fix(packaging): make source archive inspection portable * fix(packaging): harden Snap release verification
446 lines
15 KiB
JavaScript
446 lines
15 KiB
JavaScript
/**
|
|
* Verifies that a packaged Electron `app.asar` ships a self-consistent set of
|
|
* runtime node_modules: every package inside the archive has its own
|
|
* non-optional dependencies shipped alongside it.
|
|
*
|
|
* electron-builder's pnpm dependency collector reads `pnpm list --json`, which
|
|
* deduplicates repeated packages and reports empty `dependencies` for all but
|
|
* one occurrence. That makes it silently drop deeply-nested transitive
|
|
* dependencies (for example `ms` under `debug`) from the packaged archive, which
|
|
* crashes the app on launch ("Cannot find module 'ms'", issue #1103).
|
|
*
|
|
* These helpers audit every package that is actually shipped inside the asar and
|
|
* report any non-optional dependency of a shipped package that is missing,
|
|
* resolving each one the way Node would inside the packaged tree. IO is injected
|
|
* so the core logic stays pure and unit-testable without a real archive.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { TextDecoder } from 'node:util';
|
|
|
|
const PACKAGE_MANIFEST = 'package.json';
|
|
const NODE_MODULES_SEGMENT = '/node_modules/';
|
|
const EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT = '/electron-backend/native';
|
|
const ASAR_SIZE_PREFIX_BYTES = 8;
|
|
const ASAR_HEADER_MAX_BYTES = 32 * 1024 * 1024;
|
|
const ASAR_ENTRY_LIMIT = 250_000;
|
|
const ASAR_PATH_MAX_BYTES = 32 * 1024;
|
|
const ASAR_LISTED_PATH_MAX_BYTES = 32 * 1024 * 1024;
|
|
|
|
function isRecord(value) {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function* ownRecordEntries(record) {
|
|
for (const name in record) {
|
|
if (Object.hasOwn(record, name)) {
|
|
yield [name, record[name]];
|
|
}
|
|
}
|
|
}
|
|
|
|
function readExactly(descriptor, buffer, position) {
|
|
let offset = 0;
|
|
while (offset < buffer.length) {
|
|
const bytesRead = fs.readSync(
|
|
descriptor,
|
|
buffer,
|
|
offset,
|
|
buffer.length - offset,
|
|
position + offset
|
|
);
|
|
if (bytesRead === 0) {
|
|
throw new Error('ASAR archive ended before its header was read.');
|
|
}
|
|
offset += bytesRead;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Lists an ASAR from its bounded Chromium-Pickle JSON header using only Node
|
|
* built-ins. Public-release verification runs from a clean tag checkout, so it
|
|
* cannot rely on the workspace-only `@electron/asar` development dependency.
|
|
*/
|
|
export function listAsarPackageEntries(
|
|
archivePath,
|
|
{ maxListedPathBytes = ASAR_LISTED_PATH_MAX_BYTES } = {}
|
|
) {
|
|
if (
|
|
!Number.isSafeInteger(maxListedPathBytes) ||
|
|
maxListedPathBytes <= 0 ||
|
|
maxListedPathBytes > ASAR_LISTED_PATH_MAX_BYTES
|
|
) {
|
|
throw new Error('ASAR listed-path byte limit is invalid.');
|
|
}
|
|
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
const descriptor = fs.openSync(
|
|
archivePath,
|
|
fs.constants.O_RDONLY | noFollow
|
|
);
|
|
let header;
|
|
try {
|
|
const archiveStat = fs.fstatSync(descriptor);
|
|
if (!archiveStat.isFile()) {
|
|
throw new Error('ASAR archive must be a regular file.');
|
|
}
|
|
const sizePrefix = Buffer.alloc(ASAR_SIZE_PREFIX_BYTES);
|
|
readExactly(descriptor, sizePrefix, 0);
|
|
if (sizePrefix.readUInt32LE(0) !== 4) {
|
|
throw new Error('ASAR size pickle is malformed.');
|
|
}
|
|
const headerSize = sizePrefix.readUInt32LE(4);
|
|
if (headerSize > ASAR_HEADER_MAX_BYTES) {
|
|
throw new Error(
|
|
`ASAR header exceeds the ${String(
|
|
ASAR_HEADER_MAX_BYTES
|
|
)}-byte limit.`
|
|
);
|
|
}
|
|
if (
|
|
headerSize < 8 ||
|
|
headerSize + ASAR_SIZE_PREFIX_BYTES > archiveStat.size
|
|
) {
|
|
throw new Error('ASAR header size is invalid.');
|
|
}
|
|
header = Buffer.alloc(headerSize);
|
|
readExactly(descriptor, header, ASAR_SIZE_PREFIX_BYTES);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
|
|
const payloadSize = header.readUInt32LE(0);
|
|
if (payloadSize + 4 !== header.length || payloadSize < 4) {
|
|
throw new Error('ASAR header pickle is malformed.');
|
|
}
|
|
const jsonLength = header.readInt32LE(4);
|
|
const alignedJsonLength =
|
|
jsonLength >= 0 ? Math.ceil(jsonLength / 4) * 4 : -1;
|
|
if (
|
|
jsonLength <= 0 ||
|
|
alignedJsonLength + 4 !== payloadSize ||
|
|
header
|
|
.subarray(8 + jsonLength, 8 + alignedJsonLength)
|
|
.some((byte) => byte !== 0)
|
|
) {
|
|
throw new Error('ASAR header JSON framing is malformed.');
|
|
}
|
|
|
|
let parsedHeader;
|
|
try {
|
|
const headerJson = new TextDecoder('utf-8', { fatal: true }).decode(
|
|
header.subarray(8, 8 + jsonLength)
|
|
);
|
|
parsedHeader = JSON.parse(headerJson);
|
|
} catch (error) {
|
|
throw new Error(
|
|
`ASAR header JSON is invalid: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`
|
|
);
|
|
}
|
|
if (!isRecord(parsedHeader) || !isRecord(parsedHeader.files)) {
|
|
throw new Error('ASAR header must contain a files mapping.');
|
|
}
|
|
|
|
const entries = [];
|
|
let listedPathBytes = 0;
|
|
const pendingDirectories = [
|
|
{
|
|
entries: ownRecordEntries(parsedHeader.files),
|
|
parentPath: '',
|
|
},
|
|
];
|
|
while (pendingDirectories.length > 0) {
|
|
const directory = pendingDirectories.at(-1);
|
|
const nextEntry = directory.entries.next();
|
|
if (nextEntry.done) {
|
|
pendingDirectories.pop();
|
|
continue;
|
|
}
|
|
const [name, node] = nextEntry.value;
|
|
const { parentPath } = directory;
|
|
if (
|
|
!name ||
|
|
name === '.' ||
|
|
name === '..' ||
|
|
name.includes('/') ||
|
|
name.includes('\0')
|
|
) {
|
|
throw new Error('ASAR header contains an invalid path segment.');
|
|
}
|
|
if (!isRecord(node)) {
|
|
throw new Error('ASAR header contains an invalid filesystem node.');
|
|
}
|
|
const entryPath = `${parentPath}/${name}`;
|
|
const entryPathBytes = Buffer.byteLength(entryPath, 'utf8');
|
|
if (entryPathBytes > ASAR_PATH_MAX_BYTES) {
|
|
throw new Error('ASAR header contains an overlong entry path.');
|
|
}
|
|
if (entryPathBytes > maxListedPathBytes - listedPathBytes) {
|
|
throw new Error(
|
|
`Cumulative ASAR entry paths exceed the ${String(
|
|
maxListedPathBytes
|
|
)}-byte limit.`
|
|
);
|
|
}
|
|
listedPathBytes += entryPathBytes;
|
|
entries.push(entryPath);
|
|
if (entries.length > ASAR_ENTRY_LIMIT) {
|
|
throw new Error(
|
|
`ASAR header exceeds the ${String(ASAR_ENTRY_LIMIT)}-entry limit.`
|
|
);
|
|
}
|
|
|
|
if (Object.hasOwn(node, 'files')) {
|
|
if (!isRecord(node.files)) {
|
|
throw new Error(
|
|
'ASAR header contains an invalid directory mapping.'
|
|
);
|
|
}
|
|
pendingDirectories.push({
|
|
entries: ownRecordEntries(node.files),
|
|
parentPath: entryPath,
|
|
});
|
|
} else if (
|
|
!(
|
|
(Number.isSafeInteger(node.size) && node.size >= 0) ||
|
|
(typeof node.link === 'string' && node.link.length > 0)
|
|
)
|
|
) {
|
|
throw new Error('ASAR header contains an invalid file node.');
|
|
}
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
/**
|
|
* Embedded MPV's native payload is profile-specific and is written only by
|
|
* afterPack. Any copy retained in app.asar predates that mutation and can leak
|
|
* x64 helpers, runtimes, manifests, or notices into marker-only/system builds.
|
|
*/
|
|
export function collectEmbeddedMpvNativeArchiveEntries(
|
|
asarEntries,
|
|
pathSep = path.sep
|
|
) {
|
|
const toPosix = (value) =>
|
|
pathSep === '\\' ? value.replaceAll('\\', '/') : value;
|
|
|
|
return asarEntries
|
|
.map(toPosix)
|
|
.map((entry) => (entry.startsWith('/') ? entry : `/${entry}`))
|
|
.filter(
|
|
(entry) =>
|
|
entry === EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT ||
|
|
entry.startsWith(`${EMBEDDED_MPV_NATIVE_ARCHIVE_ROOT}/`)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A genuine installed package lives directly under a node_modules directory as
|
|
* `<name>` or `@scope/<name>`. Nested folders that merely carry their own
|
|
* package.json (for example `fast-uri/benchmark` or `<pkg>/test`) are not
|
|
* packages and must not be treated as dependency sources.
|
|
*/
|
|
function isPackageRootDir(dir) {
|
|
const boundary = dir.lastIndexOf(NODE_MODULES_SEGMENT);
|
|
if (boundary === -1) {
|
|
return false;
|
|
}
|
|
|
|
const relative = dir.slice(boundary + NODE_MODULES_SEGMENT.length);
|
|
const segments = relative.split('/');
|
|
|
|
return segments[0].startsWith('@')
|
|
? segments.length === 2
|
|
: segments.length === 1;
|
|
}
|
|
|
|
/**
|
|
* Collects every genuine installed package directory in the archive (those that
|
|
* sit directly under a node_modules as `<name>` or `@scope/<name>`). Entry paths
|
|
* are leading-slash posix.
|
|
*/
|
|
export function collectAsarPackageDirs(asarEntries) {
|
|
const packageDirs = new Set();
|
|
|
|
for (const entry of asarEntries) {
|
|
if (!entry.endsWith(`/${PACKAGE_MANIFEST}`)) {
|
|
continue;
|
|
}
|
|
|
|
const dir = entry.slice(0, -(PACKAGE_MANIFEST.length + 1));
|
|
if (isPackageRootDir(dir)) {
|
|
packageDirs.add(dir);
|
|
}
|
|
}
|
|
|
|
return packageDirs;
|
|
}
|
|
|
|
/**
|
|
* Mirrors Node's upward node_modules resolution across the packaged tree, which
|
|
* is what electron-builder's hoisting produces. Walks every ancestor directory
|
|
* (not just node_modules boundaries) so a dependency hoisted to the archive
|
|
* root still resolves for a package that lives under an app subdirectory such
|
|
* as `/electron-backend/node_modules/foo`. Returns the resolved package
|
|
* directory or null when the dependency is absent.
|
|
*/
|
|
export function resolvePackagedDependency(
|
|
fromDir,
|
|
dependencyName,
|
|
packageDirs
|
|
) {
|
|
let current = fromDir;
|
|
|
|
while (true) {
|
|
// Node never looks inside `.../node_modules/node_modules`.
|
|
if (!current.endsWith(NODE_MODULES_SEGMENT.slice(0, -1))) {
|
|
const candidate = `${current}/node_modules/${dependencyName}`;
|
|
if (packageDirs.has(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (current === '') {
|
|
return null;
|
|
}
|
|
|
|
current = current.slice(0, current.lastIndexOf('/'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks that every package physically shipped inside the asar has its own
|
|
* non-optional dependencies shipped too (node-resolvable from its location).
|
|
* Returns the list of missing dependencies.
|
|
*
|
|
* The app root manifest ('') is intentionally skipped: it declares frontend-only
|
|
* packages (Angular, zone.js, ...) that are compiled into the web bundle rather
|
|
* than shipped as runtime node_modules, so requiring them here would be a false
|
|
* positive. Auditing what is actually bundled still catches the real regression
|
|
* (a shipped `debug` whose transitive `ms` was dropped — issue #1103).
|
|
*
|
|
* @param {Set<string>} packageDirs directories that contain a package.json
|
|
* @param {(packageDir: string) => object | null} readManifest manifest reader
|
|
* @returns {{ dependency: string, requiredBy: string }[]}
|
|
*/
|
|
export function findMissingPackagedDependencies(packageDirs, readManifest) {
|
|
const missing = [];
|
|
|
|
for (const packageDir of packageDirs) {
|
|
if (packageDir === '') {
|
|
continue;
|
|
}
|
|
|
|
const manifest = readManifest(packageDir);
|
|
if (!manifest) {
|
|
continue;
|
|
}
|
|
|
|
const optionalDependencies = new Set(
|
|
Object.keys(manifest.optionalDependencies ?? {})
|
|
);
|
|
// Packages sometimes list a host-provided peer (e.g. `electron`) in
|
|
// `dependencies` as well so package managers install it during
|
|
// development; at runtime the host supplies it, so its absence from
|
|
// the archive is not a packaging defect.
|
|
const peerDependencies = new Set(
|
|
Object.keys(manifest.peerDependencies ?? {})
|
|
);
|
|
|
|
for (const dependencyName of Object.keys(manifest.dependencies ?? {})) {
|
|
if (
|
|
optionalDependencies.has(dependencyName) ||
|
|
peerDependencies.has(dependencyName)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!resolvePackagedDependency(
|
|
packageDir,
|
|
dependencyName,
|
|
packageDirs
|
|
)
|
|
) {
|
|
missing.push({
|
|
dependency: dependencyName,
|
|
requiredBy: packageDir,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return missing;
|
|
}
|
|
|
|
/**
|
|
* Inspects a real `app.asar` for missing runtime dependencies. `listPackage` and
|
|
* `extractFile` are injected (normally from `@electron/asar`).
|
|
*
|
|
* `@electron/asar` builds listing entries and resolves lookup paths with the
|
|
* HOST separator: on Windows `listPackage` returns entries like
|
|
* `\node_modules\debug\package.json` and `extractFile` splits its path on
|
|
* `path.sep`. The pure helpers above are posix-only, so listings are normalized
|
|
* to posix and lookup paths converted back to the host separator (`pathSep` is
|
|
* injectable for tests). Without this the guard silently audited nothing on
|
|
* Windows.
|
|
*
|
|
* Returns `{ missing, packageCount, manifestReadFailures }` so callers can
|
|
* reject a vacuous pass: a packaged app always ships node_modules, so
|
|
* `packageCount === 0` or read failures indicate the guard itself is broken,
|
|
* not a healthy archive.
|
|
*/
|
|
export function inspectPackagedDependencyClosure(
|
|
asarPath,
|
|
{ listPackage, extractFile, pathSep = path.sep }
|
|
) {
|
|
const toPosix = (value) =>
|
|
pathSep === '\\' ? value.replaceAll('\\', '/') : value;
|
|
const toHostPath = (value) =>
|
|
pathSep === '\\' ? value.replaceAll('/', '\\') : value;
|
|
|
|
const packageDirs = collectAsarPackageDirs(
|
|
listPackage(asarPath).map(toPosix)
|
|
);
|
|
const manifestCache = new Map();
|
|
const manifestReadFailures = [];
|
|
|
|
const readManifest = (packageDir) => {
|
|
if (manifestCache.has(packageDir)) {
|
|
return manifestCache.get(packageDir);
|
|
}
|
|
|
|
const relativePath = toHostPath(
|
|
(packageDir === '' ? '' : `${packageDir.slice(1)}/`) +
|
|
PACKAGE_MANIFEST
|
|
);
|
|
|
|
let manifest = null;
|
|
|
|
try {
|
|
manifest = JSON.parse(
|
|
extractFile(asarPath, relativePath).toString('utf8')
|
|
);
|
|
} catch (error) {
|
|
manifestReadFailures.push({
|
|
packageDir,
|
|
message: error.message,
|
|
});
|
|
manifest = null;
|
|
}
|
|
|
|
manifestCache.set(packageDir, manifest);
|
|
return manifest;
|
|
};
|
|
|
|
const missing = findMissingPackagedDependencies(packageDirs, readManifest);
|
|
|
|
return {
|
|
missing,
|
|
packageCount: packageDirs.size,
|
|
manifestReadFailures,
|
|
};
|
|
}
|