diff --git a/tools/packaging/electron-package-identity.test.mjs b/tools/packaging/electron-package-identity.test.mjs index 7dd6207d3..5fdcd22c3 100644 --- a/tools/packaging/electron-package-identity.test.mjs +++ b/tools/packaging/electron-package-identity.test.mjs @@ -312,6 +312,10 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( packageLayoutVerifier, /const\s*{\s*resolveLinuxLauncherLayout\s*}\s*=\s*require\(['"]\.\/linux-launcher-layout\.cjs['"]\)/ ); + assert.match( + packageLayoutVerifier, + /const\s*{\s*validateFlatpakLauncher\s*,?\s*}\s*=\s*require\(['"]\.\/flatpak-launcher-validation\.cjs['"]\)/ + ); assert.match( packageLayoutVerifier, /function verifyLinuxLauncher\(\s*resourceDir,\s*targetNames,\s*errors\s*\)/ @@ -324,16 +328,6 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( packageLayoutVerifier, /verifyLinuxLauncher\(\s*resourceDir,\s*linuxTargetNames,\s*errors\s*\)/ ); - assert.match(packageLayoutVerifier, /const elfMagic = Buffer\.alloc\(4\)/); - assert.match( - packageLayoutVerifier, - /fs\.openSync\(launcherPath,\s*['"]r['"]\)/ - ); - assert.match( - packageLayoutVerifier, - /fs\.readSync\(\s*descriptor,\s*elfMagic,\s*0,\s*elfMagic\.length,\s*0\s*\)/ - ); - assert.match(packageLayoutVerifier, /fs\.closeSync\(descriptor\)/); const launcherVerifier = packageLayoutVerifier.match( /function verifyLinuxLauncher\([\s\S]*?\n}\n\nfunction verifyFlatpakPermissions/ @@ -345,11 +339,7 @@ test('package layout verifier uses canonical helpers and direct dependencies', ( ); assert.match( launcherVerifier, - /if \(!launcherLayout\.wrapperRequired\) \{[\s\S]*?return;[\s\S]*?\}\s*const launcherBinaryPath[\s\S]*?fs\.readFileSync\(launcherPath,\s*['"]utf8['"]\)/ - ); - assert.match( - launcherVerifier, - /fs\.existsSync\(flatpakBinarySiblingPath\)/ + /if \(!launcherLayout\.wrapperRequired\) \{\s*errors\.push\(\s*\.\.\.validateFlatpakLauncher\(\s*appDir,\s*linuxExecutableName\s*\)\s*\);\s*return;\s*\}\s*const launcherBinaryPath[\s\S]*?fs\.readFileSync\(launcherPath,\s*['"]utf8['"]\)/ ); }); diff --git a/tools/packaging/flatpak-launcher-validation.cjs b/tools/packaging/flatpak-launcher-validation.cjs new file mode 100644 index 000000000..933bf6098 --- /dev/null +++ b/tools/packaging/flatpak-launcher-validation.cjs @@ -0,0 +1,125 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const expectedElfMagic = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); +const executableModeBits = 0o111; + +function fsErrorCode(error) { + return error && typeof error === 'object' && typeof error.code === 'string' + ? error.code + : 'UNKNOWN'; +} + +function validateBinarySibling(siblingPath, errors) { + try { + fs.lstatSync(siblingPath); + } catch (error) { + if (fsErrorCode(error) === 'ENOENT') { + return; + } + errors.push( + `Unable to inspect Flatpak Electron launcher binary sibling at ${siblingPath} (${fsErrorCode(error)}).` + ); + return; + } + + errors.push( + `Flatpak Electron layout must not include a launcher binary sibling: ${siblingPath}` + ); +} + +function validateFlatpakLauncher(appDir, executableName) { + const errors = []; + const launcherPath = path.join(appDir, executableName); + const flatpakBinarySiblingPath = `${launcherPath}.bin`; + validateBinarySibling(flatpakBinarySiblingPath, errors); + + let descriptor; + try { + descriptor = fs.openSync( + launcherPath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW + ); + } catch (error) { + const errorCode = fsErrorCode(error); + if (errorCode === 'ENOENT') { + errors.push( + `Missing Flatpak Electron ELF in ${appDir}: ${path.basename(launcherPath)}` + ); + } else if (errorCode === 'ELOOP') { + errors.push( + `Flatpak Electron launcher must be a regular file: ${launcherPath}` + ); + } else { + errors.push( + `Unable to open Flatpak Electron launcher at ${launcherPath} (${errorCode}).` + ); + } + return errors; + } + + try { + let launcherStat; + try { + launcherStat = fs.fstatSync(descriptor); + } catch (error) { + errors.push( + `Unable to stat Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + return errors; + } + + if (!launcherStat.isFile()) { + errors.push( + `Flatpak Electron launcher must be a regular file: ${launcherPath}` + ); + return errors; + } + if ((launcherStat.mode & executableModeBits) === 0) { + errors.push( + `Flatpak Electron launcher must be executable: ${launcherPath}` + ); + } + + const elfMagic = Buffer.alloc(expectedElfMagic.length); + let bytesRead; + try { + bytesRead = fs.readSync( + descriptor, + elfMagic, + 0, + elfMagic.length, + 0 + ); + } catch (error) { + errors.push( + `Unable to read Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + return errors; + } + + if ( + bytesRead !== expectedElfMagic.length || + !elfMagic.equals(expectedElfMagic) + ) { + errors.push( + `Flatpak Electron launcher must be an ELF binary: ${launcherPath}` + ); + } + } finally { + try { + fs.closeSync(descriptor); + } catch (error) { + errors.push( + `Unable to close Flatpak Electron launcher at ${launcherPath} (${fsErrorCode(error)}).` + ); + } + } + return errors; +} + +module.exports = { + validateFlatpakLauncher, +}; diff --git a/tools/packaging/flatpak-launcher-validation.test.mjs b/tools/packaging/flatpak-launcher-validation.test.mjs new file mode 100644 index 000000000..0b8ff95b2 --- /dev/null +++ b/tools/packaging/flatpak-launcher-validation.test.mjs @@ -0,0 +1,108 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { + validateFlatpakLauncher, +} = require('./flatpak-launcher-validation.cjs'); + +const executableName = 'iptvnator'; +const electronElf = Buffer.from([ + 0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00, +]); + +async function createFixture(t) { + const appDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'iptvnator-flatpak-launcher-') + ); + t.after(() => fs.rm(appDir, { recursive: true, force: true })); + + return { + appDir, + launcherPath: path.join(appDir, executableName), + siblingPath: path.join(appDir, `${executableName}.bin`), + }; +} + +async function writeLauncher(launcherPath, contents, mode) { + await fs.writeFile(launcherPath, contents); + await fs.chmod(launcherPath, mode); +} + +test('accepts a regular executable Flatpak Electron ELF', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o755); + + assert.deepEqual( + validateFlatpakLauncher(fixture.appDir, executableName), + [] + ); +}); + +test('rejects a symlinked Flatpak Electron launcher', async (t) => { + const fixture = await createFixture(t); + const electronTargetPath = path.join(fixture.appDir, 'electron-target'); + await writeLauncher(electronTargetPath, electronElf, 0o755); + await fs.symlink(electronTargetPath, fixture.launcherPath); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron launcher must be a regular file: ${fixture.launcherPath}`, + ]); +}); + +test('rejects a dangling Flatpak launcher binary sibling', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o755); + await fs.symlink('missing-electron', fixture.siblingPath); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron layout must not include a launcher binary sibling: ${fixture.siblingPath}`, + ]); +}); + +test('rejects a non-executable regular Flatpak Electron ELF', async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, electronElf, 0o644); + + assert.deepEqual(validateFlatpakLauncher(fixture.appDir, executableName), [ + `Flatpak Electron launcher must be executable: ${fixture.launcherPath}`, + ]); +}); + +for (const [description, contents] of [ + ['short ELF magic', Buffer.from([0x7f, 0x45, 0x4c])], + ['incorrect ELF magic', Buffer.from([0x00, 0x45, 0x4c, 0x46])], +]) { + test(`rejects ${description} in the Flatpak Electron launcher`, async (t) => { + const fixture = await createFixture(t); + await writeLauncher(fixture.launcherPath, contents, 0o755); + + assert.deepEqual( + validateFlatpakLauncher(fixture.appDir, executableName), + [ + `Flatpak Electron launcher must be an ELF binary: ${fixture.launcherPath}`, + ] + ); + }); +} + +test('packaging Nx tests register the Flatpak launcher validation suite', async () => { + const project = JSON.parse( + await fs.readFile(new URL('./project.json', import.meta.url), 'utf8') + ); + const moduleFile = + '{workspaceRoot}/tools/packaging/flatpak-launcher-validation.cjs'; + const testFile = + '{workspaceRoot}/tools/packaging/flatpak-launcher-validation.test.mjs'; + + assert.ok(project.targets.test.inputs.includes(moduleFile)); + assert.ok(project.targets.test.inputs.includes(testFile)); + assert.match( + project.targets.test.options.command, + /node --test .*tools\/packaging\/flatpak-launcher-validation\.test\.mjs/ + ); +}); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index c8b7b526b..2c3097c63 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -22,6 +22,8 @@ "{workspaceRoot}/tools/packaging/asar-dependency-closure.test.mjs", "{workspaceRoot}/tools/packaging/embedded-mpv-packaging.cjs", "{workspaceRoot}/tools/packaging/embedded-mpv-arch.test.mjs", + "{workspaceRoot}/tools/packaging/flatpak-launcher-validation.cjs", + "{workspaceRoot}/tools/packaging/flatpak-launcher-validation.test.mjs", "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.mjs", "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", "{workspaceRoot}/tools/packaging/linux-after-pack.test.mjs", @@ -50,7 +52,7 @@ "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-after-pack.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/flatpak-launcher-validation.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-after-pack.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/prepare-linux-runtime-source-snapshot.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/release-snap-assets.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs index d86e5fec0..40a709c6f 100644 --- a/tools/packaging/verify-electron-package-layout.mjs +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -19,6 +19,9 @@ const { validateLinuxProfileTargets, } = require('./linux-frame-copy-profile.cjs'); const { resolveLinuxLauncherLayout } = require('./linux-launcher-layout.cjs'); +const { + validateFlatpakLauncher, +} = require('./flatpak-launcher-validation.cjs'); const args = process.argv.slice(2); const normalizedArgs = args[0] === '--' ? args.slice(1) : args; const [platform, arch = ''] = normalizedArgs; @@ -489,61 +492,7 @@ function verifyLinuxLauncher(resourceDir, targetNames, errors) { const launcherPath = path.join(appDir, linuxExecutableName); if (!launcherLayout.wrapperRequired) { - if (!fileExists(launcherPath)) { - errors.push( - `Missing Flatpak Electron ELF in ${appDir}: ${path.basename(launcherPath)}` - ); - return; - } - - const flatpakBinarySiblingPath = `${launcherPath}.bin`; - if (fs.existsSync(flatpakBinarySiblingPath)) { - errors.push( - `Flatpak Electron layout must not include a launcher binary sibling: ${flatpakBinarySiblingPath}` - ); - } - - const expectedElfMagic = Buffer.from([0x7f, 0x45, 0x4c, 0x46]); - const elfMagic = Buffer.alloc(4); - let descriptor; - try { - descriptor = fs.openSync(launcherPath, 'r'); - const bytesRead = fs.readSync( - descriptor, - elfMagic, - 0, - elfMagic.length, - 0 - ); - if ( - bytesRead !== expectedElfMagic.length || - !elfMagic.equals(expectedElfMagic) - ) { - errors.push( - `Flatpak Electron launcher must be an ELF binary: ${launcherPath}` - ); - } - } catch (error) { - errors.push( - `Unable to inspect Flatpak Electron ELF at ${launcherPath}: ${ - error instanceof Error ? error.message : String(error) - }` - ); - } finally { - if (descriptor !== undefined) { - try { - fs.closeSync(descriptor); - } catch (error) { - errors.push( - `Unable to close Flatpak Electron ELF at ${launcherPath}: ${ - error instanceof Error - ? error.message - : String(error) - }` - ); - } - } - } + errors.push(...validateFlatpakLauncher(appDir, linuxExecutableName)); return; }