From edac617e10381330761012059c72f9a3dcae41fb Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 14:41:05 -0700 Subject: [PATCH] fix(desktop): assert Linux AppImage embeds Postgres packaging (#2131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Verification of the Fusion Linux AppImage + embedded Postgres packaging surface (follow-on to #2106 Mac packaging and #2117 Windows PG work). ### What we found 1. **Published `v0.60.0` AppImages are broken for Local/embedded Postgres** (pre-#2106): - No `embedded-postgres` / `@embedded-postgres/*` in `app.asar` or `app.asar.unpacked` - `package.json` `main` is still `dist/main.js` (no `main-bootstrap.cjs`) - No `omp-runtime` packaged - `asar.unpacked` only has incidental natives (pi-tui, esbuild, node-pty) 2. **Current main (post-#2106) packaging config is correct** (verified via mac `--dir` pack on this host): - `main` → `dist/main-bootstrap.cjs` - Full `asarUnpack` of `embedded-postgres` + `@embedded-postgres/**` - Native bins present under `app.asar.unpacked` - `omp-runtime` dist present in asar 3. **Linux arm64 native PG binary smoke** (`@embedded-postgres/linux-arm64` 15.18) in Docker: initdb → start → create DB → persist across restart → **OK** (requires postinstall soname symlinks from `hydrate-symlinks.js` / `pg-symlinks.json`). 4. **Host blocker:** this machine is macOS arm64 — cannot produce or execute a Linux AppImage end-to-end. Linux packaging must run on `ubuntu-latest` CI. ### Fix in this PR Release jobs only checked that `*.AppImage` files existed — which is how v0.60.0 shipped empty of Postgres. Add: - `scripts/verify-desktop-linux-pg-packaging.mjs` — inspects `linux-*-unpacked` trees for: - `app.asar.unpacked` embedded-postgres + `@embedded-postgres/linux-*` bins - `dist/main-bootstrap.cjs` + `package.json` main - `omp-runtime` presence - Wire into `release.yml` + `test-release.yml` after AppImage artifact checks - Unit test lock in `release-workflow.test.ts` ## Test plan - [x] `pnpm --filter @fusion/core test:embedded-postgres` (33/33 with 60s timeout; default 15s flaked under load) - [x] Desktop packaging unit tests (`electron-builder-config`, `build-bundling`, `release-workflow`) - [x] Inspected published `Fusion-0.60.0-linux-arm64.AppImage` (checksum OK; PG packaging absent) - [x] Post-#2106 `electron-builder --mac --dir`: asar.unpacked has PG + bootstrap + omp - [x] Docker linux-arm64 native binary lifecycle smoke - [ ] CI `build-desktop-linux` on this PR (runs the new verifier against real linux-unpacked) ## Gaps remaining (not fixed here) | Gap | Notes | |-----|-------| | Full AppImage launch + `/api/health` on Linux | Needs Linux host/CI with display or headless Electron | | No post-#2106 published AppImage yet | Next release will include packaging fixes; this PR stops empty AppImages | | README still says `linux-x64.AppImage` | Actual name is `linux-x86_64.AppImage` (workflow already correct) | | Windows packaged Local | Tracked by #2117 / verify-desktop | ## Summary by CodeRabbit * **Bug Fixes** * Added verification for Linux AppImage packaging to ensure embedded Postgres binaries, platform files, symlinks, and runtime assets are included correctly. * Confirmed packaged application metadata points to the expected startup entry point. * Improved detection of invalid, missing, or incorrectly formatted packaging artifacts. * **Tests** * Added coverage for architecture-specific binaries, exact ASAR paths, executable files, and symlink metadata. * Verified packaging checks run after Linux desktop artifacts are created. --- .../linux-pg-packaging-verifier.test.ts | 83 +++++++++++++++++++ .../src/__tests__/release-workflow.test.ts | 6 ++ scripts/verify-desktop-linux-pg-packaging.mjs | 31 +++++-- 3 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 packages/desktop/src/__tests__/linux-pg-packaging-verifier.test.ts 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(); +}