From 8aea64d5f7b460298510fa90f883161569d1d9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Mon, 2 Nov 2026 19:43:23 +0800 Subject: [PATCH] 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 --- packages/desktop/README.md | 6 + .../desktop/scripts/smoke-linux-installers.js | 113 +++++++++++++++++- packages/desktop/scripts/test-release.js | 61 ++++++++++ 3 files changed, 179 insertions(+), 1 deletion(-) diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 186a42f419c..3b2fb6994e3 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -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`. diff --git a/packages/desktop/scripts/smoke-linux-installers.js b/packages/desktop/scripts/smoke-linux-installers.js index 92c2fbc437f..2744870f16a 100755 --- a/packages/desktop/scripts/smoke-linux-installers.js +++ b/packages/desktop/scripts/smoke-linux-installers.js @@ -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 }); } diff --git a/packages/desktop/scripts/test-release.js b/packages/desktop/scripts/test-release.js index 09c8e9c62dc..721e20d94ed 100644 --- a/packages/desktop/scripts/test-release.js +++ b/packages/desktop/scripts/test-release.js @@ -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 = {