Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
367 lines
14 KiB
JavaScript
367 lines
14 KiB
JavaScript
/*
|
|
* 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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { after, describe, test } from 'node:test';
|
|
import { createPackage } from '@electron/asar';
|
|
import {
|
|
asarLookupPath,
|
|
assertPackagedDependencyClosure,
|
|
assertPackagedResources,
|
|
} from './verify-packaged-app.mjs';
|
|
|
|
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,
|
|
});
|
|
|
|
// 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', '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 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),
|
|
};
|
|
|
|
describe('assertPackagedDependencyClosure', () => {
|
|
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,
|
|
});
|
|
});
|
|
});
|