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