mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix(linux): give the CLI one entrypoint by extracting the AppImage once
* refactor(linux): trim AppImage CLI registration seams
* test(cli): assert registration lock serialization
* fix(linux): fence AppImage terminal shim mounts
* fix(linux): accept extracted AppImage runtimes with APPDIR only
* docs(linux): make headless AppImage extraction runnable
* refactor(linux): import bundled launcher directly
* fix(linux): reclaim superseded AppImage payloads and packaged symlinks
Pruning removed 3215 of 3216 files from a superseded generation and always
stranded resources/app.asar, leaking ~105 MB per version update. Electron's
asar shim reports a *.asar file as a directory, so the recursive remove tried
to rmdir a real file and failed with ENOTEMPTY; the .catch(() => {}) hid it.
Reproduced end to end on Ubuntu 24.04: 519M -> 623M across one update, and
519M again once the payload is actually reclaimed.
removeExtractedAppImagePayload holds process.noAsar for the removal, counted
so overlapping removals cannot hand the shim back early, and the prune site
now warns with the path instead of swallowing the rejection. All three
removal sites use it -- staging cleanup and displaced roots leaked the same
way.
Also reclaim symlinks left by a packaged deb/rpm install, which the
extracted-cache-only rule turned into a hard conflict on a deb -> AppImage
migration, and name the remedy in the conflict error.
* fix(linux): bound the CLI registration lock wait
`retries: 1000` caps the attempt count, not elapsed time, so at up to 1s per
attempt an IPC-driven registration could hang ~16 minutes against a wedged
holder with no feedback.
A legitimate holder is bounded by the extraction timeout, so wait that plus
slack and then fail with a message naming the lock file, rather than hanging.
`maxRetryTime` is forwarded verbatim to the `retry` package by proper-lockfile.
* fix(linux): stop re-extracting the AppImage on inode metadata churn
The extracted-payload cache key hashed ctime alongside dev/ino/size/mtime.
ctime moves on any inode metadata write -- `chmod +x`, which every AppImage
user is told to run, plus `chown`, an ACL or SELinux relabel, and a backup
restore -- none of which alter a byte of the payload.
Measured on Ubuntu 24.04: `chmod +x` leaves dev, ino, size and mtime
identical and moves ctime alone, so the key changed and the next launch paid
a full ~519 MB re-extraction and a multi-second stall to rebuild a payload it
already had, then pruned the old generation.
Key on content identity instead. An in-place content change moves mtime and
almost always size; a replacement moves the inode. The existing
replace-in-place test still passes.
* fix(linux): stop CLI commands from falling through to Chromium startup
* refactor(cli): remove redundant command membership check
* test(cli): cover command-named project selectors
* fix(cli): redirect the open-url command before startup
* test(linux): cover AUR serve wrapper flags
* fix(linux): tighten CLI launch detection
* fix(linux): respect CLI flag value boundaries
* fix(linux): strip injected Chromium switches from CLI args
* fix(linux): report a missing display instead of dying in uv_close
* refactor(linux): read display locks without a preflight race
* fix(linux): preserve unverified external displays
* chore: format reliability gate manifest
* test(packaging): split runtime resource checks
* fix(linux): fail serve when no display is available
* fix(linux): do not treat a lockless X socket as a dead display
An X server writes its lock beside its socket and both survive a crash
(verified against Xvfb under SIGKILL), so a socket with no lock was never
left by a crashed server. It is an endpoint published from elsewhere: a
container bind-mounting only /tmp/.X11-unix, WSLg, or a foreign PID
namespace. Declaring those dead made the desktop gate exit(1) on displays
that work, with no workaround, and the serve gate refuse to start.
Liveness now splits by ownership. A foreign DISPLAY trusts a lockless
socket; Orca's own :99 does not, because removeStaleDisplayArtifacts
unlinks the lock before the socket and so manufactures that state itself --
adopting it would resurrect the orphan-socket bug and stop the cleanup from
self-healing. The stale-lock rejection is unchanged.
Also correct four doc statements this behaviour falsified.
* fix(linux): fail closed when a stale socket blocks the Xvfb rebind
Readiness only checked that /tmp/.X11-unix/X99 exists. A stale socket we
could not unlink still exists after our own Xvfb refused to bind, so Orca set
DISPLAY to a dead server and Chromium died in Ozone init.
Measured on Ubuntu 24.04 against the pre-fix build: with a leftover :99
socket and no lock, serve exits 139 (SIGSEGV), the socket inode is unchanged
before and after, and no lock is recreated -- it neither cleaned up nor
respawned. To a user that is a crash, not a misconfiguration.
This is reachable in the documented topology, where orca-xvfb.service has no
User= and runs as root while serve runs as User=orca: /tmp is sticky, so the
orca uid cannot unlink a root-owned socket, rmSync fails, and Xvfb exits with
the display already active.
Readiness now requires the display to actually be live -- our socket plus a
lock naming a running process -- so the same state reports an unusable
display and exits 1 with the existing diagnosis.
* fix(linux): recognise abstract X sockets and inherited Wayland fds
Two display setups this gate could not prove were refused outright, and on the
desktop path that is app.exit(1) with no workaround.
An X server may bind only the abstract namespace (`@/tmp/.X11-unix/X0`), which
leaves no filesystem socket to stat. Abstract addresses are kernel-owned and
vanish the moment the owner exits, so an entry in /proc/net/unix is proof of a
live server -- no lock file needed and no stale entry possible. Verified on
Ubuntu 24.04, where 139 such addresses were present.
WAYLAND_SOCKET is an already-connected fd handed over by the compositor, so
there is no path to stat and WAYLAND_DISPLAY may be unset entirely. Its
presence is the display.
Both are consulted only after the filesystem-socket check fails, so no
existing verdict changes.
* fix(linux): never treat Orca's own display number as a foreign endpoint
Recognising a lockless X socket as live is correct for an endpoint published
from elsewhere -- a container bind mount, WSLg -- because an X server writes
its lock beside its socket and both survive a crash. It is wrong for
VIRTUAL_DISPLAY_NUMBER, because Orca's own teardown unlinks the lock before
the socket and so manufactures that exact state.
The managed branch was already strict, but a caller that sets DISPLAY=:99
explicitly takes the foreign path and skipped it, accepting a dead display
left by Orca's own interrupted cleanup. Route the managed number through the
strict probe on both paths.
Found by an adversarial audit of the asymmetry introduced earlier in this
branch; the documented systemd topology is unaffected because its Xvfb writes
a real lock.
* test(linux): add a packaged-artifact contract for the CLI launch paths
* test(linux): avoid buffered serve readiness detection
* test(linux): signal AppImage serve owner directly
* test(linux): tolerate readiness timeout boundary
* test(linux): add startup margin to shutdown oracle
* ci(linux): give package contracts timeout headroom
* fix(ci): route all Linux packaging contract changes
* test(linux): poll shutdown readiness without tail leaks
* test(linux): bound shutdown cleanup grace
* test(linux): assert on CLI output, not the harness's own control lines
run-cli-case.sh echoes `RESULT status=N case=<name>`, and the two cases named
*-skills asserted `expectOutput: 'skills'`. That substring was satisfied by
the case name in the harness's own line, so 2 of 8 cases asserted nothing
about the command -- gutting `skills` entirely would still have gone green.
Control lines are now excluded before matching, and both cases assert the
rendered help header, which only real help output produces. Verified on an
Ubuntu 24.04 host: 8/8 still pass against a stack-tip AppImage.
Also register the gate in reliability-gates.jsonc, which #15085 added a CI
Docker gate without. Red/green is recorded from a stock release AppImage
failing 4 of 8, three of them at status 133 (SIGTRAP).
* fix(linux): require static AppImage runtimes (#17319)
* test(linux): reject a wrong-architecture native binary at packaging time
Cross-building the arm64 slice on an x64 host silently packed an x86-64
`pty.node` -- the rebuild logged "Forcing native rebuild for linux-arm64" and
shipped the host's binary anyway. Every gate here inspects symbol versions,
which are perfectly valid on the wrong architecture, so nothing noticed.
Observed on a Raspberry Pi 5: the packaged app loaded, then failed with
"Failed to load native module: pty.node", and the launch contract reported
3 of 8 cases crashed rather than naming the cause. Swapping in the aarch64
`pty.node` took the same build to 8/8.
Compare ELF `e_machine` against the slice being packaged and fail with the
offending path. Checked before the glibc pass, because a wrong-architecture
binary's symbol versions are valid but meaningless and would send the reader
down the wrong path.
Release CI builds arm64 on a native runner, so this guards local and future
cross-builds rather than a shipped artifact.
* test(linux): judge per-arch vendored binaries against their own path
The first CI run of the architecture gate failed the x64 package job on
`@parcel/watcher-linux-arm64-glibc/watcher.node`. That binary is arm64 on
purpose: the package ships every architecture and its loader picks the match,
so its presence in an x64 build is correct.
Judge a binary against the architecture its own path names, falling back to
the slice when the path names none. That keeps the case this gate exists for
-- `bin/linux-arm64-*/node-pty.node` holding an x86-64 binary, which is what
shipped to a Raspberry Pi 5 -- while letting multi-arch dependencies through.
Dry-run over the real dependency tree flags nothing for either target arch.
* fix(linux): move deb/rpm update installation outside Orca (#17318)
* fix(linux): complete deb/rpm package metadata
* fix(linux): preserve CLI link during package upgrades
* docs(linux): document local RPM build prerequisites
* fix(linux): move deb/rpm update installation outside Orca
* fix(updater): preserve Linux recovery across stale events
* fix(updater): fence stale downloaded events by active target
* fix(updater): preserve active Linux package recovery
* test(linux): keep workflow order assertion in scope
* test(updater): assert stale recovery stays silent
* fix(updater): preserve Linux package recovery after checks
* refactor(updater): keep Linux marker message with status
* fix(linux): describe the right manual update path for deb/rpm hosts
A remote host installed from .deb or .rpm now reports
manual-service-update-required, and the guidance told the operator to
"update through the service manager that starts this server" -- which is
correct for unsupported-headless-serve but wrong for a package install,
where nothing about the remedy involves the service manager.
Say both, keyed on how the host was installed.
* docs(linux): document orcad update restart safety
* docs(linux): scope restart census omissions
* docs(linux): use absolute service CLI launcher
* fix(serve): validate in-process serve options before startup (#17683)
* fix(linux): stop offering updates a distro-managed install cannot apply (#17918)
Closes #17702.
The resources/package-type marker is authoritative but never checked against
the host, so any repackager that unpacks Orca's .deb -- AUR, Nix, a container
rebuild -- inherits `deb` verbatim. Install feasibility was then computed
after a ~165 MB download, so those users got check -> download -> a card
promising an install command -> a dead end.
Validate the marker against the host: a deb/rpm marker with no matching
package manager in the trusted directories means a package manager owns this
install. This reuses the exact lists and resolver that
buildLinuxPackageInstallCommand already loops over, so a false positive is
impossible by construction -- any host flagged here would have failed with
no-package-manager after the download anyway. The gate only moves that
verdict earlier. Verified across Debian 12, Ubuntu 24.04, Arch, Fedora 40 and
openSUSE Leap: no false positive on a real deb host, correct on every
repackaging host.
The release is still reported, because the user does want to know 1.4.194
exists and to update through their distro; only the download path is closed.
`externallyManaged` is an additive optional field on the existing `available`
status, so older paired clients decode it unchanged. downloadUpdate() refuses
authoritatively, since main owns this verdict rather than the card, and
unwinds any pinned-build state first -- a Linux pinned jump resolves to
'release', and stranding isPinnedBuildActive would silently kill every
background check for the rest of the process.
Note the fix the issue suggests cannot work: electron-updater builds a
PacmanUpdater whose doDownloadUpdate looks for a .pacman asset Orca does not
publish, then dereferences undefined.
* style(cli): restore prettier wrapping on install error copy
* test(linux): re-pin the child-process ratchets and the batch-shim allowlist after the merge
494 lines
18 KiB
JavaScript
494 lines
18 KiB
JavaScript
const { readdirSync, openSync, readSync, closeSync } = require('node:fs')
|
|
const { spawnSync } = require('node:child_process')
|
|
const { join, relative } = require('node:path')
|
|
|
|
// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required
|
|
// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's
|
|
// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04
|
|
// (glibc 2.31) — the runner image silently bumped the build-host glibc. This
|
|
// gate fails Linux packaging if any bundled native binary requires a glibc (or
|
|
// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future
|
|
// runner bump or dependency change cannot reintroduce the regression unnoticed.
|
|
// See docs/reference/linux-glibc-compatibility.md.
|
|
const MIN_GLIBC = Object.freeze([2, 31])
|
|
|
|
// The symbol-version families this gate checks, each with the highest version
|
|
// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis;
|
|
// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules
|
|
// against the system libstdc++ (Orca does not bundle one).
|
|
const VERSION_FLOORS = Object.freeze([
|
|
Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }),
|
|
Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }),
|
|
Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) })
|
|
])
|
|
const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)'
|
|
|
|
// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that
|
|
// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads
|
|
// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app
|
|
// launch, so it cannot cause the #9902 startup crash. Exempt it from the
|
|
// libstdc++ floor (its glibc is still gated) rather than fail the release on a
|
|
// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11.
|
|
const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/
|
|
|
|
// VER_FLG_WEAK: a version need whose references are all weak. The loader
|
|
// tolerates its absence (resolves to null and the caller's fallback runs)
|
|
// instead of refusing to load, so a weak need must not count as a requirement.
|
|
const VER_FLG_WEAK = 0x2
|
|
|
|
/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */
|
|
function parseGlibcVersion(versionStr) {
|
|
return versionStr.split('.').map((part) => Number.parseInt(part, 10))
|
|
}
|
|
|
|
/** Compare two numeric version tuples; missing trailing parts are 0. */
|
|
function compareGlibcVersions(a, b) {
|
|
const length = Math.max(a.length, b.length)
|
|
for (let i = 0; i < length; i += 1) {
|
|
const diff = (a[i] ?? 0) - (b[i] ?? 0)
|
|
if (diff !== 0) {
|
|
return diff < 0 ? -1 : 1
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
/**
|
|
* Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section)
|
|
* into the version nodes this binary requires from each shared library. This is
|
|
* the authoritative load-time requirement list: unlike the dynamic symbol table
|
|
* (`objdump -T`), it also captures symbol-less ABI markers such as
|
|
* `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still
|
|
* block loading on an older glibc. Each entry: `0xHASH 0xFLAGS <n> <NAME>`.
|
|
*/
|
|
function parseVersionNeeds(objdumpOutput) {
|
|
const needs = []
|
|
let library = null
|
|
let inSection = false
|
|
for (const line of objdumpOutput.split('\n')) {
|
|
if (line.startsWith('Version References:')) {
|
|
inSection = true
|
|
continue
|
|
}
|
|
if (!inSection) {
|
|
continue
|
|
}
|
|
// Any new non-indented line ends the Version References block.
|
|
if (!/^\s/.test(line)) {
|
|
inSection = false
|
|
continue
|
|
}
|
|
const libraryMatch = line.match(/^\s+required from (\S+):/)
|
|
if (libraryMatch) {
|
|
library = libraryMatch[1]
|
|
continue
|
|
}
|
|
const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/)
|
|
if (entryMatch) {
|
|
const flags = Number.parseInt(entryMatch[1], 16)
|
|
needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 })
|
|
}
|
|
}
|
|
return needs
|
|
}
|
|
|
|
/**
|
|
* Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric
|
|
* nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric
|
|
* glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and
|
|
* `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across
|
|
* glibc releases, so a binary needing one can fail to load on the floor even
|
|
* though the version node itself exists (a well-formed addon needs neither).
|
|
* Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04.
|
|
* Families we do not gate (`GCC_`, `NSS_`) return false.
|
|
*/
|
|
function isVersionNodeAboveFloor(name) {
|
|
for (const { prefix, floor } of VERSION_FLOORS) {
|
|
if (!name.startsWith(prefix)) {
|
|
continue
|
|
}
|
|
const rest = name.slice(prefix.length)
|
|
if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) {
|
|
return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0
|
|
}
|
|
// Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE).
|
|
return prefix === 'GLIBC_'
|
|
}
|
|
return false
|
|
}
|
|
|
|
function isLibstdcxxNode(name) {
|
|
return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_')
|
|
}
|
|
|
|
/**
|
|
* Version needs from `filePath` that would prevent loading on the floor OS.
|
|
* `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT)
|
|
* but its glibc needs are still checked.
|
|
*/
|
|
function findFloorViolations(needs, filePath = '') {
|
|
const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath)
|
|
return needs.filter(
|
|
(need) =>
|
|
!need.weak &&
|
|
isVersionNodeAboveFloor(need.name) &&
|
|
!(exemptLibstdcxx && isLibstdcxxNode(need.name))
|
|
)
|
|
}
|
|
|
|
// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs —
|
|
// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that
|
|
// imports them must keep the DSO in DT_NEEDED or they will not resolve on the
|
|
// floor. This guards config/patches/node-pty@1.1.0.patch's forced
|
|
// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned
|
|
// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time
|
|
// (so the version-floor check passes) yet fail to load on 20.04. libpthread
|
|
// (pthread_sigmask) is intentionally omitted — the Node/Electron host always
|
|
// loads it, so it resolves regardless of this addon's DT_NEEDED.
|
|
const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({
|
|
openpty: 'libutil.so.1',
|
|
forkpty: 'libutil.so.1'
|
|
})
|
|
|
|
/**
|
|
* Relocated symbols the binary imports whose providing DSO is absent from
|
|
* DT_NEEDED — meaning they resolve at build time but not on the floor OS.
|
|
*/
|
|
function findMissingProviderDeps(importedSymbols, neededLibraries) {
|
|
const missing = []
|
|
for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) {
|
|
if (importedSymbols.has(symbol) && !neededLibraries.has(library)) {
|
|
missing.push({ symbol, library })
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
// ELF e_machine values for the Linux slices we package. Names match electron-builder's Arch enum.
|
|
const ELF_MACHINE_BY_ARCH = Object.freeze({ x64: 0x3e, arm64: 0xb7 })
|
|
const ARCH_BY_ELF_MACHINE = Object.freeze({ 0x3e: 'x64', 0xb7: 'arm64' })
|
|
|
|
/**
|
|
* ELF `e_machine`, or null when the file is not a readable little-endian ELF.
|
|
*
|
|
* Why this is checked at all: cross-building an arm64 package on an x64 host can silently pack an
|
|
* x86-64 `pty.node` into the arm64 slice — the rebuild logs a forced arm64 rebuild and still ships
|
|
* the host's binary. Every other gate here inspects symbol versions, which are perfectly valid on
|
|
* the wrong architecture, so nothing noticed. Observed on a Raspberry Pi 5: the app loaded, then
|
|
* failed with "Failed to load native module: pty.node".
|
|
*/
|
|
function readElfMachine(filePath) {
|
|
let fd
|
|
try {
|
|
fd = openSync(filePath, 'r')
|
|
const header = Buffer.alloc(20)
|
|
if (readSync(fd, header, 0, 20, 0) !== 20) {
|
|
return null
|
|
}
|
|
// EI_DATA (offset 5) must be ELFDATA2LSB for a little-endian e_machine read.
|
|
if (header[5] !== 1) {
|
|
return null
|
|
}
|
|
return header.readUInt16LE(18)
|
|
} catch {
|
|
return null
|
|
} finally {
|
|
if (fd !== undefined) {
|
|
closeSync(fd)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Arch tokens that appear in vendored per-architecture package/directory names.
|
|
const ARCH_TOKEN_PATTERN = /(?:^|[^a-z0-9])(arm64|aarch64|x64|x86_64)(?:[^a-z0-9]|$)/i
|
|
const ARCH_BY_TOKEN = Object.freeze({ arm64: 'arm64', aarch64: 'arm64', x64: 'x64', x86_64: 'x64' })
|
|
|
|
/**
|
|
* The architecture a path advertises, or null when it advertises none.
|
|
*
|
|
* Why this matters: some dependencies ship every architecture and let their loader pick
|
|
* (`@parcel/watcher-linux-arm64-glibc/watcher.node` is arm64 on purpose inside an x64 build). Those
|
|
* must be judged against the arch their own path declares, not against the slice.
|
|
*/
|
|
function declaredArchFromPath(filePath) {
|
|
const match = ARCH_TOKEN_PATTERN.exec(filePath)
|
|
return match ? ARCH_BY_TOKEN[match[1].toLowerCase()] : null
|
|
}
|
|
|
|
function findArchViolation(filePath, targetArch) {
|
|
// A path that names an architecture is judged against that name, so a per-arch vendored package
|
|
// is fine while `bin/linux-arm64-.../node-pty.node` holding an x86-64 binary is still caught.
|
|
const declared = declaredArchFromPath(filePath)
|
|
const expectedArch = declared ?? targetArch
|
|
const expected = ELF_MACHINE_BY_ARCH[expectedArch]
|
|
if (expected === undefined) {
|
|
return null
|
|
}
|
|
const machine = readElfMachine(filePath)
|
|
if (machine === null || machine === expected) {
|
|
return null
|
|
}
|
|
return {
|
|
machine,
|
|
actual: ARCH_BY_ELF_MACHINE[machine] ?? `0x${machine.toString(16)}`,
|
|
expectedArch,
|
|
declared: declared !== null
|
|
}
|
|
}
|
|
|
|
function isElfFile(filePath) {
|
|
let fd
|
|
try {
|
|
fd = openSync(filePath, 'r')
|
|
const header = Buffer.alloc(4)
|
|
const bytesRead = readSync(fd, header, 0, 4, 0)
|
|
return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF'
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
if (fd !== undefined) {
|
|
closeSync(fd)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */
|
|
function collectNativeBinaries(rootDir) {
|
|
const binaries = []
|
|
const walk = (dir) => {
|
|
let entries
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
for (const entry of entries) {
|
|
const fullPath = join(dir, entry.name)
|
|
if (entry.isSymbolicLink()) {
|
|
continue
|
|
}
|
|
if (entry.isDirectory()) {
|
|
walk(fullPath)
|
|
continue
|
|
}
|
|
if (!entry.isFile()) {
|
|
continue
|
|
}
|
|
// Why: .node/.so are always native; extensionless files (the Electron
|
|
// executable, chrome-sandbox) are checked via the ELF magic so we cover
|
|
// every launch-critical binary without objdump-ing app.asar or assets.
|
|
const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name)
|
|
if (looksNative || !entry.name.includes('.')) {
|
|
if (isElfFile(fullPath)) {
|
|
binaries.push(fullPath)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walk(rootDir)
|
|
return binaries.sort()
|
|
}
|
|
|
|
function resolveObjdump(explicitPath) {
|
|
const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean)
|
|
for (const candidate of candidates) {
|
|
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() })
|
|
if (!probe.error && probe.status === 0) {
|
|
return candidate
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// Why: GNU objdump localizes its section headers ("Version References:") via
|
|
// gettext, and the parser anchors on the English text. Force the C locale so
|
|
// output stays deterministic on non-English packaging hosts (LC_ALL=C also
|
|
// disables LANGUAGE-based message translation).
|
|
function cLocaleEnv() {
|
|
return { ...process.env, LC_ALL: 'C', LANG: 'C' }
|
|
}
|
|
|
|
/**
|
|
* Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero
|
|
* exit, or signal throws, because a silently-unreadable binary (truncated,
|
|
* corrupt, or an objdump that cannot decode its format) would let a too-new
|
|
* binary slip past the gate.
|
|
*/
|
|
function runObjdump(objdumpPath, flag, filePath) {
|
|
const result = spawnSync(objdumpPath, [flag, filePath], {
|
|
encoding: 'utf8',
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
env: cLocaleEnv()
|
|
})
|
|
if (result.error) {
|
|
throw new Error(
|
|
`[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}`
|
|
)
|
|
}
|
|
if (result.signal || result.status !== 0) {
|
|
throw new Error(
|
|
`[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` +
|
|
`(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}`
|
|
)
|
|
}
|
|
return result.stdout || ''
|
|
}
|
|
|
|
/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED <lib>`). */
|
|
function parseNeededLibraries(objdumpOutput) {
|
|
const needed = new Set()
|
|
for (const line of objdumpOutput.split('\n')) {
|
|
const match = line.match(/^\s+NEEDED\s+(\S+)/)
|
|
if (match) {
|
|
needed.add(match[1])
|
|
}
|
|
}
|
|
return needed
|
|
}
|
|
|
|
/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */
|
|
function parseImportedSymbols(objdumpOutput) {
|
|
const imported = new Set()
|
|
for (const line of objdumpOutput.split('\n')) {
|
|
if (!line.includes('*UND*')) {
|
|
continue
|
|
}
|
|
// The symbol name is the final token; strip any @VERSION suffix.
|
|
const token = line.trim().split(/\s+/).pop()
|
|
if (token) {
|
|
imported.add(token.split('@')[0])
|
|
}
|
|
}
|
|
return imported
|
|
}
|
|
|
|
/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */
|
|
function readDynamicInfo(filePath, objdumpPath) {
|
|
const output = runObjdump(objdumpPath, '-p', filePath)
|
|
return {
|
|
versionNeeds: parseVersionNeeds(output),
|
|
neededLibraries: parseNeededLibraries(output)
|
|
}
|
|
}
|
|
|
|
/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */
|
|
function readImportedSymbols(filePath, objdumpPath) {
|
|
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
|
|
}
|
|
|
|
/**
|
|
* Fail Linux packaging if any bundled native binary under `rootDir` requires a
|
|
* glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed
|
|
* on Linux: a missing objdump throws, because a silent skip would defeat the
|
|
* regression gate on exactly the host where it matters.
|
|
*/
|
|
function verifyLinuxGlibcFloor(rootDir, options = {}) {
|
|
const binaries = collectNativeBinaries(rootDir)
|
|
const targetArch = options.targetArch
|
|
if (binaries.length === 0) {
|
|
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
|
|
return
|
|
}
|
|
|
|
// Why: resolve objdump only once there is something to inspect, so a fixture
|
|
// with no ELF binaries does not fail on a host that lacks binutils.
|
|
const objdumpPath = resolveObjdump(options.objdumpPath)
|
|
if (!objdumpPath) {
|
|
throw new Error(
|
|
'[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' +
|
|
'packaging host so the glibc-floor gate can inspect bundled native binaries.'
|
|
)
|
|
}
|
|
|
|
// Why before the glibc pass: a wrong-architecture binary's symbol versions are valid but
|
|
// meaningless, so reporting a floor violation for it would send the reader down the wrong path.
|
|
const archOffenders = binaries
|
|
.map((filePath) => ({ filePath, violation: findArchViolation(filePath, targetArch) }))
|
|
.filter(({ violation }) => violation !== null)
|
|
if (archOffenders.length > 0) {
|
|
const detail = archOffenders
|
|
.map(
|
|
({ filePath, violation }) =>
|
|
` ${relative(rootDir, filePath) || filePath} is ${violation.actual}, expected ` +
|
|
`${violation.expectedArch}${violation.declared ? ' (from its own path)' : ''}`
|
|
)
|
|
.join('\n')
|
|
throw new Error(
|
|
`[verify-linux-glibc-floor] ${archOffenders.length} bundled native binar` +
|
|
`${archOffenders.length === 1 ? 'y is' : 'ies are'} built for the wrong architecture ` +
|
|
`(target ${targetArch}), so the app will fail to load them at runtime:\n${detail}\n` +
|
|
'Cross-building a Linux slice can pack the host architecture despite a forced rebuild; ' +
|
|
'build this slice on a native runner.'
|
|
)
|
|
}
|
|
|
|
const offenders = []
|
|
for (const filePath of binaries) {
|
|
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
|
|
const floorViolations = findFloorViolations(versionNeeds, filePath)
|
|
// Only pay for `objdump -T` when a relocated-symbol provider is not already
|
|
// in DT_NEEDED (the common, healthy case short-circuits without it).
|
|
const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some(
|
|
(library) => !neededLibraries.has(library)
|
|
)
|
|
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
|
|
: []
|
|
if (floorViolations.length > 0 || providerViolations.length > 0) {
|
|
offenders.push({ filePath, floorViolations, providerViolations })
|
|
}
|
|
}
|
|
|
|
if (offenders.length > 0) {
|
|
const detail = offenders
|
|
.map(({ filePath, floorViolations, providerViolations }) => {
|
|
const reasons = []
|
|
if (floorViolations.length > 0) {
|
|
const nodes = [...new Set(floorViolations.map((v) => v.name))].sort()
|
|
const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))]
|
|
reasons.push(
|
|
`needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}`
|
|
)
|
|
}
|
|
for (const { symbol, library } of providerViolations) {
|
|
reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`)
|
|
}
|
|
return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}`
|
|
})
|
|
.join('\n')
|
|
throw new Error(
|
|
`[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` +
|
|
`will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` +
|
|
'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' +
|
|
'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).'
|
|
)
|
|
}
|
|
|
|
console.log(
|
|
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}`
|
|
)
|
|
}
|
|
|
|
module.exports = {
|
|
MIN_GLIBC,
|
|
ELF_MACHINE_BY_ARCH,
|
|
readElfMachine,
|
|
declaredArchFromPath,
|
|
findArchViolation,
|
|
VERSION_FLOORS,
|
|
FLOOR_LABEL,
|
|
RELOCATED_SYMBOL_PROVIDERS,
|
|
parseGlibcVersion,
|
|
compareGlibcVersions,
|
|
parseVersionNeeds,
|
|
parseNeededLibraries,
|
|
parseImportedSymbols,
|
|
isVersionNodeAboveFloor,
|
|
isLibstdcxxNode,
|
|
findFloorViolations,
|
|
findMissingProviderDeps,
|
|
collectNativeBinaries,
|
|
readDynamicInfo,
|
|
readImportedSymbols,
|
|
verifyLinuxGlibcFloor
|
|
}
|