| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { access, chmod, mkdtemp, open, readdir, readFile, rm, writeFile } from 'node:fs/promises'; |
| import { tmpdir } from 'node:os'; |
| import { basename, dirname, join, resolve } from 'node:path'; |
| import { fileURLToPath, pathToFileURL } from 'node:url'; |
| import { resolveDesktopReleaseTarget } from './desktop-nightly.mjs'; |
| import { |
| assertPackagedUpdateConfiguration, |
| verifyDesktopUpdateArtifacts, |
| } from './desktop-update-contract.mjs'; |
| import { |
| assertMissing, |
| assertPackagedDependencyClosure, |
| assertPackagedResources, |
| runCommand, |
| sha256File, |
| smokePackagedRenderer, |
| } from './verify-packaged-app.mjs'; |
| |
| const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); |
| |
| /** |
| * `e_machine`, at offset 18 of every ELF header. This is the one field that |
| * says which processor the file was built for. |
| */ |
| const ELF_MACHINES = Object.freeze({ x64: 0x3e, arm64: 0xb7 }); |
| |
| /** |
| * Debian policy allows only lowercase letters, digits and `-+.` in a package |
| * name, and `dpkg` refuses to install one that breaks the rule. The name is |
| * derived from the product name rather than configured, so nothing else in |
| * this repository would notice a capital letter reaching it. |
| */ |
| const DEBIAN_PACKAGE_NAME = /^[a-z0-9][a-z0-9+.-]+$/u; |
| |
| function runCommandFromRepo(command, args, options = {}) { |
| return runCommand(command, args, { cwd: repoRoot, ...options }); |
| } |
| |
| /** |
| * The packaging scripts refuse to cross-build because the Runtime Host peer is |
| * a host binary, and a runner that shipped the other architecture's peer would |
| * produce a package that installs and then fails at launch. Only reading the |
| * ELF header proves which one is actually inside. |
| */ |
| export async function assertElfArchitecture(path, arch) { |
| const handle = await open(path); |
| let header; |
| try { |
| header = Buffer.alloc(20); |
| const { bytesRead } = await handle.read(header, 0, 20, 0); |
| if (bytesRead < 20 || header.toString('latin1', 0, 4) !== '\x7fELF') { |
| throw new Error(`${basename(path)} is not an ELF binary`); |
| } |
| } finally { |
| await handle.close(); |
| } |
| // EI_DATA: every architecture this project builds is little-endian, and |
| // reading `e_machine` the wrong way round would silently compare garbage. |
| if (header[5] !== 1) { |
| throw new Error(`${basename(path)} is not a little-endian ELF binary`); |
| } |
| const machine = header.readUInt16LE(18); |
| if (machine !== ELF_MACHINES[arch]) { |
| throw new Error( |
| `${basename(path)} is built for ELF machine 0x${machine.toString(16)}, not ${arch}`, |
| ); |
| } |
| } |
| |
| /** |
| * Where fpm placed the application is discovered from the extracted tree rather |
| * than derived from electron-builder's install prefix and product name. Deriving |
| * what a payload should contain, instead of reading what it does, is exactly how |
| * this verifier came to hand a checksum to a file it had never opened. Exactly |
| * one match is required: `find` would silently pick either of two. |
| */ |
| async function debResourcesDirectory(root) { |
| const suffix = join('resources', 'app.asar'); |
| const entries = await readdir(root, { recursive: true }); |
| const matches = entries.filter((entry) => entry.endsWith(suffix)); |
| if (matches.length !== 1) { |
| throw new Error(`The deb contains ${matches.length} resources/app.asar entries, expected 1`); |
| } |
| return join(root, dirname(matches[0])); |
| } |
| |
| /** |
| * Both Linux distributables are verified here, and each is opened. They are |
| * built by two separate electron-builder runs — the split is what keeps the |
| * deb's `package-type` marker out of the AppImage — so nothing proven about one |
| * carries over to the other. `--appimage-extract` is handled by the AppImage |
| * runtime itself and needs no FUSE mount; `dpkg-deb` ships with the runner. |
| * |
| * The renderer smoke test needs a display, so the caller runs this whole script |
| * under `xvfb-run`. It is applied to the AppImage only: extracting it produces |
| * the same tree its runtime mounts at launch, so running that tree is running |
| * the artifact. A deb extracted with `dpkg-deb -x` is not an installation — |
| * `dpkg` would still have to set the sandbox helper's setuid bit — so launching |
| * it would prove something about a tree no user ever has. |
| */ |
| export async function verifyLinuxRelease( |
| arch, |
| { |
| platform = process.platform, |
| run = runCommandFromRepo, |
| requirePath = access, |
| forbidPath = assertMissing, |
| environment = process.env, |
| checksum = sha256File, |
| smokeRenderer = smokePackagedRenderer, |
| assertArchitecture = assertElfArchitecture, |
| } = {}, |
| ) { |
| if (platform !== 'linux') { |
| throw new Error('Linux release verification requires Linux.'); |
| } |
| |
| // The AppImage and the deb never share a spelling of the architecture, so the |
| // target descriptor is the only place that knows both names. |
| const target = await resolveDesktopReleaseTarget(`linux-${arch}`, { environment }); |
| const appImagePath = resolve(target.payloadPath('.AppImage')); |
| const debPath = resolve(target.payloadPath('.deb')); |
| await access(appImagePath); |
| await access(debPath); |
| // The descriptor already resolved and validated the channel when it resolved |
| // the version; re-reading the environment here would let an unvalidated value |
| // name one channel while the artifacts it just named came from the other. |
| const channel = target.nightly ? 'nightly' : 'release'; |
| const workingDirectory = await mkdtemp(join(tmpdir(), 'maka-release-verify-')); |
| const peerBinary = join('runtime-host-peer', 'maka_runtime_host_peer.node'); |
| |
| try { |
| await chmod(appImagePath, 0o755); |
| await run(appImagePath, ['--appimage-extract'], { cwd: workingDirectory }); |
| const squashfsRoot = join(workingDirectory, 'squashfs-root'); |
| const appImageResources = join(squashfsRoot, 'resources'); |
| |
| await assertPackagedResources(appImageResources, { requirePath, forbidPath }); |
| // The deb target writes this marker into the shared unpacked tree, and it is |
| // what electron-updater reads to pick DebUpdater over AppImageUpdater. An |
| // AppImage carrying it would try to update itself by installing a deb. |
| await forbidPath(join(appImageResources, 'package-type')); |
| await assertPackagedUpdateConfiguration(appImageResources, { channel }); |
| await assertPackagedDependencyClosure(appImageResources); |
| await assertArchitecture(join(appImageResources, peerBinary), arch); |
| |
| const debRoot = join(workingDirectory, 'deb'); |
| await run('dpkg-deb', ['-x', debPath, debRoot]); |
| const debResources = await debResourcesDirectory(debRoot); |
| |
| await assertPackagedResources(debResources, { requirePath, forbidPath }); |
| await assertPackagedUpdateConfiguration(debResources, { channel }); |
| await assertPackagedDependencyClosure(debResources); |
| await assertArchitecture(join(debResources, peerBinary), arch); |
| // The mirror of the AppImage assertion above. This marker is what sends the |
| // packaged updater down DebUpdater, and the deb is the one payload that has |
| // to carry it: without it an installed deb would try to update itself by |
| // replacing an AppImage that is not there. |
| const packageType = (await readFile(join(debResources, 'package-type'), 'utf8')).trim(); |
| if (packageType !== 'deb') { |
| throw new Error(`The deb declares package-type ${packageType || '(empty)'}`); |
| } |
| // electron-builder builds this name out of the product name, so nothing |
| // else here would catch a capital letter reaching `dpkg`, which rejects it. |
| const { stdout: declaredName } = await run('dpkg-deb', ['-f', debPath, 'Package']); |
| if (!DEBIAN_PACKAGE_NAME.test(declaredName.trim())) { |
| throw new Error(`The deb declares an uninstallable package name: ${declaredName.trim()}`); |
| } |
| // fpm records the architecture it was told to build; the descriptor names |
| // the file after the architecture it asked for. A runner that produced the |
| // wrong one would otherwise publish it under the right name. |
| const namedArchitecture = /-([^-]+)\.deb$/u.exec(basename(debPath))?.[1]; |
| const { stdout } = await run('dpkg-deb', ['-f', debPath, 'Architecture']); |
| if (stdout.trim() !== namedArchitecture) { |
| throw new Error(`${basename(debPath)} contains architecture ${stdout.trim() || '(none)'}`); |
| } |
| |
| // Every assertion above reads files. This one runs the application, the way |
| // the macOS and Windows verifications already do, and is the only thing here |
| // that can fail on a package that is structurally perfect and still cannot |
| // start — a missing shared library, or a sandbox the host will not grant. |
| await requirePath(join(squashfsRoot, 'AppRun')); |
| await smokeRenderer(join(squashfsRoot, 'AppRun'), { workingDirectory }); |
| |
| // Linux is the one platform whose update feed this repository assembles |
| // itself: `package:linux` runs electron-builder twice and merges the two |
| // feeds, so the merged bytes are the only ones no build step ever wrote as |
| // a whole. Reading them here, against the payloads just verified, is what |
| // keeps a dropped entry or a stale digest from surviving until publication. |
| await verifyDesktopUpdateArtifacts({ |
| directory: target.releaseDirectory, |
| metadataName: target.feed, |
| version: target.version, |
| artifactNames: target.advertised, |
| }); |
| |
| // A formal release publishes a checksum beside each distributable, the way |
| // the Windows verification does for its installer and archive. Each one is |
| // issued only for a payload every assertion above has already accepted. |
| const checksums = []; |
| for (const path of target.checksumPaths()) { |
| const sha256 = await checksum(path); |
| const checksumPath = `${path}.sha256`; |
| await writeFile(checksumPath, `${sha256} ${basename(path)}\n`, 'utf8'); |
| checksums.push({ path, checksumPath, sha256 }); |
| } |
| |
| return { appImagePath, debPath, checksums }; |
| } finally { |
| await rm(workingDirectory, { recursive: true, force: true }); |
| } |
| } |
| |
| if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| const result = await verifyLinuxRelease(process.argv[2] ?? process.arch); |
| console.log(`Verified ${result.appImagePath}`); |
| console.log(`Verified ${result.debPath}`); |
| for (const { path, sha256 } of result.checksums) { |
| console.log(`SHA-256 ${sha256} ${basename(path)}`); |
| } |
| } |