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
201 lines
8.6 KiB
JavaScript
201 lines
8.6 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.
|
|
*/
|
|
|
|
// The dependency closure a packaged artifact actually ships: the Node
|
|
// production closure that lands in `app.asar/node_modules`, plus everything
|
|
// reachable from the workspace's declared renderer roots, which vite bundles
|
|
// into `dist-renderer`. The notice generator writes from this closure and the
|
|
// packaged-artifact verifier checks against it, so both must read the same
|
|
// definition — this module is that single definition.
|
|
import { execFileSync } from 'node:child_process';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { npmSpawnOptions } from './npm-spawn.mjs';
|
|
|
|
const repoRoot = resolve(import.meta.dirname, '..');
|
|
|
|
export const WORKSPACE_PREFIX = '@maka/';
|
|
|
|
export function npmWorkspaceTree(workspaceName, omitDev) {
|
|
const tree = JSON.parse(
|
|
execFileSync(
|
|
'npm',
|
|
['ls', '--workspace', workspaceName, ...(omitDev ? ['--omit=dev'] : []), '--all', '--json'],
|
|
npmSpawnOptions({
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
}),
|
|
),
|
|
);
|
|
const workspace = tree.dependencies?.[workspaceName];
|
|
if (!workspace) throw new Error(`npm ls did not return the ${workspaceName} workspace`);
|
|
return workspace;
|
|
}
|
|
|
|
function collectInto(packages, dependencies) {
|
|
for (const [name, dependency] of Object.entries(dependencies ?? {})) {
|
|
if (!dependency || typeof dependency !== 'object') continue;
|
|
if (!name.startsWith(WORKSPACE_PREFIX) && typeof dependency.version === 'string') {
|
|
packages.set(`${name}@${dependency.version}`, { name, version: dependency.version });
|
|
}
|
|
collectInto(packages, dependency.dependencies);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Packages the workspace declares as bundled into the renderer.
|
|
*
|
|
* They live in `devDependencies` so electron-builder keeps a second, unread
|
|
* copy of their sources out of `app.asar` — but vite bundles them into
|
|
* `dist-renderer`, which the archive does carry. So they ship, and their
|
|
* notices have to ship with them. Reading the list from the manifest keeps the
|
|
* notice generator and the packaged-artifact verifier from drifting apart.
|
|
*/
|
|
export function rendererBundledRoots(manifestPath) {
|
|
if (!manifestPath) return [];
|
|
const declared = JSON.parse(readFileSync(manifestPath, 'utf8'))?.maka
|
|
?.rendererBundledDependencies;
|
|
if (!Array.isArray(declared) || declared.length === 0) {
|
|
throw new Error(
|
|
`${manifestPath}: maka.rendererBundledDependencies must list the renderer roots`,
|
|
);
|
|
}
|
|
return declared;
|
|
}
|
|
|
|
/**
|
|
* Renderer-bundled packages whose license ships as a vendored file in the
|
|
* artifact instead of an entry in the generated npm notices. The Geist fonts
|
|
* are OFL-1.1 — carried as their own license files next to the other renderer
|
|
* asset notices — so the notice generator skips them while the security audit
|
|
* and the bundle-graph check still cover them as shipped packages.
|
|
*/
|
|
export const ASSET_LICENSED_RENDERER_PACKAGES = new Map([
|
|
['@fontsource-variable/geist', join('licenses', 'renderer', 'GEIST_LICENSE.txt')],
|
|
['@fontsource-variable/geist-mono', join('licenses', 'renderer', 'GEIST_MONO_LICENSE.txt')],
|
|
]);
|
|
|
|
/**
|
|
* Every third-party `{ name, version }` the workspace ships, sorted. Pass
|
|
* `manifestPath` only for a workspace that bundles a renderer; without it the
|
|
* closure is the production closure alone.
|
|
*/
|
|
export function collectWorkspaceClosure({ workspaceName, manifestPath }) {
|
|
const packages = new Map();
|
|
collectInto(packages, npmWorkspaceTree(workspaceName, true).dependencies);
|
|
|
|
const roots = new Set(rendererBundledRoots(manifestPath));
|
|
if (roots.size > 0) {
|
|
const full = npmWorkspaceTree(workspaceName, false).dependencies ?? {};
|
|
for (const [name, dependency] of Object.entries(full)) {
|
|
if (!roots.has(name) || !dependency || typeof dependency !== 'object') continue;
|
|
if (name.startsWith(WORKSPACE_PREFIX)) {
|
|
// A workspace root's slot in the full tree carries its dev edges too
|
|
// (`@maka/ui` declares @types/* and linkedom for its tests), and none
|
|
// of those are bundle inputs. Its own production closure is what the
|
|
// renderer can actually reach through it.
|
|
collectInto(packages, npmWorkspaceTree(name, true).dependencies);
|
|
continue;
|
|
}
|
|
if (typeof dependency.version === 'string') {
|
|
packages.set(`${name}@${dependency.version}`, { name, version: dependency.version });
|
|
}
|
|
collectInto(packages, dependency.dependencies);
|
|
}
|
|
// Every declared root must be reachable, the workspace ones included: a
|
|
// workspace root (`@maka/ui`) carries no notice of its own — it is first
|
|
// party — but its third-party dependencies are only collected through it,
|
|
// so its absence from the tree would silently drop their notices.
|
|
const missing = [...roots].filter((root) => !Object.hasOwn(full, root));
|
|
if (missing.length > 0) {
|
|
throw new Error(`renderer roots absent from the dependency tree: ${missing.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
return [...packages.values()].sort(
|
|
(left, right) =>
|
|
left.name.localeCompare(right.name) || left.version.localeCompare(right.version),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Every package name that may legitimately appear under the workspace's
|
|
* packaged `node_modules` — the production closure, workspace packages
|
|
* included. electron-builder walks exactly this graph, so anything in the
|
|
* archive outside it is a leak regardless of how it got there.
|
|
*/
|
|
export function collectProductionNames(workspaceName) {
|
|
return new Set(collectProductionClosure(workspaceName).keys());
|
|
}
|
|
|
|
/**
|
|
* The same closure as `collectProductionNames`, keyed by name with the exact
|
|
* versions npm resolved. Verifying by name alone accepted an archive carrying
|
|
* a different version of a permitted package, which is the shape a
|
|
* substitution attack takes: a name that belongs, at a version that does not.
|
|
*
|
|
* A workspace package has no version in the tree; it maps to `undefined`, and
|
|
* the archive's own manifest is compared against that the same way.
|
|
*/
|
|
export function collectProductionClosure(workspaceName) {
|
|
const closure = new Map();
|
|
const walk = (dependencies) => {
|
|
for (const [name, dependency] of Object.entries(dependencies ?? {})) {
|
|
if (!dependency || typeof dependency !== 'object') continue;
|
|
if (!closure.has(name)) closure.set(name, new Set());
|
|
closure
|
|
.get(name)
|
|
.add(typeof dependency.version === 'string' ? dependency.version : undefined);
|
|
walk(dependency.dependencies);
|
|
}
|
|
};
|
|
walk(npmWorkspaceTree(workspaceName, true).dependencies);
|
|
return closure;
|
|
}
|
|
|
|
/**
|
|
* Package names a stylesheet imports by name. Vite inlines a CSS `@import` at
|
|
* transform time, so the imported file never becomes a module and a package of
|
|
* pure rules leaves no trace in the bundle record — reading the source is what
|
|
* finds it. Relative, absolute, remote and data URLs are first-party or
|
|
* already-resolved and are not package references.
|
|
*/
|
|
export function bareCssImportSpecifiers(css) {
|
|
// `@import "pkg"`, `@import url("pkg")` and the unquoted `@import url(pkg)`
|
|
// Vite also accepts, with or without a layer/media suffix. Relative and
|
|
// absolute URLs are first-party or already-resolved assets, so only bare
|
|
// specifiers are of interest here.
|
|
//
|
|
// Unquoted is matched only inside `url()`. CSS has no bare `@import pkg`,
|
|
// and accepting one would read the `layer`/`supports` keyword of a
|
|
// quoted import as a package name.
|
|
const names = new Set();
|
|
const specifiers = [
|
|
...css.matchAll(/@import\s+(?:url\(\s*)?['"]([^'"]+)['"]/g),
|
|
...css.matchAll(/@import\s+url\(\s*([^'")\s][^)]*?)\s*\)/g),
|
|
];
|
|
for (const [, specifier] of specifiers) {
|
|
if (/^[./]|^https?:|^data:/.test(specifier)) continue;
|
|
const segments = specifier.split('/');
|
|
names.add(specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]);
|
|
}
|
|
return [...names];
|
|
}
|