diff --git a/packages/desktop/src/__tests__/linux-pg-packaging-verifier.test.ts b/packages/desktop/src/__tests__/linux-pg-packaging-verifier.test.ts new file mode 100644 index 0000000000..f757d9fee9 --- /dev/null +++ b/packages/desktop/src/__tests__/linux-pg-packaging-verifier.test.ts @@ -0,0 +1,83 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +const verifierPath = path.resolve( + import.meta.dirname, + "../../../../scripts/verify-desktop-linux-pg-packaging.mjs", +); +const verifier = await import(verifierPath); +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("Linux AppImage Postgres packaging verifier", () => { + it("matches normalized ASAR paths exactly", () => { + // FNXC:DesktopEmbeddedPostgres 2026-07-15-11:55: + // Runtime entrypoints must be actual ASAR files, not source maps whose + // names merely contain the required path. + const listing = "/dist/main-bootstrap.cjs.map\nnode_modules/pkg/dist/index.js.map\n/dist/main-bootstrap.cjs\n"; + + expect(verifier.listIncludesAsarPath(listing, "/dist/main-bootstrap.cjs")).toBe(true); + expect(verifier.listIncludesAsarPath(listing, "/node_modules/pkg/dist/index.js")).toBe(false); + }); + + it("derives the matching platform package and requires executable regular files", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "fusion-pg-verifier-")); + tempDirs.push(tempDir); + const executable = path.join(tempDir, "postgres"); + const notExecutable = path.join(tempDir, "initdb"); + const x64Elf = path.join(tempDir, "x64-elf"); + const arm64Elf = path.join(tempDir, "arm64-elf"); + await writeFile(executable, "#!/bin/sh\n"); + await writeFile(notExecutable, "#!/bin/sh\n"); + await chmod(executable, 0o755); + await chmod(notExecutable, 0o644); + const elfHeader = (machine: number) => { + const header = Buffer.alloc(20); + header.set([0x7f, 0x45, 0x4c, 0x46, 2, 1]); + header.writeUInt16LE(machine, 18); + return header; + }; + await writeFile(x64Elf, elfHeader(62)); + await writeFile(arm64Elf, elfHeader(183)); + + expect(verifier.expectedLinuxPlatform("/tmp/linux-unpacked")).toBe("linux-x64"); + expect(verifier.expectedLinuxPlatform("/tmp/linux-arm64-unpacked")).toBe("linux-arm64"); + expect(verifier.isRegularExecutable(executable)).toBe(true); + expect(verifier.isRegularExecutable(notExecutable)).toBe(false); + expect(verifier.isRegularExecutable(tempDir)).toBe(false); + expect(verifier.hasExpectedElfArchitecture(x64Elf, "linux-x64")).toBe(true); + expect(verifier.hasExpectedElfArchitecture(x64Elf, "linux-arm64")).toBe(false); + expect(verifier.hasExpectedElfArchitecture(arm64Elf, "linux-arm64")).toBe(true); + }); + + it("accepts complete SONAME metadata and rejects malformed package fixtures", async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), "fusion-pg-verifier-")); + tempDirs.push(tempDir); + const platform = "linux-x64"; + const nativeRoot = path.join(tempDir, platform, "native"); + await mkdir(path.join(nativeRoot, "lib"), { recursive: true }); + await writeFile(path.join(nativeRoot, "lib", "libpq.so.1"), "source"); + await writeFile(path.join(nativeRoot, "lib", "libpq.so"), "target"); + await writeFile( + path.join(nativeRoot, "pg-symlinks.json"), + JSON.stringify([{ source: "native/lib/libpq.so.1", target: "native/lib/libpq.so" }]), + ); + + // FNXC:DesktopEmbeddedPostgres 2026-07-15-11:55: + // Exercise real manifest fixtures: valid links pass while malformed data + // must fail the verifier rather than silently skipping a broken payload. + process.exitCode = undefined; + verifier.assertPlatformSonameLinks("fixture", tempDir, platform); + expect(process.exitCode).toBeUndefined(); + + await writeFile(path.join(nativeRoot, "pg-symlinks.json"), JSON.stringify([{}])); + verifier.assertPlatformSonameLinks("fixture", tempDir, platform); + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); +}); diff --git a/packages/desktop/src/__tests__/release-workflow.test.ts b/packages/desktop/src/__tests__/release-workflow.test.ts index 2f3cd4ab45..3b57f246f5 100644 --- a/packages/desktop/src/__tests__/release-workflow.test.ts +++ b/packages/desktop/src/__tests__/release-workflow.test.ts @@ -109,6 +109,12 @@ describe("desktop release workflow wiring", () => { for (const workflow of [release, testRelease, advisoryPackaging]) { expect(workflow).toContain("Verify Linux AppImage embedded Postgres packaging"); expect(workflow).toContain("node scripts/verify-desktop-linux-pg-packaging.mjs"); + // FNXC:DesktopEmbeddedPostgres 2026-07-15-11:55: + // This verifier reads electron-builder's unpacked tree, so invoking it + // before the Linux packaging command would only validate stale output. + expect(workflow.indexOf("node scripts/verify-desktop-linux-pg-packaging.mjs")).toBeGreaterThan( + workflow.indexOf("Package Linux desktop artifacts"), + ); } }); diff --git a/scripts/verify-desktop-linux-pg-packaging.mjs b/scripts/verify-desktop-linux-pg-packaging.mjs index d56fac124f..f7a772c7c2 100644 --- a/scripts/verify-desktop-linux-pg-packaging.mjs +++ b/scripts/verify-desktop-linux-pg-packaging.mjs @@ -146,7 +146,7 @@ function resourcesRoot(unpackedDir) { * with missing libicui18n.so.60 (etc.). Assert every recorded target path exists * (symlink or regular file) under the platform native root. */ -function assertPlatformSonameLinks(unpackedDir, platformRoot, platform) { +export function assertPlatformSonameLinks(unpackedDir, platformRoot, platform) { const nativeRoot = join(platformRoot, platform, "native"); const markerPath = join(nativeRoot, "pg-symlinks.json"); if (!existsSync(markerPath)) { @@ -227,7 +227,7 @@ function pathExists(p) { } } -function listIncludesAsarPath(list, entry) { +export function listIncludesAsarPath(list, entry) { // FNXC:DesktopEmbeddedPostgres 2026-07-15-11:45: // ASAR listings may omit a leading slash. Normalize line entries before exact // matching so a source map (for example index.js.map) cannot satisfy a runtime @@ -241,14 +241,14 @@ function listIncludesAsarPath(list, entry) { return normalizedEntries.has(`/${entry.replace(/^\/+/, "")}`); } -function expectedLinuxPlatform(unpackedDir) { +export function expectedLinuxPlatform(unpackedDir) { const name = unpackedDir.split(/[/\\]/).pop() ?? ""; // electron-builder calls x64's default output linux-unpacked and appends // non-default target architectures such as linux-arm64-unpacked. return name.includes("arm64") ? "linux-arm64" : "linux-x64"; } -function isRegularExecutable(path) { +export function isRegularExecutable(path) { try { const stat = statSync(path); return stat.isFile() && (stat.mode & 0o111) !== 0; @@ -257,6 +257,20 @@ function isRegularExecutable(path) { } } +export function hasExpectedElfArchitecture(path, platform) { + try { + const header = readFileSync(path); + if (header.length < 20 || header[0] !== 0x7f || header.toString("ascii", 1, 4) !== "ELF") { + return false; + } + const littleEndian = header[5] === 1; + const machine = littleEndian ? header.readUInt16LE(18) : header.readUInt16BE(18); + return (platform === "linux-x64" && machine === 62) || (platform === "linux-arm64" && machine === 183); + } catch { + return false; + } +} + function assertUnpackedTree(unpackedDir, asarBin) { const resources = resourcesRoot(unpackedDir); if (!resources) { @@ -295,6 +309,11 @@ function assertUnpackedTree(unpackedDir, asarBin) { const binPath = join(platformRoot, expectedPlatform, "native", "bin", bin); if (!isRegularExecutable(binPath)) { fail(`${unpackedDir}: missing executable file ${expectedPlatform}/native/bin/${bin}`); + // FNXC:DesktopEmbeddedPostgres 2026-07-15-12:05: + // A correctly named package can still contain another CPU's payload. + // Verify ELF e_machine for the target platform before release approval. + } else if (!hasExpectedElfArchitecture(binPath, expectedPlatform)) { + fail(`${unpackedDir}: ${expectedPlatform}/native/bin/${bin} is not a ${expectedPlatform} ELF binary`); } } assertPlatformSonameLinks(unpackedDir, platformRoot, expectedPlatform); @@ -390,4 +409,6 @@ function main() { ok("Linux desktop embedded Postgres packaging verification passed"); } -main(); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); +}