mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
* docs: design Linux frame-copy packaging * docs: plan Linux frame-copy packaging * feat(packaging): define Linux frame-copy profiles * fix(packaging): reject inherited profile names * feat(embedded-mpv): validate staged Linux runtime * fix(embedded-mpv): require Linux source packages * fix(embedded-mpv): harden Linux runtime staging * feat(embedded-mpv): build LGPL Linux runtime * fix(embedded-mpv): pin Linux runtime inputs * feat(embedded-mpv): build relocatable Linux helper * fix(embedded-mpv): require bundled Linux runtime * fix(embedded-mpv): make Linux runtime portable * feat(packaging): ship Linux frame-copy artifacts * fix(embedded-mpv): verify Linux helper linkage * fix(packaging): enforce Linux frame-copy isolation * fix(embedded-mpv): pin Linux display data * docs(embedded-mpv): document Linux frame-copy packaging * feat(embedded-mpv): probe Linux frame-copy runtime * test(embedded-mpv): smoke packaged Linux frame-copy * docs(embedded-mpv): clarify Linux system runtime baseline * fix(embedded-mpv): harden Linux runtime capability gate * ci: verify Linux frame-copy packages * test(embedded-mpv): harden packaged Linux smoke * test(embedded-mpv): preserve packaged GL mode * test(packaging): harden Linux package probes * fix(embedded-mpv): enable private Snap shared memory * fix(embedded-mpv): sanitize Linux helper environment * fix(packaging): enforce private Snap memory semantics * fix(packaging): reject ambiguous Snap memory metadata * fix(embedded-mpv): prioritize trusted Snap GL * fix(packaging): reject advanced Snap YAML semantics * fix(packaging): reject arbitrary Snap YAML aliases * feat(packaging): ship Linux runtime license notices * docs(embedded-mpv): document Linux runtime distribution * fix(packaging): parse Snap trailing comments safely * fix(release): gate Snap publish on public source release * fix(packaging): strip VCS metadata from source bundle * docs(packaging): clarify Linux source release gate * test(embedded-mpv): smoke missing bundled libmpv * style(embedded-mpv): format final validation inputs * fix(e2e): satisfy fixture index signature typing * fix(ci): declare fontconfig gperf generator * fix(embedded-mpv): hash runtime cache identities * fix(packaging): harden Linux frame-copy delivery * fix(packaging): tighten runtime delivery gates * fix(ci): decouple Linux runtime matrix * fix(packaging): harden Linux frame-copy delivery * fix(packaging): validate Linux frame-copy runtimes * fix(packaging): scope Snap Electron library checks * feat(packaging): ship Linux frame-copy runtimes * fix(packaging): improve Linux runtime smoke diagnostics * fix(packaging): expose bounded helper probe details * test(packaging): trace Snap EGL probe failures * fix(packaging): prefer core22 ABI in Snap helper * fix(packaging): bound helper probe capture * fix(packaging): harden Linux frame-copy releases * fix(packaging): canonicalize libplacebo submodule identity * fix(packaging): make source archive inspection portable * fix(packaging): harden Snap release verification
510 lines
17 KiB
JavaScript
510 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
collectEmbeddedMpvNativeArchiveEntries,
|
|
collectAsarPackageDirs,
|
|
findMissingPackagedDependencies,
|
|
inspectPackagedDependencyClosure,
|
|
listAsarPackageEntries,
|
|
resolvePackagedDependency,
|
|
} from './asar-dependency-closure.mjs';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const {
|
|
createPackage: createAsarPackage,
|
|
listPackage: listAsarPackageWithDependency,
|
|
} = require('@electron/asar');
|
|
|
|
function writeSyntheticAsarHeader(archivePath, files) {
|
|
const json = Buffer.from(JSON.stringify({ files }), 'utf8');
|
|
const alignedJsonLength = Math.ceil(json.length / 4) * 4;
|
|
const headerPayloadSize = 4 + alignedJsonLength;
|
|
const headerSize = 4 + headerPayloadSize;
|
|
const sizePrefix = Buffer.alloc(8);
|
|
sizePrefix.writeUInt32LE(4, 0);
|
|
sizePrefix.writeUInt32LE(headerSize, 4);
|
|
const header = Buffer.alloc(headerSize);
|
|
header.writeUInt32LE(headerPayloadSize, 0);
|
|
header.writeInt32LE(json.length, 4);
|
|
json.copy(header, 8);
|
|
fs.writeFileSync(archivePath, Buffer.concat([sizePrefix, header]));
|
|
}
|
|
|
|
/**
|
|
* Builds a `readManifest(dir)` backed by an in-memory map of
|
|
* `{ packageDir: manifest }`, mirroring how manifests are read from an asar.
|
|
*/
|
|
function manifestReader(manifests) {
|
|
return (packageDir) => manifests[packageDir] ?? null;
|
|
}
|
|
|
|
test('collectEmbeddedMpvNativeArchiveEntries finds stale native payloads on every host separator', () => {
|
|
assert.deepEqual(
|
|
collectEmbeddedMpvNativeArchiveEntries(
|
|
[
|
|
'/electron-backend/main.js',
|
|
'/electron-backend/native/iptvnator_mpv_helper',
|
|
'/electron-backend/native/lib/libmpv.so.2',
|
|
'/web/index.html',
|
|
],
|
|
'/'
|
|
),
|
|
[
|
|
'/electron-backend/native/iptvnator_mpv_helper',
|
|
'/electron-backend/native/lib/libmpv.so.2',
|
|
]
|
|
);
|
|
assert.deepEqual(
|
|
collectEmbeddedMpvNativeArchiveEntries(
|
|
[
|
|
'\\electron-backend\\native\\embedded-mpv-unavailable.txt',
|
|
'\\electron-backend\\node_modules\\package.json',
|
|
],
|
|
'\\'
|
|
),
|
|
['/electron-backend/native/embedded-mpv-unavailable.txt']
|
|
);
|
|
});
|
|
|
|
test('listAsarPackageEntries matches Electron ASAR listings from a bounded header', async (t) => {
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-asar-header-')
|
|
);
|
|
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
|
const sourceRoot = path.join(temporaryRoot, 'source');
|
|
const archivePath = path.join(temporaryRoot, 'app.asar');
|
|
fs.mkdirSync(path.join(sourceRoot, 'electron-backend', 'native'), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(path.join(sourceRoot, 'main.js'), 'main');
|
|
fs.writeFileSync(
|
|
path.join(
|
|
sourceRoot,
|
|
'electron-backend',
|
|
'native',
|
|
'embedded-mpv-runtime.json'
|
|
),
|
|
'{}\n'
|
|
);
|
|
await createAsarPackage(sourceRoot, archivePath);
|
|
|
|
assert.deepEqual(
|
|
listAsarPackageEntries(archivePath),
|
|
listAsarPackageWithDependency(archivePath)
|
|
);
|
|
});
|
|
|
|
test('listAsarPackageEntries rejects an unbounded declared header before allocation', (t) => {
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-asar-header-')
|
|
);
|
|
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
|
const archivePath = path.join(temporaryRoot, 'oversized.asar');
|
|
const prefix = Buffer.alloc(8);
|
|
prefix.writeUInt32LE(4, 0);
|
|
prefix.writeUInt32LE(0xffffffff, 4);
|
|
fs.writeFileSync(archivePath, prefix);
|
|
|
|
assert.throws(
|
|
() => listAsarPackageEntries(archivePath),
|
|
/ASAR header exceeds.*limit/i
|
|
);
|
|
});
|
|
|
|
test('listAsarPackageEntries traverses untrusted directory mappings without bulk Object.entries allocation', (t) => {
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-asar-header-')
|
|
);
|
|
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
|
const archivePath = path.join(temporaryRoot, 'wide.asar');
|
|
const files = Object.fromEntries(
|
|
Array.from({ length: 4096 }, (_, index) => [
|
|
`entry-${String(index).padStart(4, '0')}`,
|
|
{ size: 0, offset: '0' },
|
|
])
|
|
);
|
|
writeSyntheticAsarHeader(archivePath, files);
|
|
|
|
const originalObjectEntries = Object.entries;
|
|
Object.entries = () => {
|
|
throw new Error(
|
|
'untrusted ASAR mappings must not be materialized in bulk'
|
|
);
|
|
};
|
|
try {
|
|
assert.equal(listAsarPackageEntries(archivePath).length, 4096);
|
|
} finally {
|
|
Object.entries = originalObjectEntries;
|
|
}
|
|
});
|
|
|
|
test('listAsarPackageEntries bounds cumulative paths under a wide long prefix', (t) => {
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-asar-header-')
|
|
);
|
|
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
|
const archivePath = path.join(temporaryRoot, 'wide-prefix.asar');
|
|
const children = Object.fromEntries(
|
|
Array.from({ length: 64 }, (_, index) => [
|
|
`leaf-${String(index).padStart(2, '0')}`,
|
|
{ size: 0, offset: '0' },
|
|
])
|
|
);
|
|
writeSyntheticAsarHeader(archivePath, {
|
|
['prefix-'.repeat(32)]: { files: children },
|
|
});
|
|
|
|
assert.throws(
|
|
() =>
|
|
listAsarPackageEntries(archivePath, {
|
|
maxListedPathBytes: 1024,
|
|
}),
|
|
/cumulative ASAR entry paths exceed.*limit/i
|
|
);
|
|
});
|
|
|
|
test('listAsarPackageEntries fails closed on malformed pickle, padding, and UTF-8', (t) => {
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-asar-header-')
|
|
);
|
|
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
|
const validArchivePath = path.join(temporaryRoot, 'valid.asar');
|
|
writeSyntheticAsarHeader(validArchivePath, {
|
|
a: { size: 0, offset: '0' },
|
|
});
|
|
const validArchive = fs.readFileSync(validArchivePath);
|
|
|
|
const malformedPickle = Buffer.from(validArchive);
|
|
malformedPickle.writeUInt32LE(malformedPickle.readUInt32LE(8) - 4, 8);
|
|
const malformedPicklePath = path.join(
|
|
temporaryRoot,
|
|
'malformed-pickle.asar'
|
|
);
|
|
fs.writeFileSync(malformedPicklePath, malformedPickle);
|
|
assert.throws(
|
|
() => listAsarPackageEntries(malformedPicklePath),
|
|
/header pickle is malformed/i
|
|
);
|
|
|
|
const malformedPadding = Buffer.from(validArchive);
|
|
malformedPadding[malformedPadding.length - 1] = 0xff;
|
|
const malformedPaddingPath = path.join(
|
|
temporaryRoot,
|
|
'malformed-padding.asar'
|
|
);
|
|
fs.writeFileSync(malformedPaddingPath, malformedPadding);
|
|
assert.throws(
|
|
() => listAsarPackageEntries(malformedPaddingPath),
|
|
/JSON framing is malformed/i
|
|
);
|
|
|
|
const malformedUtf8 = Buffer.from(validArchive);
|
|
malformedUtf8[16] = 0xff;
|
|
const malformedUtf8Path = path.join(temporaryRoot, 'malformed-utf8.asar');
|
|
fs.writeFileSync(malformedUtf8Path, malformedUtf8);
|
|
assert.throws(
|
|
() => listAsarPackageEntries(malformedUtf8Path),
|
|
/header JSON is invalid/i
|
|
);
|
|
});
|
|
|
|
test('listAsarPackageEntries rejects unsafe archive path segments', (t) => {
|
|
const temporaryRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'iptvnator-asar-header-')
|
|
);
|
|
t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true }));
|
|
|
|
for (const [index, unsafeSegment] of [
|
|
'.',
|
|
'..',
|
|
'nested/name',
|
|
'nul\0name',
|
|
].entries()) {
|
|
const archivePath = path.join(
|
|
temporaryRoot,
|
|
`unsafe-${String(index)}.asar`
|
|
);
|
|
writeSyntheticAsarHeader(archivePath, {
|
|
[unsafeSegment]: { size: 0, offset: '0' },
|
|
});
|
|
assert.throws(
|
|
() => listAsarPackageEntries(archivePath),
|
|
/invalid path segment/i
|
|
);
|
|
}
|
|
});
|
|
|
|
test('collectAsarPackageDirs keeps only genuine package roots', () => {
|
|
const dirs = collectAsarPackageDirs([
|
|
'/package.json',
|
|
'/node_modules/debug/package.json',
|
|
'/node_modules/debug/src/index.js',
|
|
'/node_modules/@angular/core/package.json',
|
|
'/node_modules/fast-uri/node_modules/uri-js/package.json',
|
|
// Nested manifests that are NOT packages must be ignored.
|
|
'/node_modules/fast-uri/benchmark/package.json',
|
|
'/node_modules/@angular/core/schematics/package.json',
|
|
]);
|
|
|
|
assert.ok(dirs.has('/node_modules/debug'));
|
|
assert.ok(dirs.has('/node_modules/@angular/core'));
|
|
assert.ok(dirs.has('/node_modules/fast-uri/node_modules/uri-js'));
|
|
assert.equal(dirs.has(''), false);
|
|
assert.equal(dirs.has('/node_modules/fast-uri/benchmark'), false);
|
|
assert.equal(dirs.has('/node_modules/@angular/core/schematics'), false);
|
|
});
|
|
|
|
test('resolvePackagedDependency walks node_modules boundaries upward', () => {
|
|
const dirs = new Set([
|
|
'/node_modules/ms',
|
|
'/node_modules/electron-updater/node_modules/lazy-val',
|
|
]);
|
|
|
|
// Hoisted to top level, requested from a deeply nested package.
|
|
assert.equal(
|
|
resolvePackagedDependency(
|
|
'/node_modules/electron-updater/node_modules/builder-util-runtime',
|
|
'ms',
|
|
dirs
|
|
),
|
|
'/node_modules/ms'
|
|
);
|
|
|
|
// Nested copy preferred over walking further up.
|
|
assert.equal(
|
|
resolvePackagedDependency(
|
|
'/node_modules/electron-updater',
|
|
'lazy-val',
|
|
dirs
|
|
),
|
|
'/node_modules/electron-updater/node_modules/lazy-val'
|
|
);
|
|
|
|
assert.equal(resolvePackagedDependency('', 'missing', dirs), null);
|
|
});
|
|
|
|
test('resolvePackagedDependency reaches root node_modules from app subdirectories', () => {
|
|
// A package under an app subdirectory (e.g. /electron-backend/node_modules)
|
|
// must still resolve a dependency hoisted to the archive root, exactly as
|
|
// Node's resolver walks every ancestor directory.
|
|
const dirs = new Set([
|
|
'/electron-backend/node_modules/foo',
|
|
'/node_modules/bar',
|
|
]);
|
|
|
|
assert.equal(
|
|
resolvePackagedDependency(
|
|
'/electron-backend/node_modules/foo',
|
|
'bar',
|
|
dirs
|
|
),
|
|
'/node_modules/bar'
|
|
);
|
|
});
|
|
|
|
test('reports a deduplicated transitive dependency dropped from the archive (issue #1103)', () => {
|
|
// `debug` is packaged but its transitive `ms` was dropped by the collector.
|
|
const packageDirs = new Set([
|
|
'',
|
|
'/node_modules/electron-updater',
|
|
'/node_modules/debug',
|
|
]);
|
|
const manifests = {
|
|
'': { dependencies: { 'electron-updater': '6.8.9' } },
|
|
'/node_modules/electron-updater': { dependencies: { debug: '4.4.3' } },
|
|
'/node_modules/debug': { dependencies: { ms: '2.1.3' } },
|
|
};
|
|
|
|
const missing = findMissingPackagedDependencies(
|
|
packageDirs,
|
|
manifestReader(manifests)
|
|
);
|
|
|
|
assert.deepEqual(missing, [
|
|
{ dependency: 'ms', requiredBy: '/node_modules/debug' },
|
|
]);
|
|
});
|
|
|
|
test('passes when the full runtime closure is present', () => {
|
|
const packageDirs = new Set([
|
|
'',
|
|
'/node_modules/electron-updater',
|
|
'/node_modules/debug',
|
|
'/node_modules/ms',
|
|
]);
|
|
const manifests = {
|
|
'': { dependencies: { 'electron-updater': '6.8.9' } },
|
|
'/node_modules/electron-updater': { dependencies: { debug: '4.4.3' } },
|
|
'/node_modules/debug': { dependencies: { ms: '2.1.3' } },
|
|
'/node_modules/ms': {},
|
|
};
|
|
|
|
assert.deepEqual(
|
|
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
|
|
[]
|
|
);
|
|
});
|
|
|
|
test('does not flag frontend-only deps the app root declares but never ships', () => {
|
|
// The app package.json lists Angular etc. (compiled into the web bundle),
|
|
// which are intentionally absent from the shipped runtime node_modules.
|
|
const packageDirs = new Set(['', '/node_modules/electron-updater']);
|
|
const manifests = {
|
|
'': {
|
|
dependencies: {
|
|
'@angular/core': '21.2.9',
|
|
'electron-updater': '6.8.9',
|
|
},
|
|
},
|
|
'/node_modules/electron-updater': {},
|
|
};
|
|
|
|
assert.deepEqual(
|
|
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
|
|
[]
|
|
);
|
|
});
|
|
|
|
test('ignores missing optional dependencies', () => {
|
|
const packageDirs = new Set(['', '/node_modules/pkg']);
|
|
const manifests = {
|
|
'': { dependencies: { pkg: '1.0.0' } },
|
|
'/node_modules/pkg': {
|
|
dependencies: { fsevents: '2.3.0' },
|
|
optionalDependencies: { fsevents: '2.3.0' },
|
|
},
|
|
};
|
|
|
|
assert.deepEqual(
|
|
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
|
|
[]
|
|
);
|
|
});
|
|
|
|
test('ignores host-provided deps declared in both dependencies and peerDependencies', () => {
|
|
// e.g. a package listing `electron` in dependencies for dev installs while
|
|
// the Electron runtime provides it — never shipped inside app.asar.
|
|
const packageDirs = new Set(['', '/node_modules/pkg']);
|
|
const manifests = {
|
|
'': { dependencies: { pkg: '1.0.0' } },
|
|
'/node_modules/pkg': {
|
|
dependencies: { electron: '41.0.0' },
|
|
peerDependencies: { electron: '>=30' },
|
|
},
|
|
};
|
|
|
|
assert.deepEqual(
|
|
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
|
|
[]
|
|
);
|
|
});
|
|
|
|
test('tolerates dependency cycles without infinite recursion', () => {
|
|
const packageDirs = new Set(['', '/node_modules/a', '/node_modules/b']);
|
|
const manifests = {
|
|
'': { dependencies: { a: '1.0.0' } },
|
|
'/node_modules/a': { dependencies: { b: '1.0.0' } },
|
|
'/node_modules/b': { dependencies: { a: '1.0.0' } },
|
|
};
|
|
|
|
assert.deepEqual(
|
|
findMissingPackagedDependencies(packageDirs, manifestReader(manifests)),
|
|
[]
|
|
);
|
|
});
|
|
|
|
test('inspectPackagedDependencyClosure wires injected asar IO', () => {
|
|
const files = {
|
|
'package.json': { dependencies: { debug: '4.4.3' } },
|
|
'node_modules/debug/package.json': { dependencies: { ms: '2.1.3' } },
|
|
};
|
|
const listPackage = () => [
|
|
'/package.json',
|
|
'/node_modules/debug/package.json',
|
|
];
|
|
const extractFile = (_asarPath, relativePath) => {
|
|
const manifest = files[relativePath];
|
|
if (!manifest) {
|
|
throw new Error(`not found: ${relativePath}`);
|
|
}
|
|
return Buffer.from(JSON.stringify(manifest));
|
|
};
|
|
|
|
const { missing, packageCount, manifestReadFailures } =
|
|
inspectPackagedDependencyClosure('app.asar', {
|
|
listPackage,
|
|
extractFile,
|
|
pathSep: '/',
|
|
});
|
|
|
|
assert.deepEqual(missing, [
|
|
{ dependency: 'ms', requiredBy: '/node_modules/debug' },
|
|
]);
|
|
assert.equal(packageCount, 1);
|
|
assert.deepEqual(manifestReadFailures, []);
|
|
});
|
|
|
|
test('inspectPackagedDependencyClosure handles Windows-separator asar IO', () => {
|
|
// @electron/asar builds listing entries with the host separator and
|
|
// resolves extractFile paths by splitting on path.sep — on Windows both
|
|
// are backslash-based. The guard must normalize listings to posix and
|
|
// hand extractFile backslash paths, otherwise it audits nothing.
|
|
const files = {
|
|
'package.json': { dependencies: { debug: '4.4.3' } },
|
|
'node_modules\\debug\\package.json': {
|
|
dependencies: { ms: '2.1.3' },
|
|
},
|
|
};
|
|
const listPackage = () => [
|
|
'\\package.json',
|
|
'\\node_modules\\debug\\package.json',
|
|
'\\node_modules\\debug\\src\\index.js',
|
|
];
|
|
const extractFile = (_asarPath, relativePath) => {
|
|
// Mimic asar on win32: forward-slash lookups do not resolve.
|
|
const manifest = files[relativePath];
|
|
if (!manifest) {
|
|
throw new Error(`${relativePath} was not found in this archive`);
|
|
}
|
|
return Buffer.from(JSON.stringify(manifest));
|
|
};
|
|
|
|
const { missing, packageCount, manifestReadFailures } =
|
|
inspectPackagedDependencyClosure('app.asar', {
|
|
listPackage,
|
|
extractFile,
|
|
pathSep: '\\',
|
|
});
|
|
|
|
assert.equal(packageCount, 1);
|
|
assert.deepEqual(manifestReadFailures, []);
|
|
assert.deepEqual(missing, [
|
|
{ dependency: 'ms', requiredBy: '/node_modules/debug' },
|
|
]);
|
|
});
|
|
|
|
test('inspectPackagedDependencyClosure surfaces manifest read failures instead of swallowing them', () => {
|
|
const listPackage = () => ['/node_modules/broken/package.json'];
|
|
const extractFile = () => {
|
|
throw new Error('corrupt entry');
|
|
};
|
|
|
|
const { missing, packageCount, manifestReadFailures } =
|
|
inspectPackagedDependencyClosure('app.asar', {
|
|
listPackage,
|
|
extractFile,
|
|
pathSep: '/',
|
|
});
|
|
|
|
assert.deepEqual(missing, []);
|
|
assert.equal(packageCount, 1);
|
|
assert.deepEqual(manifestReadFailures, [
|
|
{ packageDir: '/node_modules/broken', message: 'corrupt entry' },
|
|
]);
|
|
});
|