fix(desktop): verify AppImage library closure (#16062)

Read every ELF's DT_NEEDED entries from the extracted AppImage and fail release when a dependency is neither bundled nor part of AppImage's host-provided baseline. Document the contract and cover the failing and repaired cases.

Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
易良andQwen-Coder authored and GitHub committed 2026-11-02 19:43:23 +08:00
1 parent 84c4c70789
commit 8aea64d5f7
3 files changed
+179 -1

No files matched your search

+6
View File
@@ -70,4 +70,10 @@ The `Desktop Release` workflow builds signed updater artifacts when `dry_run` is
The first stable Tauri release may set `electron_bridge=true` to publish the macOS ZIPs and DMGs, Windows NSIS installer, x64 Linux AppImage, and their Electron `0.0.5` manifests. Leave the input disabled for later releases; the fixed `desktop-latest` release retains the bridge assets while `desktop-latest.json` advances independently.
### Linux AppImage dependencies
The AppImage bundles WebKitGTK and its application libraries, but it deliberately relies on the host for the baseline libraries in the [AppImage excludelist](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist): glibc and the C++ runtime, the OpenGL/DRM drivers, X11/xcb, fontconfig/freetype/harfbuzz, zlib, and their low-level dependencies. Bundling those copies breaks against the host's graphics drivers or ABI. Install the distribution packages for the GTK/WebKit stack when using the AppImage.
The release smoke test extracts the AppImage, reads every ELF file's `DT_NEEDED` entries, and fails on any dependency that is neither inside the image nor on that host baseline list. A newly missing application library therefore blocks publication instead of surfacing as a runtime failure.
The macOS workflow accepts either the Tauri-era `APPLE_*` certificate and notarization secrets or the existing `MAC_CSC_*` and `APPLE_NOTARY_*` secrets. `TAURI_SIGNING_PRIVATE_KEY` must match the public key in `src-tauri/tauri.conf.json`.
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -11,6 +11,66 @@ import {
verifyRuntimeIntegrity,
} from './runtime-smoke-checks.js';
// AppImage's excludelist: these are supplied by the AppImage runtime or the
// host's graphics/C++ runtime. Bundling them conflicts with the host drivers
// or its glibc and C++ ABI. Keep this list in sync with the upstream file.
// https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist
const APPIMAGE_HOST_LIBRARIES = new Set([
'ld-linux-aarch64.so.1',
'ld-linux-x86-64.so.2',
'ld-linux.so.2',
'libBrokenLocale.so.1',
'libEGL.so.1',
'libGL.so.1',
'libGLdispatch.so.0',
'libGLX.so.0',
'libICE.so.6',
'libOpenGL.so.0',
'libSM.so.6',
'libX11-xcb.so.1',
'libX11.so.6',
'libanl.so.1',
'libasound.so.2',
'libc.so.6',
'libcidn.so.1',
'libcom_err.so.2',
'libdl.so.2',
'libdrm.so.2',
'libexpat.so.1',
'libfontconfig.so.1',
'libfreetype.so.6',
'libfribidi.so.0',
'libgbm.so.1',
'libgcc_s.so.1',
'libglapi.so.0',
'libgmp.so.10',
'libgpg-error.so.0',
'libharfbuzz.so.0',
'libjack.so.0',
'libm.so.6',
'libmvec.so.1',
'libnss_compat.so.2',
'libnss_dns.so.2',
'libnss_files.so.2',
'libnss_hesiod.so.2',
'libnss_nis.so.2',
'libnss_nisplus.so.2',
'libpipewire-0.3.so.0',
'libpthread.so.0',
'libresolv.so.2',
'librt.so.1',
'libstdc++.so.6',
'libthread_db.so.1',
'libusb-1.0.so.0',
'libutil.so.1',
'libuuid.so.1',
'libwayland-client.so.0',
'libxcb.so.1',
'libxcb-dri2.so.0',
'libxcb-dri3.so.0',
'libz.so.1',
]);
export function findLinuxInstallers(bundleRoot) {
const files = walkFiles(bundleRoot);
return {
@@ -56,6 +116,56 @@ export function verifyExtractedRuntime(extractedRoot) {
verifyBundledNode(runtimeRoot);
}
export function verifyAppImageClosure(extractedRoot) {
const files = walkFiles(extractedRoot);
const bundled = new Set(files.map((file) => path.basename(file)));
const elfFiles = files.filter(isElf);
if (elfFiles.length === 0) {
throw new Error('AppImage contains no ELF files');
}
const missing = new Set();
for (const elf of elfFiles) {
const result = spawnSync('readelf', ['-d', elf], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C' },
});
if (result.error || result.status !== 0) {
throw new Error(
`Failed to read dynamic dependencies for ${path.relative(extractedRoot, elf)}: ${result.error?.message ?? result.stderr}`,
);
}
for (const [, soname] of result.stdout.matchAll(
/\(NEEDED\).*\[([^\]]+)\]/g,
)) {
if (!bundled.has(soname) && !APPIMAGE_HOST_LIBRARIES.has(soname)) {
missing.add(soname);
}
}
}
if (missing.size > 0) {
throw new Error(
`AppImage dependency closure is incomplete: ${[...missing].sort().join(', ')}`,
);
}
}
function isElf(file) {
try {
const buffer = Buffer.alloc(4);
const fd = fs.openSync(file, 'r');
try {
return (
fs.readSync(fd, buffer, 0, 4, 0) === 4 &&
buffer.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
);
} finally {
fs.closeSync(fd);
}
} catch {
return false;
}
}
function smokeInstaller(installer, type) {
const extractedRoot = fs.mkdtempSync(
path.join(os.tmpdir(), `qwen-desktop-${type}-smoke-`),
@@ -73,6 +183,7 @@ function smokeInstaller(installer, type) {
});
}
verifyExtractedRuntime(extractedRoot);
if (type === 'appimage') verifyAppImageClosure(extractedRoot);
} finally {
fs.rmSync(extractedRoot, { recursive: true, force: true });
}
+61
View File
@@ -16,6 +16,7 @@ import {
import {
findLinuxInstallers,
findRuntimeRoot,
verifyAppImageClosure,
verifyExtractedRuntime,
} from './smoke-linux-installers.js';
@@ -61,6 +62,10 @@ try {
testDesktopReleaseHardening();
testImmutableRuntimePatchelf(path.join(root, 'patchelf'), canSpawnShebang);
testLinuxInstallerSmokeDiscovery(path.join(root, 'linux-installers'));
testAppImageDependencyClosure(
path.join(root, 'appimage-closure'),
canSpawnShebang,
);
testRuntimeSmokeChecks(path.join(root, 'runtime-smoke'), canSpawnShebang);
testRuntimeTargetMappings();
testUpdaterMirrorConfiguration();
@@ -845,6 +850,62 @@ function testLinuxInstallerSmokeDiscovery(directory) {
);
}
function testAppImageDependencyClosure(directory, canSpawnShebang) {
if (!canSpawnShebang) {
console.log('SKIP AppImage dependency closure: shebang unavailable');
return;
}
const extractedRoot = path.join(directory, 'extracted');
const binaryDirectory = path.join(directory, 'bin');
const fakeReadelf = path.join(binaryDirectory, 'readelf');
fs.mkdirSync(path.join(extractedRoot, 'usr', 'lib'), { recursive: true });
fs.mkdirSync(binaryDirectory, { recursive: true });
const writeElf = (relative) => {
const file = path.join(extractedRoot, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.rmSync(file, { force: true });
fs.writeFileSync(file, Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
return file;
};
const executable = writeElf('usr/bin/qwen-code-desktop');
const bundledLibrary = writeElf('usr/lib/libbundled.so');
fs.writeFileSync(path.join(extractedRoot, 'README'), 'not an ELF');
fs.writeFileSync(
fakeReadelf,
[
'#!/usr/bin/env sh',
'case "$2" in',
' *qwen-code-desktop)',
" printf ' (NEEDED) Shared library: [libbundled.so]\\\\n'",
" printf ' (NEEDED) Shared library: [libc.so.6]\\\\n'",
' ;;',
' *libbundled.so)',
" printf ' (NEEDED) Shared library: [libmystery.so.1]\\\\n'",
' ;;',
'esac',
'',
].join('\n'),
);
fs.chmodSync(fakeReadelf, 0o755);
const previousPath = process.env.PATH;
process.env.PATH = `${binaryDirectory}:${previousPath}`;
try {
assert.equal(fs.existsSync(executable), true);
assert.equal(fs.existsSync(bundledLibrary), true);
assert.throws(
() => verifyAppImageClosure(extractedRoot),
/AppImage dependency closure is incomplete: libmystery\.so\.1/,
);
fs.writeFileSync(
path.join(extractedRoot, 'usr', 'lib', 'libmystery.so.1'),
'',
);
verifyAppImageClosure(extractedRoot);
} finally {
process.env.PATH = previousPath;
}
}
function testRuntimeSmokeChecks(directory, canSpawnShebang) {
const runtimeRoot = path.join(directory, 'runtime', 'qwen-code');
const manifest = {