Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 172 additions & 1 deletion scripts/before-pack.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,28 @@ function checkLinuxNativePayload(context) {
*/
const MAX_SYMBOL_VERSION = { GLIBC: "2.35", GLIBCXX: "3.4.30", CXXABI: "1.3.13" };

/**
* The one supported way past the ceiling, for the one case it does not fit: a developer
* on a distro newer than the floor, building a package for their own machine.
*
* Without it there is no way to get a .deb out of a working tree on, say, Ubuntu 24.04.
* `npm run build:whisper-binaries` links whisper/ggml against the host's glibc, so the
* guard refuses every local build and the only recourse is to push and wait for CI.
* That is a real cost — it means nobody can test a packaging change without a round
* trip — and it is what this exists to remove.
*
* `host` does NOT disable the check. It swaps the pinned ceiling for what this machine
* actually provides, so a payload needing something even the host lacks still fails.
* The guard keeps catching genuinely broken builds; it just stops pretending the
* developer is on Ubuntu 22.04. What it gives up is the distro-floor promise, which is
* exactly the promise a local build is not making.
*
* Refused outright under CI: an escape hatch that can reach a published artifact is not
* an escape hatch, it is a hole. The runners are pinned to the floor, so they never
* need it, and a release built with it set would be the bug this whole file prevents.
*/
const SYMBOL_FLOOR_MODE = process.env.OPENSCREEN_SYMBOL_FLOOR ?? "";

/** Dotted numeric compare, so 3.4.9 < 3.4.30 and 2.4 < 2.38 rather than by string. */
function compareVersions(a, b) {
const left = a.split(".").map(Number);
Expand Down Expand Up @@ -573,6 +595,131 @@ function neededSymbolVersions(file) {
return highest;
}

/**
* The mirror of neededSymbolVersions: what a library DEFINES (SHT_GNU_VERDEF) rather
* than what it asks for. Only used to read the host's own ceiling — a payload binary
* defines nothing interesting.
*
* Same hand-rolled parse as above, and for the same reason: this file already refuses
* to shell out to readelf, so that a missing binutils cannot turn the guard off.
*/
function definedSymbolVersions(file) {
const b = fs.readFileSync(file);
if (b.readUInt32BE(0) !== 0x7f454c46) throw new Error(`${file} is not an ELF binary`);
if (b[4] !== 2 || b[5] !== 1) throw new Error(`${file} is not 64-bit little-endian ELF`);

const shoff = Number(b.readBigUInt64LE(0x28));
const shentsize = b.readUInt16LE(0x3a);
const SHT_GNU_VERDEF = 0x6ffffffd;

let section;
for (let i = 0; i < b.readUInt16LE(0x3c); i++) {
const sh = shoff + i * shentsize;
if (b.readUInt32LE(sh + 4) !== SHT_GNU_VERDEF) continue;
// sh_info is the Verdef count; sh_link is the string table these names live in.
const strtabHeader = shoff + b.readUInt32LE(sh + 0x28) * shentsize;
section = {
offset: Number(b.readBigUInt64LE(sh + 0x18)),
count: b.readUInt32LE(sh + 0x2c),
strtab: Number(b.readBigUInt64LE(strtabHeader + 0x18)),
};
break;
}
if (!section) return {};

const nameAt = (at) =>
b.subarray(section.strtab + at, b.indexOf(0, section.strtab + at)).toString("latin1");

const highest = {};
let verdef = section.offset;
for (let i = 0; i < section.count; i++) {
// Verdef: vd_version(2) vd_flags(2) vd_ndx(2) vd_cnt(2) vd_hash(4) vd_aux(4) vd_next(4)
let verdaux = verdef + b.readUInt32LE(verdef + 12);
for (let j = 0; j < b.readUInt16LE(verdef + 6); j++) {
// Verdaux: vda_name(4) vda_next(4). The first entry of the first Verdef is the
// soname rather than a version, and it simply does not match the pattern.
const [, prefix, version] =
/^(.+)_(\d+(?:\.\d+)*)$/.exec(nameAt(b.readUInt32LE(verdaux))) ?? [];
if (prefix && (!highest[prefix] || compareVersions(version, highest[prefix]) > 0)) {
highest[prefix] = version;
}
verdaux += b.readUInt32LE(verdaux + 4);
}
verdef += b.readUInt32LE(verdef + 16);
}
return highest;
}

/**
* What THIS machine provides, read from the libraries node itself is running against —
* `process.report` gives their absolute paths, so there is nothing to guess at and no
* `ldconfig` to parse. node links both of the ones that matter.
*/
function hostSymbolCeiling() {
const providers = process.report
.getReport()
.sharedObjects.filter((so) => /\/lib(?:c|stdc\+\+)\.so\.6(?:\.\d+)*$/.test(so));

const ceiling = {};
for (const lib of providers) {
for (const [prefix, version] of Object.entries(definedSymbolVersions(lib))) {
if (!(prefix in MAX_SYMBOL_VERSION)) continue;
if (!ceiling[prefix] || compareVersions(version, ceiling[prefix]) > 0) {
ceiling[prefix] = version;
}
}
}

// Every prefix the pinned ceiling names has to come back, or the comparison below
// would quietly skip one and pass a payload nobody checked.
const missing = Object.keys(MAX_SYMBOL_VERSION).filter((prefix) => !ceiling[prefix]);
if (missing.length > 0) {
throw new Error(
`OPENSCREEN_SYMBOL_FLOOR=host could not read ${missing.join(", ")} from this machine.\n\n` +
` looked in: ${providers.join(", ") || "(node reported no libc/libstdc++)"}\n\n` +
"Unset the variable to check against the pinned floor instead.",
);
}
return ceiling;
}

/**
* The ceiling this run compares against, plus whether it is the pinned one. Validates
* OPENSCREEN_SYMBOL_FLOOR here rather than at module load, so a stray value cannot
* break a Windows or macOS pack that never consults it.
*/
function resolveSymbolCeiling() {
if (SYMBOL_FLOOR_MODE === "") {
return { ceiling: MAX_SYMBOL_VERSION, pinned: true };
}
// An unrecognised value is an error, never a silent "enforce" or a silent "waive":
// a typo in the one variable that relaxes this guard must not decide either way.
if (SYMBOL_FLOOR_MODE !== "host") {
throw new Error(
`OPENSCREEN_SYMBOL_FLOOR=${SYMBOL_FLOOR_MODE} is not a value this guard knows.\n\n` +
'The only accepted value is "host": compare against this machine rather than the\n' +
"oldest supported distro, for a package you are building to run locally.\n" +
"Unset it to check against the pinned floor.",
);
}
if (process.env.CI) {
throw new Error(
"OPENSCREEN_SYMBOL_FLOOR=host is refused under CI.\n\n" +
"It exists so a developer on a newer distro can build a package for their own\n" +
"machine; a released artifact built with it would not start on the distros the\n" +
"README claims. The runners are pinned to the floor (build.yml build-linux,\n" +
"build-whisper-stt.yml), so nothing on CI needs it.",
);
}
return { ceiling: hostSymbolCeiling(), pinned: false };
}

// Exported for scripts/before-pack.test.mjs and nothing else. The two refusals above
// are the only things standing between this escape hatch and a published package that
// starts on nobody's machine but the builder's, and they are reachable from a test
// without a payload to scan — so they are tested rather than trusted.
exports.__testing = { resolveSymbolCeiling, MAX_SYMBOL_VERSION };

/** Every ELF under `dir`, recursively — the helper's ffmpeg sits in a subdirectory. */
function elfFilesUnder(dir) {
const found = [];
Expand Down Expand Up @@ -618,10 +765,29 @@ function checkLinuxSymbolVersionFloor(dir) {
);
}

// After the parser assertion on purpose: "does the pinned floor apply here" and "did
// the scan work at all" are unrelated questions, and the second is how this guard
// stays honest whichever ceiling it ends up using.
const { ceiling, pinned } = resolveSymbolCeiling();
if (!pinned) {
// Loud, because a relaxed guard that says nothing is indistinguishable from a
// guard that passed — and this one leaves a package that only runs here.
console.log(
`[before-pack] symbol-version ceiling taken from THIS MACHINE, not the pinned floor:\n` +
` ${Object.entries(ceiling)
.map(([prefix, max]) => `${prefix}_${max}`)
.join(", ")} (pinned floor: ${Object.entries(MAX_SYMBOL_VERSION)
.map(([prefix, max]) => `${prefix}_${max}`)
.join(", ")})\n` +
" OPENSCREEN_SYMBOL_FLOOR=host is set. The package this produces may not start on\n" +
" the distros the README claims — do not publish it.",
);
}

const offenders = scanned
.map((entry) => ({
name: entry.name,
bad: Object.entries(MAX_SYMBOL_VERSION)
bad: Object.entries(ceiling)
.filter(
([prefix, max]) => entry.needs[prefix] && compareVersions(entry.needs[prefix], max) > 0,
)
Expand All @@ -645,6 +811,11 @@ function checkLinuxSymbolVersionFloor(dir) {
"and build-whisper-stt.yml. To see which symbols pulled a version in:\n\n" +
" readelf -V <file>\n" +
" readelf -W --dyn-syms <file> | grep @GLIBC_2.38\n\n" +
(pinned
? "Building a package to run on THIS machine rather than to release? Set\n" +
"OPENSCREEN_SYMBOL_FLOOR=host, which compares against your own glibc instead of\n" +
"the floor. It is refused under CI, so it cannot reach a published artifact.\n\n"
: "") +
"Raising MAX_SYMBOL_VERSION drops a distro the README claims to support.",
);
}
Expand Down
90 changes: 90 additions & 0 deletions scripts/before-pack.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// OPENSCREEN_SYMBOL_FLOOR=host relaxes the one guard that keeps a Linux package
// startable on the distros the README claims. That is the right trade for a developer
// building for their own machine and a shipping bug anywhere else, so the two refusals
// that keep it local — an unknown value, and CI — are tested rather than trusted.
//
// Neither needs a payload to scan: resolveSymbolCeiling() decides from the environment
// alone, which is why it is the seam this file pokes at. The comparison it feeds is
// exercised for real by every `npm run build:linux`.
//
// The mode is read once at module load, so each case re-requires before-pack.cjs with a
// different environment instead of mutating state on an already-loaded copy.

import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { describe, expect, it } from "vitest";

const require = createRequire(import.meta.url);
const BEFORE_PACK = path.join(path.dirname(fileURLToPath(import.meta.url)), "before-pack.cjs");

/**
* Run `body` against a fresh copy of before-pack.cjs loaded under `env`.
*
* The environment has to stay set for the call and not just the require: the mode is
* captured at module load, but the CI check reads process.env when it runs, and a
* helper that restored before handing back made that refusal look absent.
*/
function withEnv(env, body) {
const saved = new Map(Object.keys(env).map((key) => [key, process.env[key]]));
for (const [key, value] of Object.entries(env)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
try {
delete require.cache[require.resolve(BEFORE_PACK)];
return body(require(BEFORE_PACK).__testing);
} finally {
for (const [key, value] of saved) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}

describe("symbol-version ceiling", () => {
it("uses the pinned floor when OPENSCREEN_SYMBOL_FLOOR is unset", () => {
withEnv({ OPENSCREEN_SYMBOL_FLOOR: undefined, CI: undefined }, (t) => {
const { ceiling, pinned } = t.resolveSymbolCeiling();

expect(pinned).toBe(true);
expect(ceiling).toBe(t.MAX_SYMBOL_VERSION);
});
});

it("refuses an unknown value rather than guessing enforce or waive", () => {
withEnv({ OPENSCREEN_SYMBOL_FLOOR: "yes-please", CI: undefined }, (t) => {
expect(() => t.resolveSymbolCeiling()).toThrow(/not a value this guard knows/);
});
});

it("refuses host mode under CI, so it cannot reach a published artifact", () => {
withEnv({ OPENSCREEN_SYMBOL_FLOOR: "host", CI: "true" }, (t) => {
expect(() => t.resolveSymbolCeiling()).toThrow(/refused under CI/);
});
});

// Reads this machine's own libc/libstdc++, so it asserts shape rather than values:
// every prefix the pinned floor names came back, and each one is a version this run
// actually parsed out of an ELF.
//
// Deliberately NOT asserted: that the host ceiling is at least the pinned one. Host
// mode substitutes, it does not raise — on a distro OLDER than the floor the ceiling
// legitimately comes back lower, which makes the check stricter rather than weaker.
// Requiring otherwise would fail this test on a correct machine.
it.runIf(process.platform === "linux")("takes the ceiling from this machine in host mode", () => {
withEnv({ OPENSCREEN_SYMBOL_FLOOR: "host", CI: undefined }, (t) => {
const { ceiling, pinned } = t.resolveSymbolCeiling();

expect(pinned).toBe(false);
expect(ceiling).not.toBe(t.MAX_SYMBOL_VERSION);
expect(Object.keys(ceiling).sort()).toEqual(Object.keys(t.MAX_SYMBOL_VERSION).sort());
for (const [prefix, version] of Object.entries(ceiling)) {
expect(version, `${prefix} came back as ${JSON.stringify(version)}`).toMatch(
/^\d+(\.\d+)*$/,
);
}
});
});
});
8 changes: 8 additions & 0 deletions technical-documentation/engineering/build-and-packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,14 @@ The exposure is instead handled at the source, which is the layer to prefer anyw

**Which script does the copying is the interesting part.** It belongs to the build, not to packaging, because provenance is what makes the bundled copy correct: `build-whisper-stt.yml` pins its Linux leg to `ubuntu-22.04`, the same floor `before-pack.cjs` enforces, so the library that ships comes from the same machine and the same glibc as the binaries that load it, and it travels inside the whisper artifact to every consumer. Copying it at packaging time instead would take it from whoever ran the build — and a 24.04 desktop's `libgomp` needs `GLIBC_2.38`, which the symbol-version guard then rejects, leaving a developer on a current distro unable to package at all. `scripts/stage-whisper-stt.sh` only asserts it arrived, and says to re-run the whisper workflow if it did not.

That last consequence — a developer on a current distro unable to package at all — has a supported way out, because the alternative is that nobody can test a packaging change without pushing and waiting for CI. `OPENSCREEN_SYMBOL_FLOOR=host` swaps the pinned ceiling for what this machine's own `libc.so.6` and `libstdc++.so.6` *define*, read straight out of the libraries node is already running against, so there is no `ldconfig` to parse and no `binutils` to require.

```bash
OPENSCREEN_SYMBOL_FLOOR=host npm run build:linux
```

It relaxes the ceiling rather than removing the guard: a payload needing something even the host lacks still fails, and the parser assertion that keeps the scan honest runs either way. What it gives up is the distro-floor promise, which is the promise a local build is not making — so the build prints the ceiling it substituted and says not to publish the result. Any value other than `host` is an error rather than a silent enforce or a silent waive, and the variable is **refused outright when `CI` is set**: an escape hatch that can reach a published artifact is a hole, and the runners are pinned to the floor so nothing on CI needs it. `scripts/before-pack.test.mjs` covers both refusals.

What remains host-supplied for the AppImage is the GTK/GLib/NSS stack, which no AppImage bundles — theme engines, GIO modules and pixbuf loaders all resolve against the host. `libvulkan.so.1` is already bundled at the AppImage root by electron-builder itself. For the Vulkan *driver*, which cannot be bundled, `d3d_linux::diagnose` names the Mesa package instead.

**One dependency class stays invisible to everything above**, and 1.9.3 adds it after #328: `xdg-desktop-portal`. All Linux capture goes through it — X11 included, the helper has no other path — but a portal is a D-Bus service, so it appears in no `DT_NEEDED` entry and `ldd` will never name it however bare the container is. Note precisely which half the check still holds: it cannot tell you the portal is *missing* from the list, but the install step does prove that a name you put there exists, which is what confirmed `xdg-desktop-portal` on all three distros. It hid behind the same metapackage accident as the three sonames, and it is now declared on `deb`, `rpm` and `pacman`. Declaring it is only half the fix: the frontend merely routes, and the ScreenCast implementation comes from a desktop-specific backend (`-gnome`, `-kde`, `-hyprland`, `-wlr`, `-gtk`) that no `depends` list here can choose without being wrong on half the machines. The other half is `portal.rs::portal_unavailable`, which names those backends in the error the user actually sees — the same answer `diagnose` gives for the Vulkan driver, and the only one that reaches the AppImage.
Expand Down
Loading