blob: c4b602b118176b92ff7d6e61ee2e963bf050dbb4 [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 assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test, { after, before } from 'node:test';
import { desktopReleaseTargets } from './desktop-release-targets.mjs';
import {
mergeDesktopUpdateFeeds,
verifyDesktopUpdateArtifacts,
} from './desktop-update-contract.mjs';
import { assertElfArchitecture, verifyLinuxRelease } from './verify-linux.mjs';
// The two `e_machine` values this project ships, from the ELF specification.
const EM_X86_64 = 0x3e;
const EM_AARCH64 = 0xb7;
let workingDirectory;
before(async () => {
workingDirectory = await mkdtemp(join(tmpdir(), 'maka-linux-harness-'));
});
after(async () => {
await rm(workingDirectory, { recursive: true, force: true });
});
/**
* A 64-byte ELF header is enough: the architecture assertion reads the magic,
* `EI_DATA` and `e_machine`, and nothing else. Building the bytes here rather
* than checking in a binary keeps the test readable and runs it on any host —
* which is the point, since the packaging it guards only runs on Linux.
*/
async function writeElf(name, machine, { data = 1, magic = '\x7fELF' } = {}) {
const header = Buffer.alloc(64);
header.write(magic, 0, 'latin1');
header[4] = 2; // EI_CLASS: ELFCLASS64
header[5] = data; // EI_DATA: 1 little-endian, 2 big-endian
header[6] = 1; // EI_VERSION
header.writeUInt16LE(2, 16); // e_type: ET_EXEC
if (data === 1) header.writeUInt16LE(machine, 18);
else header.writeUInt16BE(machine, 18);
const path = join(workingDirectory, name);
await writeFile(path, header);
return path;
}
test('the architecture assertion accepts a binary built for the target', async () => {
await assertElfArchitecture(await writeElf('x64.node', EM_X86_64), 'x64');
await assertElfArchitecture(await writeElf('arm64.node', EM_AARCH64), 'arm64');
});
test('the architecture assertion rejects the other architecture', async () => {
// The failure this exists for: a runner that cross-built the Runtime Host
// peer produces a package that installs and then dies at launch.
const path = await writeElf('wrong.node', EM_X86_64);
await assert.rejects(
() => assertElfArchitecture(path, 'arm64'),
/is built for ELF machine 0x3e, not arm64/u,
);
});
test('the architecture assertion rejects a file that is not ELF', async () => {
const path = join(workingDirectory, 'not-elf.node');
await writeFile(path, 'this is not a binary at all, it is text\n');
await assert.rejects(() => assertElfArchitecture(path, 'x64'), /is not an ELF binary/u);
});
test('the architecture assertion rejects a truncated header', async () => {
const path = join(workingDirectory, 'truncated.node');
await writeFile(path, Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
await assert.rejects(() => assertElfArchitecture(path, 'x64'), /is not an ELF binary/u);
});
test('the architecture assertion refuses to read a big-endian header', async () => {
// Reading `e_machine` little-endian out of a big-endian file compares
// garbage, so the mismatch has to be named rather than guessed at.
const path = await writeElf('big-endian.node', EM_X86_64, { data: 2 });
await assert.rejects(
() => assertElfArchitecture(path, 'x64'),
/is not a little-endian ELF binary/u,
);
});
const FEED_VERSION = '9.9.9';
/**
* The shape `package:linux` leaves behind: two payloads from two
* electron-builder runs, each run's own single-payload feed, and the merged feed
* `mergeDesktopUpdateFeeds` writes out of the two. The merge is the production
* one, so what is verified below is the merge and the verifier together — a
* hand-written merged document would only ever prove the verifier. The payload
* bytes are written here and the digests taken from them, so a test that drifts
* one field drifts it away from a feed that was otherwise exact.
*/
async function stageLinuxRelease(name, { nightly = false, drift } = {}) {
const target = desktopReleaseTargets(FEED_VERSION, { nightly }).find(
(entry) => entry.name === 'linux-x64',
);
const directory = join(workingDirectory, name);
await mkdir(directory, { recursive: true });
const sourcePaths = [];
for (const payload of target.advertised) {
const bytes = Buffer.from(`${payload} payload bytes\n`);
await writeFile(join(directory, payload), bytes);
// Each electron-builder run rewrites the feed knowing only its own payload;
// `package:linux` moves the first aside under this suffix and merges it back.
const file = {
url: payload,
sha512: createHash('sha512').update(bytes).digest('base64'),
size: bytes.length,
};
const sourcePath = join(directory, `${target.feed}.${sourcePaths.length}`);
await writeFile(
sourcePath,
feedDocument({
version: FEED_VERSION,
files: [file],
path: file.url,
sha512: file.sha512,
releaseDate: '2026-01-01T00:00:00.000Z',
}),
'utf8',
);
sourcePaths.push(sourcePath);
}
const outputPath = join(directory, target.feed);
const merged = await mergeDesktopUpdateFeeds({ sourcePaths, outputPath });
await Promise.all(sourcePaths.map((path) => rm(path)));
// Drift is applied to what the merge produced, so each rejection below names a
// field of a real merged document rather than of a fabricated one.
if (drift) {
await writeFile(outputPath, feedDocument(drift(merged)), 'utf8');
}
return { directory, target };
}
/** electron-builder's feed layout, written by hand so this suite stays on node builtins. */
function feedDocument(feed) {
const lines = [`version: ${feed.version}`, 'files:'];
for (const file of feed.files) {
lines.push(` - url: ${file.url}`, ` sha512: ${file.sha512}`, ` size: ${file.size}`);
}
lines.push(`path: ${feed.path}`, `sha512: ${feed.sha512}`, `releaseDate: '${feed.releaseDate}'`);
return `${lines.join('\n')}\n`;
}
function verifyStagedFeed({ directory, target }) {
return verifyDesktopUpdateArtifacts({
directory,
metadataName: target.feed,
version: FEED_VERSION,
artifactNames: target.advertised,
});
}
test('the merged Linux feed offers both distributables', async () => {
// The AppImage and the deb are built by separate electron-builder runs, the
// second of which overwrites the first's feed; only the merge puts both in
// one document, and until this ran nothing opened the merged bytes before
// publication.
const staged = await stageLinuxRelease('merged');
const { metadata } = await verifyStagedFeed(staged);
assert.deepEqual(
metadata.files.map((file) => file.url).sort(),
[...staged.target.advertised].sort(),
);
});
test('the Nightly merged feed is read under its own name', async () => {
// The Nightly publishes the same two payloads through `dev-linux.yml`, so a
// verifier that only ever looked for `latest-linux.yml` would check nothing
// there.
const staged = await stageLinuxRelease('merged-nightly', { nightly: true });
assert.equal(staged.target.feed, 'dev-linux.yml');
await verifyStagedFeed(staged);
});
test('a merged feed that lost a payload is rejected', async () => {
// The exact damage the merge can do: the deb's own feed contributes nothing
// and the AppImage's document survives alone, offering deb users no update.
const staged = await stageLinuxRelease('dropped-payload', {
drift: (feed) => ({ ...feed, files: feed.files.slice(0, 1) }),
});
await assert.rejects(() => verifyStagedFeed(staged), /advertises \[/u);
});
test('a merged feed whose digest belongs to another payload is rejected', async () => {
// `path` and the top-level `sha512` are the primary payload's identity; the
// merge keeps the first document's, so pointing `path` at the other file
// leaves an updater verifying the deb against the AppImage's digest.
const staged = await stageLinuxRelease('mismatched-primary', {
drift: (feed) => ({ ...feed, path: feed.files[1].url }),
});
await assert.rejects(() => verifyStagedFeed(staged), /inconsistent payload identity/u);
});
test('a merged feed carrying a stale digest is rejected', async () => {
const staged = await stageLinuxRelease('stale-digest', {
drift: (feed) => ({
...feed,
files: [feed.files[0], { ...feed.files[1], sha512: createHash('sha512').digest('base64') }],
}),
});
await assert.rejects(() => verifyStagedFeed(staged), /sha512 does not match/u);
});
test('a merged feed carrying a stale size is rejected', async () => {
const staged = await stageLinuxRelease('stale-size', {
drift: (feed) => ({
...feed,
files: [feed.files[0], { ...feed.files[1], size: feed.files[1].size + 1 }],
}),
});
await assert.rejects(() => verifyStagedFeed(staged), /records .* size/u);
});
test('Linux verification refuses to run anywhere else', async () => {
// The distributables only exist on the runner that built them, so running
// this elsewhere would otherwise fail later and less clearly.
await assert.rejects(() => verifyLinuxRelease('x64', { platform: 'darwin' }), /requires Linux/u);
});