fix(packaging): enforce Linux frame-copy isolation

This commit is contained in:
4gray committed 2026-07-17 22:42:36 +02:00
1 parent d960997061
commit e8903c86fb
5 files changed
+235 -30

No files matched your search

+5 -3
View File
@@ -1,6 +1,5 @@
const linuxAfterPack = require('./linux-after-pack.cjs');
const {
getEmbeddedMpvAddonArch,
isForeignLinuxEmbeddedMpvArch,
resolveElectronBuilderArchName,
validatePackagedEmbeddedMpv,
@@ -118,8 +117,11 @@ function resolveLinuxFrameCopyPackagingContext(
);
}
const addonArch = getEmbeddedMpvAddonArch(environment);
const foreignArch = targetArch !== addonArch;
// Official Linux frame-copy artifacts are intentionally x64-only. Do not
// let a caller-provided build-arch environment value promote an ARM
// package to a supported layout: every non-x64 target must remain the
// marker-only native-view fallback.
const foreignArch = targetArch !== 'x64';
const profileValue =
environment.IPTVNATOR_LINUX_FRAME_COPY_PROFILE?.trim() ?? '';
if (!profileValue) {
@@ -305,6 +305,9 @@ test('package layout verifier uses canonical helpers and direct dependencies', (
packageLayoutVerifier,
/validateLinuxProfileTargets\(\s*linuxFrameCopyProfile,\s*linuxTargetNames\s*\)/s
);
assert.match(packageLayoutVerifier, /dirArch !== 'x64'/);
assert.doesNotMatch(packageLayoutVerifier, /getEmbeddedMpvAddonArch/);
assert.match(electronAfterPackSource, /targetArch !== 'x64'/);
});
test('nx-electron packaging does not copy duplicate root package metadata', () => {
+131 -1
View File
@@ -301,6 +301,26 @@ test('resolves the required Linux profile from afterPack targets before mutation
);
});
test('keeps every non-x64 Linux target marker-only even when the configured addon arch matches it', () => {
const context = resolveLinuxFrameCopyPackagingContext(
{
electronPlatformName: 'linux',
arch: 3,
targets: [{ name: 'deb' }],
},
{
required: true,
environment: {
IPTVNATOR_LINUX_FRAME_COPY_PROFILE: 'system',
IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64',
},
}
);
assert.equal(context.targetArch, 'arm64');
assert.equal(context.foreignArch, true);
});
test('rejects missing, mixed, and unknown required Linux packaging context', () => {
const baseParams = {
electronPlatformName: 'linux',
@@ -811,6 +831,24 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure
});
assert.match(addonLinkErrors.join('\n'), /must not link libmpv/);
const pathBearingAddonLinkErrors = validatePackagedEmbeddedMpv(
fixture.resourceDir,
{
...options,
elfInspector: validElfInspector(fixture.nativeDir, manifest, {
[FRAME_COPY_ARTIFACTS.addon]: {
needed: ['/tmp/build/libmpv.so.2'],
rpath: [],
runpath: [],
},
}),
}
);
assert.match(
pathBearingAddonLinkErrors.join('\n'),
/must not link libmpv.*\/tmp\/build\/libmpv\.so\.2/
);
const helperLinkErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, {
...options,
elfInspector: validElfInspector(fixture.nativeDir, manifest, {
@@ -826,6 +864,29 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure
/must directly need libmpv\.so\.2/
);
const unexpectedHelperLinkErrors = validatePackagedEmbeddedMpv(
fixture.resourceDir,
{
...options,
elfInspector: validElfInspector(fixture.nativeDir, manifest, {
[FRAME_COPY_ARTIFACTS.helper]: {
needed: [
manifest.libmpvSoname,
'libEGL.so.1',
'libc.so.6',
'libsurprise.so.1',
],
rpath: [],
runpath: ['$ORIGIN/lib'],
},
}),
}
);
assert.match(
unexpectedHelperLinkErrors.join('\n'),
/helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/
);
const closureErrors = validatePackagedEmbeddedMpv(fixture.resourceDir, {
...options,
elfInspector: validElfInspector(fixture.nativeDir, manifest, {
@@ -841,6 +902,69 @@ test('validates Linux ELF process isolation, helper linkage, and bundled closure
closureErrors.join('\n'),
/not bundled or allowlisted.*libsurprise\.so\.1/
);
fs.writeFileSync(
join(fixture.appOutDir, 'libelectron-extra.so'),
'electron library'
);
const electronLibraryErrors = validatePackagedEmbeddedMpv(
fixture.resourceDir,
{
...options,
elfInspector: validElfInspector(fixture.nativeDir, manifest, {
'libelectron-extra.so': {
needed: ['libmpv.so.2'],
rpath: [],
runpath: [],
},
}),
}
);
assert.match(
electronLibraryErrors.join('\n'),
/Linux Electron library must not link libmpv.*libelectron-extra\.so/
);
});
test('rejects undeclared helper dependencies in system-runtime packages', (t) => {
const fixture = createNativeFixture();
t.after(() =>
fs.rmSync(fixture.fixtureRoot, { recursive: true, force: true })
);
const manifest = preparePackagedFrameCopyArtifacts(
fixture.nativeDir,
'linux',
{
profile: 'system',
targetNames: ['deb'],
}
);
const errors = validatePackagedEmbeddedMpv(fixture.resourceDir, {
platform: 'linux',
required: true,
foreignArch: false,
profile: 'system',
targetNames: ['deb'],
hostPlatform: 'linux',
elfInspector: validElfInspector(fixture.nativeDir, manifest, {
[FRAME_COPY_ARTIFACTS.helper]: {
needed: [
manifest.libmpvSoname,
'libEGL.so.1',
'libc.so.6',
'libsurprise.so.1',
],
rpath: [],
runpath: ['$ORIGIN/lib'],
},
}),
});
assert.match(
errors.join('\n'),
/helper dependency is not bundled or allowlisted.*libsurprise\.so\.1/
);
});
test('resolveElectronBuilderArchName maps builder-util Arch enum values', () => {
@@ -852,7 +976,7 @@ test('resolveElectronBuilderArchName maps builder-util Arch enum values', () =>
assert.equal(resolveElectronBuilderArchName(99), null);
});
test('flags Linux packages whose arch differs from the built addon', () => {
test('flags every non-x64 Linux package regardless of configured build arch', () => {
assert.equal(
isForeignLinuxEmbeddedMpvArch('linux', 3, X64_ADDON_ENV),
true
@@ -865,6 +989,12 @@ test('flags Linux packages whose arch differs from the built addon', () => {
isForeignLinuxEmbeddedMpvArch('linux', 'x64', X64_ADDON_ENV),
false
);
assert.equal(
isForeignLinuxEmbeddedMpvArch('linux', 'arm64', {
IPTVNATOR_EMBEDDED_MPV_ARCH: 'arm64',
}),
true
);
// Only Linux fans out foreign arches from one dist tree.
assert.equal(
isForeignLinuxEmbeddedMpvArch('darwin', 'arm64', X64_ADDON_ENV),
+93 -21
View File
@@ -745,26 +745,17 @@ function resolveConfiguredLinuxTargetNames(configuredTargets, targetArch) {
return [...targetNames].sort();
}
function getEmbeddedMpvAddonArch(env = process.env) {
return env.IPTVNATOR_EMBEDDED_MPV_ARCH || process.arch;
}
/**
* The embedded MPV addon is compiled once per CI host (x64 on Linux), but
* electron-builder also produces arm64/armv7l Linux packages from the same
* dist output. Those packages must not ship a foreign-architecture
* `embedded_mpv.node` — it can never load and produces a cryptic error.
* Official Linux frame-copy support is x64-only. electron-builder also
* produces arm64/armv7l packages, which must always carry only the
* unavailable marker and native-view fallback.
*/
function isForeignLinuxEmbeddedMpvArch(
platform,
targetArch,
env = process.env
) {
function isForeignLinuxEmbeddedMpvArch(platform, targetArch) {
if (normalizeEmbeddedMpvPlatform(platform) !== 'linux') {
return false;
}
const archName = resolveElectronBuilderArchName(targetArch);
return Boolean(archName) && archName !== getEmbeddedMpvAddonArch(env);
return Boolean(archName) && archName !== 'x64';
}
// Maps electron-builder Linux output directory names (`linux-unpacked`,
@@ -1294,6 +1285,40 @@ function normalizeElfInspection(value, binaryPath) {
return result;
}
function dependencyFileName(dependencyName) {
return dependencyName.replaceAll('\\', '/').split('/').at(-1) ?? '';
}
function listElectronShippedLinuxLibraries(resourceDir) {
const appDir = path.dirname(resourceDir);
const normalizedResourceDir = path.resolve(resourceDir);
const libraries = [];
function visit(directoryPath) {
for (const entry of fs.readdirSync(directoryPath, {
withFileTypes: true,
})) {
const entryPath = path.join(directoryPath, entry.name);
if (path.resolve(entryPath) === normalizedResourceDir) {
continue;
}
if (entry.isDirectory()) {
visit(entryPath);
continue;
}
if (
(entry.isFile() || entry.isSymbolicLink()) &&
/\.so(?:\.\d+)*$/.test(entry.name)
) {
libraries.push(entryPath);
}
}
}
visit(appDir);
return libraries.sort();
}
function inspectLinuxElfIsolation(
resourceDir,
nativeDir,
@@ -1328,6 +1353,11 @@ function inspectLinuxElfIsolation(
reader: path.join(nativeDir, linuxFrameCopyArtifacts.frameReader.name),
helper: path.join(nativeDir, linuxFrameCopyArtifacts.helper.name),
};
for (const [index, libraryPath] of listElectronShippedLinuxLibraries(
resourceDir
).entries()) {
inspectedPaths[`electronLibrary:${index}`] = libraryPath;
}
const inspections = {};
for (const [label, binaryPath] of Object.entries(inspectedPaths)) {
if (
@@ -1353,19 +1383,35 @@ function inspectLinuxElfIsolation(
}
}
for (const label of ['electron', 'addon', 'reader']) {
for (const label of Object.keys(inspections).filter(
(name) =>
name === 'electron' ||
name === 'addon' ||
name === 'reader' ||
name.startsWith('electronLibrary:')
)) {
const dynamic = inspections[label];
if (!dynamic) {
continue;
}
const displayLabel = label.startsWith('electronLibrary:')
? 'Electron library'
: label;
for (const dependencyName of dynamic.needed) {
if (dependencyFileName(dependencyName) !== dependencyName) {
errors.push(
`Linux ${displayLabel} DT_NEEDED entry must not contain a path: ${dependencyName} in ${inspectedPaths[label]}.`
);
}
}
const libmpvDependencies = dynamic.needed.filter((dependencyName) =>
anyLinuxLibmpvPattern.test(dependencyName)
anyLinuxLibmpvPattern.test(dependencyFileName(dependencyName))
);
if (libmpvDependencies.length > 0) {
errors.push(
`Linux ${label} binary must not link libmpv; found ${libmpvDependencies.join(
`Linux ${displayLabel} must not link libmpv; found ${libmpvDependencies.join(
', '
)}.`
)} in ${inspectedPaths[label]}.`
);
}
}
@@ -1379,8 +1425,9 @@ function inspectLinuxElfIsolation(
}
const unexpectedLibmpvDependencies = helper.needed.filter(
(dependencyName) =>
anyLinuxLibmpvPattern.test(dependencyName) &&
dependencyName !== manifest.libmpvSoname
anyLinuxLibmpvPattern.test(
dependencyFileName(dependencyName)
) && dependencyName !== manifest.libmpvSoname
);
if (unexpectedLibmpvDependencies.length > 0) {
errors.push(
@@ -1408,6 +1455,32 @@ function inspectLinuxElfIsolation(
}.`
);
}
const allowedHelperDependencies = new Set([
manifest.libmpvSoname,
...GLIBC_TOOLCHAIN_ALLOWLIST,
...EXTERNAL_SYSTEM_LIBRARIES.map(({ name }) => name),
...(manifest.runtimeMode === 'bundled' &&
Array.isArray(manifest.runtimeFiles)
? manifest.runtimeFiles.map(({ name }) => name)
: []),
...(manifest.runtimeMode === 'bundled' &&
Array.isArray(
manifest.runtimeDependencyClosure?.externalDependencies
)
? manifest.runtimeDependencyClosure.externalDependencies
: []),
]);
for (const dependencyName of helper.needed) {
if (
dependencyFileName(dependencyName) !== dependencyName ||
!allowedHelperDependencies.has(dependencyName)
) {
errors.push(
`Linux frame-copy helper dependency is not bundled or allowlisted: ${dependencyName}.`
);
}
}
}
if (
@@ -1822,7 +1895,6 @@ module.exports = {
validateNoForbiddenRuntimeLinks,
getPackagedRuntimeCandidates,
validatePackagedEmbeddedMpv,
getEmbeddedMpvAddonArch,
isForeignLinuxEmbeddedMpvArch,
linuxUnpackedDirArch,
resolveConfiguredLinuxTargetNames,
@@ -8,7 +8,6 @@ import { inspectPackagedDependencyClosure } from './asar-dependency-closure.mjs'
const require = createRequire(import.meta.url);
const { extractFile, listPackage } = require('@electron/asar');
const {
getEmbeddedMpvAddonArch,
linuxUnpackedDirArch,
resolveConfiguredLinuxTargetNames,
validatePackagedEmbeddedMpv,
@@ -622,9 +621,8 @@ function verifyPackagedDependencyClosure(resourceDir, errors) {
);
}
// The embedded MPV addon is built once per CI host (x64), but electron-builder
// emits arm64/armv7l Linux output directories from the same dist tree. Those
// must carry the unavailable marker instead of a foreign-architecture addon.
// Official Linux frame-copy support is x64-only. Every arm64/armv7l output
// must carry the unavailable marker instead of native frame-copy artifacts.
function isForeignArchLinuxResourceDir(resourceDir) {
if (platform !== 'linux') {
return false;
@@ -633,7 +631,7 @@ function isForeignArchLinuxResourceDir(resourceDir) {
const dirArch = linuxUnpackedDirArch(
path.basename(path.dirname(resourceDir))
);
return Boolean(dirArch) && dirArch !== getEmbeddedMpvAddonArch();
return Boolean(dirArch) && dirArch !== 'x64';
}
function verifyResourceDir(resourceDir) {