mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
1 parent
84c4c70789
commit
8aea64d5f7
3 files changed
+179
-1
No files matched your search
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user