diff --git a/fluxer_desktop/electron-builder.config.cjs b/fluxer_desktop/electron-builder.config.cjs index eb69f6f2e..e0c431d6f 100644 --- a/fluxer_desktop/electron-builder.config.cjs +++ b/fluxer_desktop/electron-builder.config.cjs @@ -14,6 +14,7 @@ const packageName = isCanary ? 'fluxer_desktop_canary' : 'fluxer_desktop'; const linuxPackageName = isCanary ? 'fluxer-canary' : 'fluxer'; const linuxDesktopActionIds = ['open-settings', 'new-dm']; const linuxDesktopActionList = `${linuxDesktopActionIds.join(';')};`; +const linuxGlibcBaseline = Object.freeze({major: 2, minor: 35, patch: 0, name: 'GLIBC_2.35'}); const rpmBuildIdFilePrefix = '/usr/lib/.build-id'; const rpmBuildIdLinkFpmArgs = [ '--rpm-rpmbuild-define', @@ -814,11 +815,165 @@ async function addLinuxLegacyBinarySymlink(context) { } } +async function isElfFile(filePath) { + const handle = await fs.open(filePath, 'r'); + try { + const magic = Buffer.alloc(4); + const {bytesRead} = await handle.read(magic, 0, magic.length, 0); + return bytesRead === magic.length && magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])); + } finally { + await handle.close(); + } +} + +async function findPackagedElfFiles(rootDir) { + const results = []; + async function visit(directory) { + const entries = await fs.readdir(directory, {withFileTypes: true}); + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await visit(entryPath); + } else if (entry.isFile() && (await isElfFile(entryPath))) { + results.push(entryPath); + } + } + } + await visit(rootDir); + return results.sort(); +} + +function compareGlibcVersions(left, right) { + for (const key of ['major', 'minor', 'patch']) { + if (left[key] !== right[key]) return left[key] - right[key]; + } + return 0; +} + +async function inspectElfGlibcRequirements(elfPath) { + let stdout; + try { + ({stdout} = await execFileAsync('readelf', ['--version-info', '--dynamic', '--wide', elfPath], { + env: {...process.env, LC_ALL: 'C'}, + maxBuffer: 16 * 1024 * 1024, + })); + } catch (error) { + if (error && error.code === 'ENOENT') { + throw new Error(`Cannot verify Linux glibc compatibility: readelf executable is not available.`); + } + const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : ''; + throw new Error(`Cannot inspect ELF file ${elfPath}: ${stderr || error?.message || String(error)}`); + } + const versions = new Map(); + const unsupportedRequirements = new Set(); + const hasGlibcRequirement = /\bGLIBC_[A-Za-z0-9_.]+\b/.test(stdout); + let readingVersionNeeds = false; + let foundVersionNeeds = false; + let hasVersionNeedsTag = false; + let needsGlibc = false; + let usesDtRelr = false; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (/\(VERNEED\)/.test(trimmed)) { + hasVersionNeedsTag = true; + } + if (/^0x0*(?:23|24|25)\s+\(/i.test(trimmed) || /\((?:RELR|RELRSZ|RELRENT)\)/.test(trimmed)) { + usesDtRelr = true; + } + if (/\(NEEDED\)/.test(trimmed)) { + const neededMatch = /\(NEEDED\)\s+Shared library: \[([^\]]+)\]/.exec(trimmed); + if (!neededMatch) { + throw new Error(`Cannot parse a required library for ELF file ${elfPath}: ${trimmed}`); + } + if (neededMatch[1] === 'libc.so.6') needsGlibc = true; + } + if (trimmed.startsWith('Version needs section ')) { + readingVersionNeeds = true; + foundVersionNeeds = true; + continue; + } + if (trimmed.startsWith('Version symbols section ') || trimmed.startsWith('Version definition section ')) { + readingVersionNeeds = false; + continue; + } + if (!readingVersionNeeds) continue; + const requirement = /\bName:\s+(\S+)/.exec(trimmed)?.[1]; + if (!requirement?.startsWith('GLIBC_')) continue; + const match = /^GLIBC_(\d+)\.(\d+)(?:\.(\d+))?$/.exec(requirement); + if (!match) { + unsupportedRequirements.add(requirement); + continue; + } + const version = { + major: Number.parseInt(match[1], 10), + minor: Number.parseInt(match[2], 10), + patch: Number.parseInt(match[3] ?? '0', 10), + name: requirement, + }; + if (![version.major, version.minor, version.patch].every(Number.isSafeInteger)) { + throw new Error(`readelf returned an invalid glibc version for ${elfPath}: ${requirement}`); + } + versions.set(version.name, version); + } + if ((hasGlibcRequirement || hasVersionNeedsTag) && !foundVersionNeeds) { + throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf omitted version needs.`); + } + if (needsGlibc && (!foundVersionNeeds || (versions.size === 0 && unsupportedRequirements.size === 0))) { + throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf returned no version needs.`); + } + return { + versions: Array.from(versions.values()), + unsupportedRequirements: Array.from(unsupportedRequirements).sort(), + usesDtRelr, + }; +} + +async function findLinuxGlibcCompatibilityViolations(elfFiles) { + const violations = []; + for (const elfPath of elfFiles) { + const {versions, unsupportedRequirements, usesDtRelr} = await inspectElfGlibcRequirements(elfPath); + const maximum = versions.sort(compareGlibcVersions).at(-1); + const requirements = [...unsupportedRequirements]; + if (maximum && compareGlibcVersions(maximum, linuxGlibcBaseline) > 0) { + requirements.push(maximum.name); + } + if (usesDtRelr) requirements.push('DT_RELR (glibc 2.36+)'); + if (requirements.length > 0) violations.push({elfPath, requirements}); + } + return violations; +} + +function throwLinuxGlibcCompatibilityError(violations, formatPath) { + if (violations.length === 0) return; + const lines = [ + `Linux package exceeds the supported ${linuxGlibcBaseline.name} ABI baseline.`, + 'Build Linux artifacts on Ubuntu 22.04 and keep every shipped ELF at or below that glibc requirement.', + ]; + for (const {elfPath, requirements} of violations) { + lines.push(` - ${formatPath(elfPath)} requires ${requirements.join(', ')}`); + } + throw new Error(lines.join('\n')); +} + +async function verifyLinuxGlibcCompatibility(context) { + if (context.electronPlatformName !== 'linux') return; + const elfFiles = await findPackagedElfFiles(context.appOutDir); + if (elfFiles.length === 0) { + throw new Error(`Linux package output contains no ELF files: ${context.appOutDir}`); + } + const violations = await findLinuxGlibcCompatibilityViolations(elfFiles); + throwLinuxGlibcCompatibilityError(violations, (elfPath) => path.relative(context.appOutDir, elfPath)); + console.log( + `Verified ${elfFiles.length} packaged Linux ELF files against the ${linuxGlibcBaseline.name} ABI baseline.`, + ); +} + async function afterPack(context) { await copyMissingPackagedNativeArtifacts(context); await cleanupNativeBuildIntermediates(context); await addLinuxLegacyBinarySymlink(context); await verifyPackagedNativeArtifacts(context); + await verifyLinuxGlibcCompatibility(context); } async function listRpmPackageFiles(artifactPath) { @@ -922,12 +1077,19 @@ async function verifyLinuxPackagesContainAppArmorProfile(buildResult) { async function readElfNeededLibraries(artifactPath) { try { const {stdout} = await execFileAsync('readelf', ['-d', artifactPath], { + env: {...process.env, LC_ALL: 'C'}, maxBuffer: 8 * 1024 * 1024, }); - return stdout - .split(/\r?\n/) - .map((line) => line.match(/\(NEEDED\)\s+Shared library: \[([^\]]+)\]/)?.[1]) - .filter(Boolean); + const neededLibraries = []; + for (const line of stdout.split(/\r?\n/)) { + if (!/\(NEEDED\)/.test(line)) continue; + const match = /\(NEEDED\).*\[([^\]]+)\]/.exec(line); + if (!match) { + throw new Error(`Cannot parse a required library for AppImage artifact ${artifactPath}: ${line.trim()}`); + } + neededLibraries.push(match[1]); + } + return neededLibraries; } catch (error) { if (error && error.code === 'ENOENT') { throw new Error(`Cannot inspect AppImage artifact ${artifactPath}: readelf executable is not available.`); @@ -937,6 +1099,14 @@ async function readElfNeededLibraries(artifactPath) { } } +async function verifyAppImageArtifactsGlibcCompatibility(buildResult) { + const appImageArtifacts = (buildResult.artifactPaths ?? []).filter( + (artifactPath) => path.extname(artifactPath) === '.AppImage', + ); + const violations = await findLinuxGlibcCompatibilityViolations(appImageArtifacts); + throwLinuxGlibcCompatibilityError(violations, (artifactPath) => path.basename(artifactPath)); +} + async function verifyAppImageArtifactsDoNotNeedFuse2(buildResult) { const appImageArtifacts = (buildResult.artifactPaths ?? []).filter( (artifactPath) => path.extname(artifactPath) === '.AppImage', @@ -1072,6 +1242,7 @@ async function verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult) { async function verifyLinuxArtifactContracts(buildResult) { await verifyRpmArtifactsDoNotOwnBuildIds(buildResult); await verifyLinuxPackagesContainAppArmorProfile(buildResult); + await verifyAppImageArtifactsGlibcCompatibility(buildResult); await verifyAppImageArtifactsDoNotNeedFuse2(buildResult); await verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult); } diff --git a/tools/ci/src/desktop.rs b/tools/ci/src/desktop.rs index b0ce02c6d..1b235a1ac 100644 --- a/tools/ci/src/desktop.rs +++ b/tools/ci/src/desktop.rs @@ -143,14 +143,14 @@ const PLATFORMS: &[Platform] = &[ platform: "linux", arch: "x64", desktop_variant: DEFAULT_DESKTOP_VARIANT, - os: "ubuntu-24.04", + os: "ubuntu-22.04", electron_arch: "x64", }, Platform { platform: "linux", arch: "arm64", desktop_variant: DEFAULT_DESKTOP_VARIANT, - os: "ubuntu-24.04-arm", + os: "ubuntu-22.04-arm", electron_arch: "arm64", }, ]; @@ -3588,8 +3588,8 @@ mod tests { selected, vec![ "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-24.04\",\"electron_arch\":\"x64\"}", - "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-24.04-arm\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", + "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", ] ); } @@ -3628,8 +3628,8 @@ mod tests { selected, vec![ "{\"platform\":\"windows\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"windows-2025\",\"electron_arch\":\"arm64\"}", - "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-24.04\",\"electron_arch\":\"x64\"}", - "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-24.04-arm\",\"electron_arch\":\"arm64\"}", + "{\"platform\":\"linux\",\"arch\":\"x64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04\",\"electron_arch\":\"x64\"}", + "{\"platform\":\"linux\",\"arch\":\"arm64\",\"desktop_variant\":\"default\",\"os\":\"ubuntu-22.04-arm\",\"electron_arch\":\"arm64\"}", ] ); }