blob: 7747e89e79412c4cd919d8c8e6a9d53320d4410b [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 { spawn } from 'node:child_process';
import { EventEmitter, once } from 'node:events';
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, relative } from 'node:path';
import { after, describe, test } from 'node:test';
import { crc32, deflateSync } from 'node:zlib';
import { createPackage } from '@electron/asar';
import {
FileMatcher,
getMainFileMatchers,
getNodeModuleFileMatcher,
} from 'app-builder-lib/out/fileMatcher.js';
import { NodeModuleCopyHelper } from 'app-builder-lib/out/util/NodeModuleCopyHelper.js';
import { computeFileSets } from 'app-builder-lib/out/util/appFileCopier.js';
import { doMergeConfigs } from 'app-builder-lib/out/util/config/config.js';
import { resolveDesktopBuilderConfig } from '../apps/desktop/electron-builder.config.mjs';
import {
asarLookupPath,
assertPackagedDependencyClosure,
assertPackagedResources,
runCommand,
stopChild,
} from './verify-packaged-app.mjs';
const { version: productVersion } = JSON.parse(
await readFile(new URL('../package.json', import.meta.url), 'utf8'),
);
test('runCommand preserves the exit code and both output streams on failure', async () => {
await assert.rejects(
runCommand(process.execPath, [
'-e',
"require('node:fs').writeSync(1, 'CloudKit connection lost'); require('node:fs').writeSync(2, 'stderr detail'); process.exit(68)",
]),
(error) => {
assert.equal(error.code, 68);
assert.equal(error.stdout, 'CloudKit connection lost');
assert.equal(error.stderr, 'stderr detail');
assert.match(error.message, /CloudKit connection lost/u);
assert.match(error.message, /stderr detail/u);
return true;
},
);
});
test('stopChild waits for a process that ignores SIGTERM to exit before returning', {
skip: process.platform === 'win32',
}, async (t) => {
const child = spawn(
process.execPath,
[
'-e',
`
process.on('SIGTERM', () => {});
process.send('ready');
setInterval(() => {}, 1000);
`,
],
{ stdio: ['ignore', 'ignore', 'ignore', 'ipc'] },
);
t.after(async () => {
if (child.exitCode !== null || child.signalCode !== null) return;
const exited = once(child, 'exit');
child.kill('SIGKILL');
await exited;
});
await once(child, 'message');
await stopChild(child);
assert.equal(child.signalCode, 'SIGKILL');
assert.equal(child.exitCode, null);
// A child already terminated by a signal is also stopped, even though its
// numeric exitCode is null; stopping it again must not wait for another exit.
await stopChild(child);
});
test('stopChild reports the pid when a child never exits after SIGKILL', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const child = new EventEmitter();
child.pid = 12345;
child.exitCode = null;
child.signalCode = null;
const signals = [];
child.kill = (signal) => {
signals.push(signal);
return true;
};
let outcome;
const stopped = stopChild(child).then(
() => {
outcome = 'resolved';
},
(error) => {
outcome = error;
},
);
assert.deepEqual(signals, ['SIGTERM']);
t.mock.timers.tick(5_000);
await new Promise(setImmediate);
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
t.mock.timers.tick(9_999);
await new Promise(setImmediate);
assert.equal(outcome, undefined, 'wait for exit throughout the SIGKILL grace period');
t.mock.timers.tick(1);
await new Promise(setImmediate);
assert.ok(outcome instanceof Error, 'an unresponsive child must reject within 10 seconds');
assert.match(outcome.message, /12345.*10_?000ms.*SIGKILL/);
assert.equal(child.listenerCount('exit'), 0);
await stopped;
});
test('Windows file rules keep test code and renderer side-files out of the app', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-app-package-'));
t.after(() => rm(root, { recursive: true, force: true }));
const runtimeFiles = [
'package.json',
'dist/main/index.js',
'dist-renderer/index.html',
'dist/renderer/computer-use-overlay/index.js',
];
for (const name of [
...runtimeFiles,
'dist/main/__tests__/about.test.js',
'dist/main/test-only/bootstrap.js',
'dist/renderer/agent-graph-panel.js',
'scripts/plugins/codex-app-server-executor/index.mjs',
]) {
const path = join(root, name);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, name);
}
// Config normalization runs before file matching in electron-builder.
const base = resolveDesktopBuilderConfig({});
const config = doMergeConfigs([{ ...base, files: [...base.files] }]);
const packager = {
config,
projectDir: root,
buildResourcesDir: 'build',
debugLogger: { isEnabled: false },
};
const platformPackager = { info: packager };
const output = join(root, 'release');
const matchers = getMainFileMatchers(
root,
output,
(s) => s,
config.win,
platformPackager,
output,
false,
);
const sets = await computeFileSets(matchers, null, platformPackager, false);
const files = [
...new Set(
sets.flatMap((set) => set.files).map((file) => relative(root, file).replaceAll('\\', '/')),
),
];
assert.deepEqual(files.sort(), runtimeFiles.sort());
});
test('Desktop packaging keeps node-pty runtime files without its build intermediates', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'maka-pty-package-'));
t.after(() => rm(root, { recursive: true, force: true }));
const moduleRoot = join(root, 'node_modules', 'node-pty');
const runtimeFiles = [
'package.json',
'LICENSE',
'lib/index.js',
'lib/worker/conoutSocketWorker.js',
'build/Release/conpty.node',
'build/Release/conpty_console_list.node',
'build/Release/pty.node',
'build/Release/spawn-helper',
'build/Release/conpty/conpty.dll',
'build/Release/conpty/OpenConsole.exe',
'prebuilds/win32-x64/conpty.node',
'prebuilds/win32-x64/conpty/conpty.dll',
'prebuilds/win32-x64/conpty/OpenConsole.exe',
];
const buildFiles = [
'build/conpty.vcxproj',
'build/conpty.vcxproj.filters',
'build/Release/conpty.exp',
'build/Release/conpty.iobj',
'build/Release/conpty.ipdb',
'build/Release/obj/conpty/conpty.tlog/CL.command.1.tlog',
'build/Release/obj/conpty/conpty.node.recipe',
'node-addon-api/node_addon_api_except.vcxproj',
'node-addon-api/Release/obj/node_addon_api_except/n.nativecodeanalysis.xml',
];
for (const name of [...runtimeFiles, ...buildFiles]) {
const path = join(moduleRoot, name);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, name);
}
const base = resolveDesktopBuilderConfig({});
const config = doMergeConfigs([{ ...base, files: [...base.files] }]);
const packager = {
config,
appInfo: { type: 'module' },
debugLogger: { isEnabled: false },
getWorkspaceRoot: async () => root,
};
const destination = join(root, 'output');
const mainMatcher = getNodeModuleFileMatcher(root, destination, (s) => s, config.win, packager);
const matcher = new FileMatcher(moduleRoot, destination, (s) => s, mainMatcher.patterns);
const copier = new NodeModuleCopyHelper(matcher, packager);
const files = await copier.collectNodeModules(
{ name: 'node-pty', dir: moduleRoot },
[],
join('node_modules', 'node-pty'),
);
assert.deepEqual(
files.map((file) => relative(moduleRoot, file).replaceAll('\\', '/')).sort(),
runtimeFiles.sort(),
);
});
test('packaged resources forbid the retired bundled Git distribution', async () => {
const required = [];
const forbidden = [];
await assertPackagedResources('resources', {
requirePath: async (path) => required.push(path),
forbidPath: async (path) => forbidden.push(path),
requireWindowsSandbox: false,
});
for (const path of [
join('resources', 'git'),
join('resources', 'bundled-git.json'),
join('resources', 'licenses', 'dugite'),
join('resources', 'licenses', 'git'),
]) {
assert.equal(required.includes(path), false);
assert.equal(forbidden.includes(path), true);
}
});
test('the upgrade baseline keeps the Git absence rule while relaxing newer resources', async () => {
const required = [];
const forbidden = [];
await assertPackagedResources('resources', {
requirePath: async (path) => required.push(path),
forbidPath: async (path) => forbidden.push(path),
requireWindowsSandbox: false,
requireDisclaimer: false,
requireCanonicalIcon: false,
requireAppIconCatalog: false,
requireDirectPeerArtifact: false,
requireMjsFilesystemWorker: false,
});
// A pinned baseline may predate any of these; none of them may be demanded
// of bytes that were correct when they shipped.
for (const path of [
join('resources', 'workers', 'filesystem-worker.mjs'),
join('resources', 'assets', 'icon.png'),
join('resources', 'licenses', 'maka', 'DISCLAIMER-WIP'),
join('resources', 'runtime-host-peer', 'maka_runtime_host_peer.node'),
join('resources', 'licenses', 'runtime-host-peer', 'THIRD_PARTY_NOTICES.txt'),
]) {
assert.equal(required.includes(path), false);
}
// Git is not one of them: no published build still carries it.
for (const path of [
join('resources', 'git'),
join('resources', 'bundled-git.json'),
join('resources', 'licenses', 'dugite'),
join('resources', 'licenses', 'git'),
]) {
assert.equal(required.includes(path), false);
assert.equal(forbidden.includes(path), true);
}
});
describe('asarLookupPath', () => {
// The archive stores `/`-joined paths, but `@electron/asar` resolves a lookup
// by splitting it on `path.sep`. Passing an archive path straight through
// therefore works on macOS and Linux and silently finds nothing on Windows,
// which is how this shipped green from a mac and failed the Windows lane.
test('leaves archive paths alone where the separator already matches', () => {
assert.equal(asarLookupPath('dist/main/app-ipc-main.js', '/'), 'dist/main/app-ipc-main.js');
});
test('localizes every segment for a Windows separator', () => {
assert.equal(asarLookupPath('dist/main/app-ipc-main.js', '\\'), 'dist\\main\\app-ipc-main.js');
});
test('resolves a real archive path under a Windows separator', () => {
// Mirrors `@electron/asar`'s own descent so the assertion fails on any
// platform rather than only on the one that has the bug.
const header = {
files: { dist: { files: { main: { files: { 'app-ipc-main.js': { size: 1 } } } } } },
};
const descend = (path, separator) =>
path
.split(separator)
.filter(Boolean)
.reduce((node, part) => node?.files?.[part], header);
assert.ok(descend(asarLookupPath('dist/main/app-ipc-main.js', '\\'), '\\'));
assert.equal(descend('dist/main/app-ipc-main.js', '\\'), undefined);
});
});
// The closure assertion must judge the artifact by its own contents. These
// fixtures build a real `resources/` layout — an actual asar carrying both a
// node_modules tree and the renderer's bundled-package record, plus a shipped
// notices file — because the regressions this guards against were verifiers
// that read part of their evidence from the checkout.
const roots = [];
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** An ICNS archive carrying `slots` verbatim, so a test can state the exact
* entry shape a generator produced without shipping a binary fixture. */
function icnsWith(slots) {
const entries = slots.map(([type, payload]) => {
const entry = Buffer.alloc(8 + payload.length);
entry.write(type, 0, 'latin1');
entry.writeUInt32BE(entry.length, 4);
payload.copy(entry, 8);
return entry;
});
const body = Buffer.concat(entries);
const header = Buffer.alloc(8);
header.write('icns', 0, 'latin1');
header.writeUInt32BE(header.length + body.length, 4);
return Buffer.concat([header, body]);
}
/** Packs one ARGB plane the ICNS way: runs of 3-130 equal bytes, literals of up to 128. */
function packIcnsPlane(plane) {
const packed = [];
for (let i = 0; i < plane.length; ) {
let run = 1;
while (run < 130 && i + run < plane.length && plane[i + run] === plane[i]) run += 1;
if (run >= 3) {
packed.push(0x80 + run - 3, plane[i]);
i += run;
continue;
}
let end = i + 1;
while (
end < plane.length &&
end - i < 128 &&
!(plane[end] === plane[end + 1] && plane[end] === plane[end + 2])
) {
end += 1;
}
packed.push(end - i - 1, ...plane.subarray(i, end));
i = end;
}
return Buffer.from(packed);
}
/** A real `ic04`/`ic05` payload, which `iconutil` unpacks to a `side` px image:
* a transparent border around opaque gradients, so it holds runs and literals. */
function argbPayload(side) {
const planes = [0, 1, 2, 3].map((channel) => {
const plane = Buffer.alloc(side * side);
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
const edge = x === 0 || y === 0 || x === side - 1 || y === side - 1;
plane[y * side + x] =
channel === 0 ? (edge ? 0 : 255) : (channel * 60 + x * 7 + y * 3) & 0xff;
}
}
return packIcnsPlane(plane);
});
return Buffer.concat([Buffer.from('ARGB', 'latin1'), ...planes]);
}
/** One PNG chunk, with its CRC. */
function pngChunk(type, data) {
const chunk = Buffer.alloc(12 + data.length);
chunk.writeUInt32BE(data.length, 0);
chunk.write(type, 4, 'latin1');
data.copy(chunk, 8);
chunk.writeUInt32BE(crc32(chunk.subarray(4, 8 + data.length)), 8 + data.length);
return chunk;
}
/** A real 8-bit RGBA PNG of a `width` × `height` image. `rows` and `filter`
* let a test write fewer scanlines than IHDR declares or an unknown filter. */
function pngImage(width, height = width, { rows = height, filter = 0 } = {}) {
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header.set([8, 6, 0, 0, 0], 8);
const scanline = Buffer.alloc(1 + width * 4, 0x7f);
scanline[0] = filter;
return Buffer.concat([
PNG_SIGNATURE,
pngChunk('IHDR', header),
pngChunk('IDAT', deflateSync(Buffer.concat(Array.from({ length: rows }, () => scanline)))),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
const RENDERABLE_ICNS = icnsWith([
['ic04', argbPayload(16)],
['ic05', argbPayload(32)],
['ic07', pngImage(128)],
['ic13', pngImage(256)],
]);
const PTY_PACKAGES = ['@xterm/headless', '@xterm/addon-unicode11'];
const COVERING_NOTICES = 'Header\n\nPackage: react@19.2.0\nDeclared license: MIT\n';
async function makeResources({
asarPackages = PTY_PACKAGES,
bundled = ['react'],
notices = COVERING_NOTICES,
rendererLicenses = [],
// Files placed under `dist/` inside the archive, so the bare-import scan
// has shipped code to read.
distFiles = {},
} = {}) {
const root = await mkdtemp(join(tmpdir(), 'maka-closure-'));
roots.push(root);
const stage = join(root, 'stage');
for (const entry of asarPackages) {
// `name` or `name@version` — the archive's manifest is what the verifier
// compares against the closure, so a fixture has to be able to ship one
// that disagrees.
const at = entry.lastIndexOf('@');
const [name, version] =
at > 0 ? [entry.slice(0, at), entry.slice(at + 1)] : [entry, '0.0.0-fixture'];
const directory = join(stage, 'node_modules', name);
await mkdir(directory, { recursive: true });
await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version })}\n`);
}
for (const [relative, contents] of Object.entries(distFiles)) {
const target = join(stage, 'dist', ...relative.split('/'));
await mkdir(dirname(target), { recursive: true });
await writeFile(target, contents);
}
if (bundled !== null) {
await mkdir(join(stage, 'dist-renderer'), { recursive: true });
await writeFile(
join(stage, 'dist-renderer', 'bundled-npm-packages.json'),
`${JSON.stringify(bundled)}\n`,
);
}
const resources = join(root, 'resources');
await mkdir(join(resources, 'licenses', 'npm'), { recursive: true });
await createPackage(stage, join(resources, 'app.asar'));
await writeFile(join(resources, 'licenses', 'npm', 'THIRD_PARTY_NOTICES.txt'), notices);
for (const relativePath of rendererLicenses) {
const path = join(resources, relativePath);
await mkdir(join(path, '..'), { recursive: true });
await writeFile(path, 'license text\n');
}
return resources;
}
after(async () => {
await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })));
});
const FIXTURE_VERSION = '0.0.0-fixture';
const allowlistOf = (entries) =>
new Map(
entries.map((entry) => {
const at = entry.lastIndexOf('@');
return at > 0
? [entry.slice(0, at), new Set([entry.slice(at + 1)])]
: [entry, new Set([FIXTURE_VERSION])];
}),
);
const options = {
collectClosure: () => [{ name: 'react', version: '19.2.0' }],
collectPackagedAllowlist: () => allowlistOf(PTY_PACKAGES),
};
test('verifies an Intel macOS package after a transient ticket validation failure', async () => {
const { verifyPackagedMacApp } = await import('./verify-macos-dmg.mjs');
const asarPackages = await Promise.all(
PTY_PACKAGES.map(async (name) => {
const manifest = JSON.parse(
await readFile(new URL(`../node_modules/${name}/package.json`, import.meta.url), 'utf8'),
);
return `${name}@${manifest.version}`;
}),
);
const resources = await makeResources({
asarPackages,
notices: await readFile(
new URL('../apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt', import.meta.url),
'utf8',
),
rendererLicenses: [
'licenses/renderer/GEIST_LICENSE.txt',
'licenses/renderer/GEIST_MONO_LICENSE.txt',
],
});
await writeFile(
join(resources, 'app-update.yml'),
'provider: github\nowner: apache\nrepo: maka\nchannel: dev\nupdaterCacheDirName: "@makadesktop-updater"\n',
);
await writeFile(join(resources, 'icon.icns'), RENDERABLE_ICNS);
const version = `${productVersion}-dev.14.20260902`;
const app = join(dirname(resources), 'Maka.app');
await mkdir(join(app, 'Contents'), { recursive: true });
await rename(resources, join(app, 'Contents', 'Resources'));
const verificationSteps = [];
// The archive and update configuration are real; macOS command output and
// app launches are the system boundaries this portable test substitutes.
await verifyPackagedMacApp(app, {
expectedArch: 'x64',
channel: 'nightly',
environment: { MAKA_DESKTOP_NIGHTLY_VERSION: version },
requirePath: async () => {},
smokeFilesystemWorker: async () => {},
smokeRenderer: async () => {
verificationSteps.push('renderer');
},
run: async (command, args) => {
if (command === 'plutil') {
const values = {
CFBundleIdentifier: 'com.maka.desktop',
CFBundleShortVersionString: version,
CFBundleExecutable: 'Maka',
};
assert.ok(Object.hasOwn(values, args[1]));
return { stdout: `${values[args[1]]}\n` };
}
if (command === 'lipo') return { stdout: 'x86_64\n' };
if (command === 'xcrun') {
verificationSteps.push(args[1]);
assert.deepEqual(args, ['stapler', 'validate', app]);
if (verificationSteps.length === 1) {
return runCommand(process.execPath, [
'-e',
"require('node:fs').writeSync(1, 'Error Domain=NSURLErrorDomain Code=-1005 connection lost'); process.exit(68)",
]);
}
return { stdout: '' };
}
if (['codesign', 'spctl', join(app, 'Contents', 'MacOS', 'Maka')].includes(command)) {
return { stdout: '' };
}
throw new Error(`Unexpected command: ${command}`);
},
});
assert.deepEqual(verificationSteps, ['validate', 'validate', 'renderer']);
});
describe('assertRenderableAppIcon', () => {
const withIcon = async (t, icns) => {
const resources = await mkdtemp(join(tmpdir(), 'maka-icon-'));
t.after(() => rm(resources, { recursive: true, force: true }));
await writeFile(join(resources, 'icon.icns'), icns);
return resources;
};
test('accepts an icon whose small sizes are stored as ARGB', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
await assertRenderableAppIcon(await withIcon(t, RENDERABLE_ICNS));
});
test('rejects PNG data in the legacy slots macOS does not decode', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
// The shape electron-builder 26.15.2-26.15.3 produced: every size present,
// the large ones fine, and 16px/32px unreadable where a person sees them.
const resources = await withIcon(
t,
icnsWith([
['icp4', pngImage(16)],
['icp5', pngImage(32)],
['ic07', pngImage(128)],
]),
);
await assert.rejects(assertRenderableAppIcon(resources), /16x16 \(icp4\), 32x32 \(icp5\)/);
});
test('rejects an icon that carries no small sizes at all', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
const resources = await withIcon(t, icnsWith([['ic07', pngImage(128)]]));
await assert.rejects(assertRenderableAppIcon(resources), /missing the sizes/);
});
test('rejects small sizes whose ARGB planes do not unpack to the full image', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
const whole = argbPayload(16);
for (const ic04 of [
// The empty and one-byte payloads the review reproduced.
Buffer.alloc(0),
Buffer.from([0x80]),
// The last packed token cut short.
whole.subarray(0, whole.length - 1),
// Bytes left over once all four planes are full.
Buffer.concat([whole, Buffer.from([0x00, 0xff])]),
// The right planes behind the wrong magic.
Buffer.concat([Buffer.from('PNGX', 'latin1'), whole.subarray(4)]),
// 1024 bytes in all, as the review built it, but a run spills four
// bytes of the first plane into the second: planes of 260, 252, 256
// and 256, which macOS draws with every plane after the first shifted.
Buffer.from([
...Buffer.from('ARGB'),
0xff,
1,
0xff,
1,
0xff,
2,
0xf7,
2,
0xff,
3,
0xfb,
3,
0xff,
4,
0xfb,
4,
]),
]) {
const resources = await withIcon(
t,
icnsWith([
['ic04', ic04],
['ic05', argbPayload(32)],
]),
);
await assert.rejects(
assertRenderableAppIcon(resources),
/small sizes macOS cannot decode: 16x16 \(ic04\)\./,
);
}
});
test('rejects PNG art at the wrong size for its slot', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
// The retina slots as the toolset electron-builder 26.15.3 pinned wrote
// them, and art that is not square.
const resources = await withIcon(
t,
icnsWith([
['ic04', argbPayload(16)],
['ic05', argbPayload(32)],
['ic13', pngImage(512)],
['ic14', pngImage(1024)],
['ic11', pngImage(32, 16)],
]),
);
await assert.rejects(
assertRenderableAppIcon(resources),
new RegExp(
'ic11 holds 32x16 where macOS expects 32x32; ' +
'ic13 holds 512x512 where macOS expects 256x256; ' +
'ic14 holds 1024x1024 where macOS expects 512x512\\.',
),
);
});
test('rejects PNG art that does not decode', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
const whole = pngImage(128);
const signatureAndHeader = whole.subarray(0, 33);
const end = pngChunk('IEND', Buffer.alloc(0));
const badCrc = Buffer.from(whole);
badCrc[29] ^= 0xff;
const overlong = Buffer.from(whole);
overlong.writeUInt32BE(1, whole.length - 12);
for (const ic07 of [
// A signature and IHDR alone, as the review's fixture was.
signatureAndHeader,
badCrc,
// Bytes after IEND.
Buffer.concat([whole, Buffer.from([0])]),
// A chunk longer than what is left.
overlong,
// IHDR not first, and one byte short.
Buffer.concat([PNG_SIGNATURE, pngChunk('pHYs', Buffer.alloc(9)), whole.subarray(8)]),
Buffer.concat([PNG_SIGNATURE, pngChunk('IHDR', whole.subarray(16, 28)), whole.subarray(33)]),
// IDAT that does not inflate.
Buffer.concat([signatureAndHeader, pngChunk('IDAT', Buffer.from('not deflate')), end]),
// A scanline short, and an unknown row filter.
pngImage(128, 128, { rows: 127 }),
pngImage(128, 128, { filter: 5 }),
]) {
const resources = await withIcon(
t,
icnsWith([
['ic04', argbPayload(16)],
['ic05', argbPayload(32)],
['ic07', ic07],
]),
);
await assert.rejects(
assertRenderableAppIcon(resources),
/PNG art macOS cannot draw: ic07 does not decode\./,
);
}
});
test('refuses to guess at a truncated entry instead of looping on it', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
for (const length of [0, 1_000]) {
const icns = icnsWith([['ic04', argbPayload(16)]]);
icns.writeUInt32BE(length, 12);
await assert.rejects(assertRenderableAppIcon(await withIcon(t, icns)), /unusable length/);
}
});
test('rejects an archive whose header does not match its entries', async (t) => {
const { assertRenderableAppIcon } = await import('./verify-macos-dmg.mjs');
const understated = Buffer.from(RENDERABLE_ICNS);
understated.writeUInt32BE(8, 4);
await assert.rejects(
assertRenderableAppIcon(await withIcon(t, understated)),
new RegExp(`declares 8 bytes but holds ${understated.length}\\.`),
);
// Bytes after the last entry that are too few to be another one.
const padded = Buffer.concat([RENDERABLE_ICNS, Buffer.from([0, 0, 0])]);
padded.writeUInt32BE(padded.length, 4);
await assert.rejects(
assertRenderableAppIcon(await withIcon(t, padded)),
/ends inside an entry header/,
);
});
});
describe('assertPackagedDependencyClosure', () => {
test('requires a relative runtime import to exist inside the shipped archive', async () => {
const distFiles = {
'main/workhub-remote-bridge.js':
"import { readWorkHubTopicAssociation } from '../shared/workhub-topic-association.js';\n",
};
await assert.rejects(
assertPackagedDependencyClosure(await makeResources({ distFiles }), options),
/workhub-remote-bridge\.js.*missing file.*shared\/workhub-topic-association\.js/u,
);
await assertPackagedDependencyClosure(
await makeResources({
distFiles: {
...distFiles,
'shared/workhub-topic-association.js':
'export function readWorkHubTopicAssociation() {}\n',
},
}),
options,
);
});
test('accepts an artifact whose asar, bundle record, and shipped notices match', async () => {
const resources = await makeResources();
await assertPackagedDependencyClosure(resources, options);
});
test('rejects a stale shipped notice even though the checkout copy is complete', async () => {
// The checkout's own THIRD_PARTY_NOTICES.txt covers react — that is what
// check:third-party-notices enforces — so a verifier reading from the
// checkout would pass this artifact. Only the shipped copy is stale.
const resources = await makeResources({
notices: 'Header\n\nPackage: something-else@1.0.0\nDeclared license: MIT\n',
});
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/shipped THIRD_PARTY_NOTICES\.txt is missing packages the artifact ships: react@19\.2\.0/,
);
});
test('rejects a notice entry whose version is not the shipped one', async () => {
const resources = await makeResources({
notices: 'Header\n\nPackage: react@18.0.0\nDeclared license: MIT\n',
});
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/react@19\.2\.0/,
);
});
test('rejects a permitted package shipped at a version the closure does not declare', async () => {
// Names alone matched, so an archive carrying react@18 against a closure
// declaring react@19 passed — a name that belongs at a version that does
// not, which is the shape a substitution takes.
const resources = await makeResources({
asarPackages: [...PTY_PACKAGES, 'react@18.3.1'],
bundled: ['react'],
});
await assert.rejects(
assertPackagedDependencyClosure(resources, {
...options,
collectPackagedAllowlist: () => allowlistOf([...PTY_PACKAGES, 'react@19.2.0']),
}),
/outside the production closure: react@18\.3\.1/u,
);
});
test('rejects shipped code importing a package the closure allows but the archive lacks', async () => {
// Being in the closure was accepted as proof the import resolves. It is
// not: only the archive can answer that, and an allowed-but-absent
// package is exactly the ERR_MODULE_NOT_FOUND this check exists for.
const resources = await makeResources({
asarPackages: PTY_PACKAGES,
distFiles: { 'main/app.js': "import QRCode from 'qrcode';\n" },
});
await assert.rejects(
assertPackagedDependencyClosure(resources, {
...options,
collectPackagedAllowlist: () => allowlistOf([...PTY_PACKAGES, 'qrcode@1.5.4']),
}),
/importing packages it does not carry: qrcode/u,
);
});
test('rejects a leak hidden inside a nested node_modules', async () => {
// npm nests a second copy under a package on version conflict; a walk that
// stops at the top level certifies an archive it has not fully inspected.
const resources = await makeResources({
asarPackages: [...PTY_PACKAGES, '@xterm/headless/node_modules/left-pad'],
});
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/app\.asar carries packages outside the production closure: left-pad/,
);
});
test('rejects any package outside the production closure, transitive ones included', async () => {
// The old check compared the archive against the declared renderer roots,
// so a renderer-only transitive package (never a root) could leak back in
// silently. The allowlist is the production closure, so anything else —
// root or transitive — is a leak.
const resources = await makeResources({
asarPackages: [...PTY_PACKAGES, 'lodash-es'],
});
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/app\.asar carries packages outside the production closure: lodash-es/,
);
});
test('rejects an asar trimmed past what the PTY stack loads', async () => {
const resources = await makeResources({ asarPackages: ['@xterm/addon-unicode11'] });
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/missing @xterm\/headless/,
);
});
test('rejects a bundle record naming a package the closure does not declare', async () => {
// The record is written by the vite build from the real module graph, so
// this is the failure a package entering through a new path (a CSS import,
// an asset chain) produces until it is declared.
const resources = await makeResources({ bundled: ['react', 'left-pad'] });
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/renderer bundle carries packages outside the declared closure: left-pad/,
);
});
test('rejects an artifact with no bundle record at all', async () => {
const resources = await makeResources({ bundled: null });
await assert.rejects(
() => assertPackagedDependencyClosure(resources, options),
/does not carry dist-renderer\/bundled-npm-packages\.json/,
);
});
test('asset-licensed packages need their shipped license file, not an npm notice', async () => {
const closure = () => [
{ name: 'react', version: '19.2.0' },
{ name: '@fontsource-variable/geist', version: '5.3.0' },
];
const withLicense = await makeResources({
bundled: ['react', '@fontsource-variable/geist'],
rendererLicenses: [join('licenses', 'renderer', 'GEIST_LICENSE.txt')],
});
await assertPackagedDependencyClosure(withLicense, { ...options, collectClosure: closure });
const withoutLicense = await makeResources({
bundled: ['react', '@fontsource-variable/geist'],
});
await assert.rejects(
() =>
assertPackagedDependencyClosure(withoutLicense, { ...options, collectClosure: closure }),
/shipped license file for @fontsource-variable\/geist is missing/,
);
});
});
// The resource list is contract, not implementation: the permission overlay
// reads `assets/icon.png` at runtime, so a current build that drops it ships
// a regression the app cannot report. The check is driven through the
// injectable `requirePath`, so it needs no packaging and no platform.
describe('assertPackagedResources', () => {
const resources = join('fake', 'resources');
const iconPath = join(resources, 'assets', 'icon.png');
const requirePathMissing = (absent) => async (path) => {
if (path === absent) throw new Error(`MISSING ${path}`);
};
const forbidPath = async () => {};
test('a current build must carry the canonical icon', async () => {
await assert.rejects(
() =>
assertPackagedResources(resources, {
requirePath: requirePathMissing(iconPath),
forbidPath,
requireWindowsSandbox: false,
}),
/MISSING .*icon\.png/,
);
});
test('a legacy baseline predating the packaged icon is not required to carry it', async () => {
await assertPackagedResources(resources, {
requirePath: requirePathMissing(iconPath),
forbidPath,
requireWindowsSandbox: false,
requireDisclaimer: false,
requireCanonicalIcon: false,
});
});
});