blob: 0db154c40a76800f4cc62b362ce4af0ee62417aa [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.
*/
// 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];
}