| /* | |
| * 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); | |
| }); |