blob: 77129fcad2f9c480ea0f0b7b43e28d2ae2feb48a [file]
/*
* 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)}`);
}
}