UNPKG

@endo/compartment-mapper

Version:

The compartment mapper assembles Node applications in a sandbox

1,589 lines (1,459 loc) 48.7 kB
/* eslint-disable no-underscore-dangle */ /** * Provides functions for constructing a compartment map that has a * compartment descriptor corresponding to every reachable package from an * entry module and how to create links between them. * The resulting compartment map does not describe individual modules but does * capture every usable route between packages including those generalized by * wildcard expansion. * See {@link link} to expand a compartment map to capture module descriptors * for transitive dependencies. * * @module */ /* eslint no-shadow: 0 */ import { inferExportsAliasesAndPatterns } from './infer-exports.js'; import { parseLocatedJson } from './json.js'; import { join } from './node-module-specifier.js'; import { assertPolicy, ATTENUATORS_COMPARTMENT, ENTRY_COMPARTMENT, generateCanonicalName, } from './policy-format.js'; import { dependencyAllowedByPolicy, makePackagePolicy } from './policy.js'; import { unpackReadPowers } from './powers.js'; import { search, searchDescriptor } from './search.js'; import { GenericGraph, makeShortestPath } from './generic-graph.js'; /** * @import { * CanonicalFn, * CompartmentDescriptor, * CompartmentMapDescriptor, * CompartmentMapForNodeModulesOptions, * FileUrlString, * LanguageForExtension, * MapNodeModulesOptions, * MaybeReadDescriptorFn, * MaybeReadFn, * MaybeReadPowers, * PackageDescriptor, * ReadFn, * ReadPowers, * SomePolicy, * LogFn, * CompartmentModuleConfiguration, * PackageCompartmentDescriptor, * PackageCompartmentMapDescriptor, * ScopeDescriptor, * CanonicalName, * SomePackagePolicy, * PackageCompartmentDescriptorName, * PackageData, * } from './types.js' * @import { * Graph, * Node, * LanguageOptions, * CommonDependencyDescriptors, * GatherDependencyOptions, * GraphPackageOptions, * GraphPackagesOptions, * LogicalPathGraph, * PackageDetails, * FinalGraph, * CanonicalNameMap, * FinalNode, TranslateGraphOptions, * } from './types/node-modules.js' */ const { assign, create, keys, values, entries, freeze } = Object; const decoder = new TextDecoder(); // q, as in quote, for enquoting strings in error messages. const { quote: q } = assert; /** * Default logger that does nothing. */ const noop = () => {}; /** * Default handler for unknown canonical names found in policy. * Logs a warning when a canonical name from policy is not found in the compartment map. * * @param {object} params * @param {CanonicalName} params.canonicalName * @param {string} params.message * @param {LogFn} params.log */ const defaultUnknownCanonicalNameHandler = ({ canonicalName, message, log, }) => { log(`WARN: Invalid resource ${q(canonicalName)} in policy: ${message}`); }; /** * Default filter for package dependencies based on policy. * Filters out dependencies not allowed by the package policy. * * **Note:** This filter is _only_ applied if a policy is provided. * * @param {object} params - The parameters object * @param {CanonicalName} params.canonicalName - The canonical name of the package * @param {Readonly<Set<CanonicalName>>} params.dependencies - The set of dependencies * @param {LogFn} params.log - The logging function * @param {SomePolicy} policy - The policy to check against * @returns {Partial<{ dependencies: Set<CanonicalName> }> | void} */ const prePackageDependenciesFilter = ( { canonicalName, dependencies, log }, policy, ) => { const packagePolicy = makePackagePolicy(canonicalName, { policy }); if (!packagePolicy) { return { dependencies }; } const filteredDependencies = new Set( [...dependencies].filter(dependency => { const allowed = dependencyAllowedByPolicy(dependency, packagePolicy); if (!allowed) { log( `Excluding dependency ${q(dependency)} of package ${q(canonicalName)} per policy`, ); } return allowed; }), ); return { dependencies: filteredDependencies }; }; /** * Given a relative path andd URL, return a fully qualified URL string. * * @overload * @param {string} rel - a relative URL * @param {URL} abs - a fully qualified URL * @returns {string} Fully qualified URL string */ /** * Given a relative path and fully qualified stringlike URL, return a fully * qualified stringlike URL. * * @template {string} [T=string] Type of fully qualified URL string * @overload * @param {string} rel - a relative URL * @param {T} abs - a fully qualified URL * @returns {T} Fully qualified stringlike URL */ /** * @param {string} rel - a relative URL * @param {string|URL} abs - a fully qualified URL */ const resolveLocation = (rel, abs) => { return new URL(rel, abs).toString(); }; /** * Ensures a string is a file URL (a {@link FileUrlString}) * * @param {unknown} allegedPackageLocation - a package location to assert * @returns {asserts allegedPackageLocation is FileUrlString} */ const assertFileUrlString = allegedPackageLocation => { assert( typeof allegedPackageLocation === 'string', `Package location must be a string, got ${q(allegedPackageLocation)}`, ); assert( allegedPackageLocation.startsWith('file://'), `Package location must be a file URL, got ${q(allegedPackageLocation)}`, ); }; // Exported for testing: /** * @param {string} location * @returns {string} */ export const basename = location => { let { pathname } = new URL(location); if (pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } const index = pathname.lastIndexOf('/'); if (index < 0) { return pathname; } return pathname.slice(index + 1); }; /** * Asserts that the given value is a plain-object `PackageDescriptor`. * * Note: this does _not_ enforce the presence of a `name`. Callers that adopt * a descriptor as the root of a compartment must additionally call * {@link assertPackageDescriptorHasName} so that the diagnostic blames the * correct `package.json`. * * @param {unknown} allegedPackageDescriptor * @returns {asserts allegedPackageDescriptor is PackageDescriptor} */ const assertPackageDescriptor = allegedPackageDescriptor => { assert( typeof allegedPackageDescriptor !== 'function' && Object(allegedPackageDescriptor) === allegedPackageDescriptor, `Package descriptor must be a plain object, got ${q(allegedPackageDescriptor)}`, ); }; /** * Asserts that the given `PackageDescriptor` has a non-empty `name`. * * `name` is required by {@link PackageDescriptor} and the compartment mapper * relies on it to label and link compartments. Without it, downstream * failures are obscure (for example, the bundler reports an undefined name * far from the offending `package.json`). This surfaces a precise * diagnostic that points at the offending file so the misconfiguration * is easy to fix. * * @param {PackageDescriptor} packageDescriptor * @param {string} packageDescriptorLocation - URL of the `package.json` * file that produced this descriptor, used to attribute errors. * @returns {void} */ const assertPackageDescriptorHasName = ( packageDescriptor, packageDescriptorLocation, ) => { const { name } = packageDescriptor; if (name === undefined || name === '') { throw Error( `package.json at ${q(packageDescriptorLocation)} must have a "name" field; consider naming it after the parent directory`, ); } }; /** * @param {MaybeReadFn} maybeRead * @param {string} packageLocation * @returns {Promise<PackageDescriptor|undefined>} */ const readDescriptor = async (maybeRead, packageLocation) => { const descriptorLocation = resolveLocation('package.json', packageLocation); const descriptorBytes = await maybeRead(descriptorLocation); if (descriptorBytes === undefined) { return undefined; } const descriptorText = decoder.decode(descriptorBytes); const descriptor = parseLocatedJson(descriptorText, descriptorLocation); // Only validate plain-object shape here. The `name` requirement is enforced // separately at sites that adopt a descriptor as the root of a compartment, // because `readDescriptorUpwards` legitimately reads ancestor `package.json` // files (e.g., declaring `type: "module"` for sub-folder module resolution) // that are not themselves package roots. assertPackageDescriptor(descriptor); return descriptor; }; /** * Memoized {@link readDescriptor} * * @param {Record<string, Promise<PackageDescriptor|undefined>>} memo * @param {MaybeReadFn} maybeRead * @param {string} packageLocation * @returns {Promise<PackageDescriptor|undefined>} */ const readDescriptorWithMemo = async (memo, maybeRead, packageLocation) => { /** @type {Promise<PackageDescriptor|undefined>} */ let promise = memo[packageLocation]; if (promise !== undefined) { return promise; } promise = readDescriptor(maybeRead, packageLocation); memo[packageLocation] = promise; return promise; }; /** * `findPackage` behaves as Node.js to find third-party modules by searching * parent to ancestor directories for a `node_modules` directory that contains * the name. * * Node.js does not actually require these to be packages, but in practice, * these are the locations that package managers drop a package so Node.js can * find it efficiently. * * @param {MaybeReadDescriptorFn} readDescriptor * @param {CanonicalFn} canonical * @param {FileUrlString} directory * @param {string} name * @returns {Promise<PackageDetails|undefined>} */ const findPackage = async (readDescriptor, canonical, directory, name) => { await null; for (;;) { // eslint-disable-next-line no-await-in-loop const packageLocation = await canonical( resolveLocation(`node_modules/${name}/`, directory), ); // We have no guarantee that `canonical` will return a file URL; it spits // back whatever we give it if `fs.promises.realpath()` rejects. assertFileUrlString(packageLocation); // eslint-disable-next-line no-await-in-loop const packageDescriptor = await readDescriptor(packageLocation); if (packageDescriptor !== undefined) { // Surface a precise diagnostic when a node_modules dependency lacks a // "name". This is a strict requirement of the compartment mapper that // would otherwise produce an obscure failure deep in the bundler. assertPackageDescriptorHasName( packageDescriptor, resolveLocation('package.json', packageLocation), ); return { packageLocation, packageDescriptor }; } const parent = resolveLocation('../', directory); if (parent === directory) { return undefined; } directory = parent; const base = basename(directory); if (base === 'node_modules') { directory = resolveLocation('../', directory); if (parent === directory) { return undefined; } directory = parent; } } }; /** @satisfies {LanguageForExtension} */ const defaultLanguageForExtension = /** @type {const} */ ({ mjs: 'mjs', cjs: 'cjs', json: 'json', text: 'text', bytes: 'bytes', }); /** @satisfies {LanguageForExtension} */ const defaultCommonjsLanguageForExtension = /** @type {const} */ ({ js: 'cjs', }); /** @satisfies {LanguageForExtension} */ const defaultModuleLanguageForExtension = /** @type {const} */ ({ js: 'mjs', }); /** * @param {PackageDescriptor} descriptor * @param {string} location * @param {LanguageOptions} languageOptions * @returns {Record<string, string>} */ const inferParsers = (descriptor, location, languageOptions) => { let { moduleLanguageForExtension, commonjsLanguageForExtension } = languageOptions; const { languages, workspaceModuleLanguageForExtension, workspaceCommonjsLanguageForExtension, } = languageOptions; // Select languageForExtension options based on whether they are physically // under node_modules, indicating that they have not been built for npm, // so any languages that compile to JavaScript may need additional parsers. if (!location.includes('/node_modules/')) { moduleLanguageForExtension = workspaceModuleLanguageForExtension; commonjsLanguageForExtension = workspaceCommonjsLanguageForExtension; } const { type, module, parsers: packageLanguageForExtension = {}, } = descriptor; // Validate package-local "parsers" if ( typeof packageLanguageForExtension !== 'object' || packageLanguageForExtension === null ) { throw Error( `Cannot interpret parser map ${JSON.stringify( packageLanguageForExtension, )} of package at ${location}, must be an object mapping file extensions to corresponding languages (for example, mjs for ECMAScript modules, cjs for CommonJS modules, or json for JSON modules`, ); } const invalidLanguages = values(packageLanguageForExtension).filter( language => !languages.has(language), ); if (invalidLanguages.length > 0) { throw Error( `Cannot interpret parser map language values ${JSON.stringify( invalidLanguages, )} of package at ${location}, must be an object mapping file extensions to corresponding languages (for example, mjs for ECMAScript modules, cjs for CommonJS modules, or json for JSON modules`, ); } if (type === 'module' || module !== undefined) { return { ...moduleLanguageForExtension, ...packageLanguageForExtension }; } if (type === 'commonjs') { return { ...commonjsLanguageForExtension, ...packageLanguageForExtension }; } if (type !== undefined) { throw Error( `Cannot infer parser map for package of type ${type} at ${location}`, ); } return { ...commonjsLanguageForExtension, ...packageLanguageForExtension }; }; /** * `graphPackage` and {@link gatherDependency} are mutually recursive functions that * gather the metadata for a package and its transitive dependencies. * The keys of the graph are the locations of the package descriptors. * The metadata include a label (which is informative and not necessarily * unique), the location of each shallow dependency, and names of the modules * that the package exports. * * @param {string} name * @param {MaybeReadDescriptorFn} readDescriptor * @param {CanonicalFn} canonical * @param {Graph} graph * @param {PackageDetails} packageDetails * @param {Set<string>} conditions * @param {boolean | undefined} dev * @param {LanguageOptions} languageOptions * @param {boolean} strict * @param {LogicalPathGraph} logicalPathGraph * @param {GraphPackageOptions} options * @returns {Promise<undefined>} */ const graphPackage = async ( name, readDescriptor, canonical, graph, { packageLocation, packageDescriptor }, conditions, dev, languageOptions, strict, logicalPathGraph, { commonDependencyDescriptors = {}, log = noop, packageDependenciesHook, policy, } = {}, ) => { if (graph[packageLocation] !== undefined) { // Returning the promise here would create a causal cycle and stall recursion. return undefined; } if (packageDescriptor.name !== name) { log('Package name does not match location', { name, packageDescriptorName: packageDescriptor.name, packageLocation, }); } const result = /** @type {Node} */ ({ location: packageLocation }); graph[packageLocation] = result; /** @type {Node['dependencyLocations']} */ const dependencyLocations = {}; /** @type {ReturnType<typeof gatherDependency>[]} */ const children = []; /** * A set containing dependency names which are considered "optional" */ const optionals = new Set(); /** * Contains the names of _all_ dependencies * * @type {Set<string>} */ const allDependencies = new Set(); // these are fields from package.json containing dependencies const { dependencies = {}, peerDependencies = {}, peerDependenciesMeta = {}, bundleDependencies = {}, optionalDependencies = {}, devDependencies = {}, } = packageDescriptor; for (const [name, descriptor] of entries(commonDependencyDescriptors)) { if (Object(descriptor) === descriptor) { allDependencies.add(name); } } // only consider devDependencies if dev flag is true for (const name of keys({ ...dependencies, ...peerDependencies, ...bundleDependencies, ...optionalDependencies, ...(dev ? devDependencies : {}), })) { allDependencies.add(name); } // for historical reasons, some packages omit peerDependencies and only // use the peerDependenciesMeta field (because there was no way to define // an "optional" peerDependency prior to npm v7). this is plainly wrong, // but not exactly rare, either for (const [dependencyName, meta] of entries(peerDependenciesMeta)) { if (Object(meta) === meta && meta.optional) { optionals.add(dependencyName); allDependencies.add(dependencyName); } } for (const dependencyName of keys(optionalDependencies)) { optionals.add(dependencyName); } for (const dependencyName of [...allDependencies].sort()) { const optional = optionals.has(dependencyName); children.push( // Mutual recursion ahead: // eslint-disable-next-line no-use-before-define gatherDependency( readDescriptor, canonical, graph, dependencyLocations, packageLocation, dependencyName, conditions, languageOptions, strict, logicalPathGraph, { optional, commonDependencyDescriptors, log, packageDependenciesHook, policy, }, ), ); } const { version = '', exports: exportsDescriptor } = packageDescriptor; /** @type {Node['types']} */ const types = {}; /** * @param {string} path * @returns {Promise<PackageDescriptor>} */ const readDescriptorUpwards = async path => { const location = resolveLocation(path, packageLocation); // readDescriptor coming from above is memoized, so this is not awfully slow const { data } = await searchDescriptor(location, readDescriptor); return data; }; /** @type {Node['externalAliases']} */ const externalAliases = {}; /** @type {Node['internalAliases']} */ const internalAliases = {}; /** @type {Node['patterns']} */ const patterns = []; inferExportsAliasesAndPatterns( packageDescriptor, externalAliases, internalAliases, patterns, conditions, types, log, ); const parsers = inferParsers( packageDescriptor, packageLocation, languageOptions, ); const sourceDirname = basename(packageLocation); /** @type {Partial<Node>} */ const partialNode = { name, label: `${name}${version ? `-v${version}` : ''}`, sourceDirname, explicitExports: exportsDescriptor !== undefined, externalAliases, internalAliases, patterns, dependencyLocations, types, parsers, packageDescriptor, }; assign(result, partialNode); await Promise.all( values(result.externalAliases).map(async item => { const descriptor = await readDescriptorUpwards(item); if (descriptor && descriptor.type === 'module') { types[item] = 'mjs'; } }), ); await Promise.all(children); // handle commonDependencyDescriptors package aliases for (const [name, { alias }] of entries(commonDependencyDescriptors)) { // update the dependencyLocations to point to the common dependency const targetLocation = dependencyLocations[name]; if (targetLocation === undefined) { throw Error( `Cannot find common dependency ${name} for ${packageLocation}`, ); } dependencyLocations[alias] = targetLocation; } // handle internalAliases package aliases for (const specifier of keys(internalAliases).sort()) { const target = internalAliases[specifier]; // ignore all internalAliases where the specifier or target is relative const specifierIsRelative = specifier.startsWith('./') || specifier === '.'; // eslint-disable-next-line no-continue if (specifierIsRelative) continue; const targetIsRelative = target.startsWith('./') || target === '.'; // eslint-disable-next-line no-continue if (targetIsRelative) continue; const targetLocation = dependencyLocations[target]; if (targetLocation === undefined) { throw Error(`Cannot find dependency ${target} for ${packageLocation}`); } dependencyLocations[specifier] = targetLocation; } return undefined; }; /** * Adds information for the dependency of the package at `packageLocation` to the `graph` object. * * @param {MaybeReadDescriptorFn} readDescriptor * @param {CanonicalFn} canonical * @param {Graph} graph - the partially build graph. * @param {Record<string, string>} dependencyLocations * @param {FileUrlString} packageLocation - location of the package of interest. * @param {string} name - name of the package of interest. * @param {Set<string>} conditions * @param {LanguageOptions} languageOptions * @param {boolean} strict - If `true`, a missing dependency will throw an exception * @param {LogicalPathGraph} logicalPathGraph * @param {GatherDependencyOptions} [options] * @returns {Promise<void>} */ const gatherDependency = async ( readDescriptor, canonical, graph, dependencyLocations, packageLocation, name, conditions, languageOptions, strict, logicalPathGraph, { optional = false, commonDependencyDescriptors = {}, log = noop, packageDependenciesHook, policy, } = {}, ) => { const dependency = await findPackage( readDescriptor, canonical, packageLocation, name, ); if (dependency === undefined) { // allow the dependency to be missing if optional if (optional || !strict) { return; } throw Error(`Cannot find dependency ${name} for ${packageLocation}`); } dependencyLocations[name] = dependency.packageLocation; logicalPathGraph.addEdge(packageLocation, dependency.packageLocation); await graphPackage( name, readDescriptor, canonical, graph, dependency, conditions, false, languageOptions, strict, logicalPathGraph, { commonDependencyDescriptors, log, packageDependenciesHook, policy, }, ); }; /** * Resolves with a {@link Graph} representing the packages for which * {@link CompartmentDescriptor CompartmentDescriptors} will be created. * * @param {MaybeReadFn} maybeRead * @param {CanonicalFn} canonical * @param {FileUrlString} packageLocation - location of the main package. * @param {Set<string>} conditions * @param {PackageDescriptor} mainPackageDescriptor - the parsed contents of the * main `package.json`, which was already read when searching for the * `package.json`. * @param {boolean|undefined} dev - whether to use devDependencies from this * package (and only this package). * @param {Record<string,string>} commonDependencies - dependencies to be added * to all packages * @param {LanguageOptions} languageOptions * @param {boolean} strict * @param {LogicalPathGraph} logicalPathGraph * @param {GraphPackagesOptions} options * @returns {Promise<{graph: Graph, readDescriptor: MaybeReadDescriptorFn}>} */ const graphPackages = async ( maybeRead, canonical, packageLocation, conditions, mainPackageDescriptor, dev, commonDependencies, languageOptions, strict, logicalPathGraph, { log = noop, packageDependenciesHook, policy } = {}, ) => { const memo = create(null); /** * @type {MaybeReadDescriptorFn} */ const readDescriptor = packageLocation => readDescriptorWithMemo(memo, maybeRead, packageLocation); if (mainPackageDescriptor !== undefined) { memo[packageLocation] = Promise.resolve(mainPackageDescriptor); } const allegedPackageDescriptor = await readDescriptor(packageLocation); if (allegedPackageDescriptor === undefined) { throw TypeError( `Cannot find package.json for application at ${packageLocation}`, ); } assertPackageDescriptor(allegedPackageDescriptor); assertPackageDescriptorHasName( allegedPackageDescriptor, resolveLocation('package.json', packageLocation), ); const packageDescriptor = allegedPackageDescriptor; conditions = new Set(conditions || []); conditions.add('import'); conditions.add('default'); conditions.add('endo'); // Resolve common dependencies. /** @type {CommonDependencyDescriptors} */ const commonDependencyDescriptors = {}; const packageDescriptorDependencies = packageDescriptor.dependencies || {}; for (const [alias, dependencyName] of entries(commonDependencies)) { const spec = packageDescriptorDependencies[dependencyName]; if (spec === undefined) { throw Error( `Cannot find dependency ${dependencyName} for ${packageLocation} from common dependencies`, ); } commonDependencyDescriptors[dependencyName] = { spec, alias, }; } logicalPathGraph.addNode(packageLocation); const graph = create(null); await graphPackage( packageDescriptor.name, readDescriptor, canonical, graph, { packageLocation, packageDescriptor, }, conditions, dev, languageOptions, strict, logicalPathGraph, { commonDependencyDescriptors, log, packageDependenciesHook, policy, }, ); return { graph, readDescriptor }; }; /** * `translateGraph` converts the graph returned by graph packages (above) into a * {@link CompartmentMapDescriptor compartment map}. * * @param {string} entryPackageLocation * @param {string} entryModuleSpecifier * @param {Graph} graph * @param {Set<string>} conditions - build conditions about the target environment * for selecting relevant exports, e.g., "browser" or "node". * @param {TranslateGraphOptions} [options] * @returns {PackageCompartmentMapDescriptor} */ const translateGraph = ( entryPackageLocation, entryModuleSpecifier, graph, conditions, { policy, log = noop, packageDependenciesHook } = {}, ) => { /** @type {Record<PackageCompartmentDescriptorName, PackageCompartmentDescriptor>} */ const compartments = create(null); /** * Execute package dependencies hooks: default first (if policy exists), then user-provided. * * @param {CanonicalName} label * @param {Record<string, FileUrlString>} dependencyLocations * @returns {Record<string, FileUrlString>} */ const executePackageDependenciesHook = (label, dependencyLocations) => { const dependencies = new Set( values(dependencyLocations).map( dependencyLocation => graph[dependencyLocation].label, ), ); const packageDependenciesHookInput = { canonicalName: label, dependencies: new Set(dependencies), log, }; // Call default filter first if policy exists let packageDependenciesHookResult; if (policy) { packageDependenciesHookResult = prePackageDependenciesFilter( packageDependenciesHookInput, policy, ); } // Then call user-provided hook if it exists if (packageDependenciesHook) { const userResult = packageDependenciesHook(packageDependenciesHookInput); // If user hook also returned a result, use it (overrides default) if (userResult?.dependencies) { packageDependenciesHookResult = userResult; } } // if "dependencies" are in here, then something changed the list. if (packageDependenciesHookResult?.dependencies) { const size = packageDependenciesHookResult.dependencies.size; if (typeof size === 'number' && size > 0) { // because the list of dependencies contains canonical names, we need to lookup any new ones. const nodesByCanonicalName = new Map( entries(graph).map(([location, node]) => [ node.label, { ...node, packageLocation: /** @type {FileUrlString} */ (location), }, ]), ); /** @type {typeof dependencyLocations} */ const newDependencyLocations = {}; try { for (const label of packageDependenciesHookResult.dependencies) { const { name, packageLocation } = nodesByCanonicalName.get(label) ?? create(null); if (name && packageLocation) { newDependencyLocations[name] = packageLocation; } else { log( `WARNING: packageDependencies hook returned unknown package with label ${q(label)}`, ); } } return newDependencyLocations; } catch { log( `WARNING: packageDependencies hook returned invalid value ${q( packageDependenciesHookResult, )}; using original dependencies`, ); } } else { dependencyLocations = create(null); } } return dependencyLocations; }; // For each package, build a map of all the external modules the package can // import from other packages. // The keys of this map are the full specifiers of those modules from the // perspective of the importing package. // The values are records that name the exporting compartment and the full // specifier of the module from the exporting package. // The full map includes every exported module from every dependencey // package and is a complete list of every external module that the // corresponding compartment can import. for (const dependeeLocation of /** @type {PackageCompartmentDescriptorName[]} */ ( keys(graph).sort() )) { const { name, label, sourceDirname, internalAliases, patterns, parsers, types, packageDescriptor, } = graph[dependeeLocation]; /** @type {Record<string, CompartmentModuleConfiguration>} */ const moduleDescriptors = create(null); /** @type {Record<string, ScopeDescriptor<PackageCompartmentDescriptorName>>} */ const scopes = create(null); const packagePolicy = makePackagePolicy(label, { policy }); /* c8 ignore next */ if (policy && !packagePolicy) { // this should never happen throw new TypeError('Unexpectedly falsy package policy'); } let dependencyLocations = graph[dependeeLocation].dependencyLocations; dependencyLocations = executePackageDependenciesHook( label, dependencyLocations, ); /** * @param {string} dependencyName * @param {PackageCompartmentDescriptorName} packageLocation */ const digestExternalAliases = (dependencyName, packageLocation) => { const { externalAliases, explicitExports, patterns: dependencyPatterns, } = graph[packageLocation]; for (const exportPath of keys(externalAliases).sort()) { const targetPath = externalAliases[exportPath]; // dependency name may be different from package's name, // as in the case of browser field dependency replacements. // note that policy still applies const localPath = join(dependencyName, exportPath); // if we have policy, this has already been vetted moduleDescriptors[localPath] = { compartment: packageLocation, module: targetPath, }; } // Propagate export patterns from dependencies. // Each dependency pattern like "./features/*.js" -> "./src/features/*.js" // becomes "dep/features/*.js" -> "./src/features/*.js" on the dependee, // resolving within the dependency's compartment. if (dependencyPatterns) { for (const { from, to } of dependencyPatterns) { // Only propagate export patterns (starting with "./"), not // import patterns (starting with "#") which are internal. if (from.startsWith('./') || from === '.') { const externalFrom = join(dependencyName, from); patterns.push({ from: externalFrom, to, compartment: packageLocation, }); } } } // if the exports field is not present, then all modules must be accessible if (!explicitExports) { scopes[dependencyName] = { compartment: packageLocation, }; } }; // Support reflexive package aliases digestExternalAliases(name, dependeeLocation); // Support external package aliases for (const dependencyName of keys(dependencyLocations).sort()) { const dependencyLocation = dependencyLocations[dependencyName]; digestExternalAliases(dependencyName, dependencyLocation); } // digest own internal aliases for (const modulePath of keys(internalAliases).sort()) { const facetTarget = internalAliases[modulePath]; const targetIsRelative = facetTarget.startsWith('./') || facetTarget === '.'; if (targetIsRelative) { // add target to moduleDescriptors moduleDescriptors[modulePath] = { compartment: dependeeLocation, module: facetTarget, }; } } compartments[dependeeLocation] = { version: packageDescriptor.version ? packageDescriptor.version : '', label, name, location: dependeeLocation, sourceDirname, modules: moduleDescriptors, scopes, ...(patterns.length > 0 ? { patterns } : {}), parsers, types, policy: /** @type {SomePackagePolicy} */ (packagePolicy), }; } return { // TODO graceful migration from tags to conditions // https://github.com/endojs/endo/issues/2388 tags: [...conditions], entry: { compartment: /** @type {FileUrlString} */ (entryPackageLocation), module: entryModuleSpecifier, }, compartments, }; }; /** * @param {Pick<MapNodeModulesOptions, * 'languageForExtension' | * 'moduleLanguageForExtension' | * 'commonjsLanguageForExtension' | * 'workspaceLanguageForExtension' | * 'workspaceModuleLanguageForExtension' | * 'workspaceCommonjsLanguageForExtension' | * 'languages' * >} options */ const makeLanguageOptions = ({ languageForExtension: additionalLanguageForExtension = {}, moduleLanguageForExtension: additionalModuleLanguageForExtension = {}, commonjsLanguageForExtension: additionalCommonjsLanguageForExtension = {}, workspaceLanguageForExtension: additionalWorkspaceLanguageForExtension = {}, workspaceModuleLanguageForExtension: additionalWorkspaceModuleLanguageForExtension = {}, workspaceCommonjsLanguageForExtension: additionalWorkspaceCommonjsLanguageForExtension = {}, languages: additionalLanguages = [], }) => { const commonjsLanguageForExtension = { ...defaultLanguageForExtension, ...additionalLanguageForExtension, ...defaultCommonjsLanguageForExtension, ...additionalCommonjsLanguageForExtension, }; const moduleLanguageForExtension = { ...defaultLanguageForExtension, ...additionalLanguageForExtension, ...defaultModuleLanguageForExtension, ...additionalModuleLanguageForExtension, }; const workspaceCommonjsLanguageForExtension = { ...defaultLanguageForExtension, ...additionalLanguageForExtension, ...defaultCommonjsLanguageForExtension, ...additionalCommonjsLanguageForExtension, ...additionalWorkspaceLanguageForExtension, ...additionalWorkspaceCommonjsLanguageForExtension, }; const workspaceModuleLanguageForExtension = { ...defaultLanguageForExtension, ...additionalLanguageForExtension, ...defaultModuleLanguageForExtension, ...additionalModuleLanguageForExtension, ...additionalWorkspaceLanguageForExtension, ...additionalWorkspaceModuleLanguageForExtension, }; const languages = new Set([ ...values(moduleLanguageForExtension), ...values(commonjsLanguageForExtension), ...values(workspaceModuleLanguageForExtension), ...values(workspaceCommonjsLanguageForExtension), ...additionalLanguages, ]); return { languages, commonjsLanguageForExtension, moduleLanguageForExtension, workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, }; }; /** * Creates a `Node` in `graph` corresponding to the "attenuators" Compartment. * * Only does so if `policy` is provided. * * @param {Graph} graph Graph * @param {Node} entryNode Entry node of the grpah * @param {SomePolicy} [policy] * @throws If there's already a `Node` in `graph` for the "attenuators" * Compartment * @returns {void} */ const makeAttenuatorsNode = (graph, entryNode, policy) => { if (policy) { assertPolicy(policy); assert( graph[ATTENUATORS_COMPARTMENT] === undefined, `${q(ATTENUATORS_COMPARTMENT)} is a reserved compartment name`, ); graph[ATTENUATORS_COMPARTMENT] = { ...entryNode, internalAliases: {}, externalAliases: {}, packageDescriptor: { name: ATTENUATORS_COMPARTMENT }, name: ATTENUATORS_COMPARTMENT, }; } }; /** * Transforms a `Graph` into a readonly `FinalGraph`, in preparation for * conversion to a `CompartmentDescriptor`. * * @param {Graph} graph Graph * @param {LogicalPathGraph} logicalPathGraph Logical path graph * @param {FileUrlString} entryPackageLocation Entry package location * @param {CanonicalNameMap} canonicalNameMap Mapping of canonical names to `Node` names (keys in `graph`) * @returns {Readonly<FinalGraph>} */ const finalizeGraph = ( graph, logicalPathGraph, entryPackageLocation, canonicalNameMap, ) => { const shortestPath = makeShortestPath(logicalPathGraph); // neither the entry package nor the attenuators compartment have a path; omit const { [ATTENUATORS_COMPARTMENT]: attenuatorsNode, [entryPackageLocation]: entryNode, ...subgraph } = graph; /** @type {FinalGraph} */ const finalGraph = create(null); /** @type {Readonly<FinalNode>} */ finalGraph[entryPackageLocation] = freeze({ ...entryNode, label: generateCanonicalName({ isEntry: true, path: [], }), }); canonicalNameMap.set(ENTRY_COMPARTMENT, entryPackageLocation); if (attenuatorsNode) { /** @type {Readonly<FinalNode>} */ finalGraph[ATTENUATORS_COMPARTMENT] = freeze({ ...attenuatorsNode, label: generateCanonicalName({ name: ATTENUATORS_COMPARTMENT, path: [], }), }); } const subgraphEntries = /** @type {[FileUrlString, Node][]} */ ( entries(subgraph) ); for (const [location, node] of subgraphEntries) { const shortestLogicalPath = shortestPath(entryPackageLocation, location); // the first element will always be the root package location; this is omitted from the path. shortestLogicalPath.shift(); const path = shortestLogicalPath.map(location => graph[location].name); const canonicalName = generateCanonicalName({ path }); /** @type {Readonly<FinalNode>} */ const finalNode = freeze({ ...node, label: canonicalName, }); canonicalNameMap.set(canonicalName, location); finalGraph[location] = finalNode; } for (const node of values(finalGraph)) { Object.freeze(node); } return freeze(finalGraph); }; /** * Returns an array of "issue" objects if any resources referenced in `policy` * are unknown. * * @param {Set<CanonicalName>} canonicalNames Set of all known canonical names * @param {SomePolicy} policy Policy to validate * @returns {Array<{canonicalName: CanonicalName, message: string, path: * string[], suggestion?: CanonicalName}>} Array of issue objects, or `undefined` if no issues were * found */ const validatePolicyResources = (canonicalNames, policy) => { /** * Finds a suggestion for `badName` if it is a suffix of any * canonical name in `canonicalNames`. * * @param {string} badName Unknown canonical name * @returns {CanonicalName | undefined} */ const findSuggestion = badName => { for (const canonicalName of canonicalNames) { if (canonicalName.endsWith(`>${badName}`)) { return canonicalName; } } return undefined; }; /** @type {Array<{canonicalName: CanonicalName, message: string, path: string[], suggestion?: CanonicalName}>} */ const issues = []; for (const [resourceName, resourcePolicy] of entries( policy.resources ?? {}, )) { if (!canonicalNames.has(resourceName)) { const issueMessage = `Resource ${q(resourceName)} was not found`; const suggestion = findSuggestion(resourceName); const issue = { canonicalName: resourceName, message: issueMessage, path: ['resources', resourceName], }; if (suggestion) { issue.suggestion = suggestion; } issues.push(issue); } if (typeof resourcePolicy?.packages === 'object') { for (const packageName of keys(resourcePolicy.packages)) { if (!canonicalNames.has(packageName)) { const issueMessage = `Resource ${q(packageName)} from resource ${q(resourceName)} was not found`; const suggestion = findSuggestion(packageName); const issue = { canonicalName: packageName, message: issueMessage, path: ['resources', resourceName, 'packages', packageName], }; if (suggestion) { issue.suggestion = suggestion; } issues.push(issue); } } } } return issues; }; /** * @param {ReadFn | ReadPowers<FileUrlString> | MaybeReadPowers<FileUrlString>} readPowers * @param {FileUrlString} entryPackageLocation * @param {Set<string>} conditionsOption * @param {PackageDescriptor} packageDescriptor * @param {string} entryModuleSpecifier * @param {CompartmentMapForNodeModulesOptions} [options] * @returns {Promise<PackageCompartmentMapDescriptor>} */ export const compartmentMapForNodeModules_ = async ( readPowers, entryPackageLocation, conditionsOption, packageDescriptor, entryModuleSpecifier, options = {}, ) => { const { dev = false, commonDependencies = {}, policy, strict = false, log = noop, unknownCanonicalNameHook, packageDataHook, packageDependenciesHook, additionalLocations = [], } = options; for (const { location: additionalLocation } of additionalLocations) { try { assertFileUrlString(additionalLocation); } catch (error) { throw new TypeError( `Invalid additional location: ${q(additionalLocation)}; must be a file URL string`, ); } } const { maybeRead, canonical } = unpackReadPowers(readPowers); const languageOptions = makeLanguageOptions(options); const conditions = new Set(conditionsOption || []); /** * This graph will contain nodes for each package location (a * {@link FileUrlString}) and edges representing dependencies between packages. * * @type {LogicalPathGraph} */ const logicalPathGraph = new GenericGraph(); // dev is only set for the entry package, and implied by the development // condition. const { graph, readDescriptor } = await graphPackages( maybeRead, canonical, entryPackageLocation, conditions, packageDescriptor, dev || (conditions && conditions.has('development')), commonDependencies, languageOptions, strict, logicalPathGraph, { log, policy, packageDependenciesHook }, ); // Graph additional package locations that are not reachable from the entry's // dependency tree (e.g., a project root package when the entry is a tool // binary in node_modules). // NOTE: This operation could theoretically be run safely in parallel, but the // performance benefits are minimal given the expected number of additional // locations. for (const { location: additionalLocation, modules: additionalModules, } of additionalLocations) { // eslint-disable-next-line no-await-in-loop const additionalDescriptor = await readDescriptor(`${additionalLocation}/`); if (additionalDescriptor === undefined) { log( `WARNING: additionalLocations entry at ${q(additionalLocation)} has no package.json; skipping`, ); // eslint-disable-next-line no-continue continue; } assertPackageDescriptor(additionalDescriptor); logicalPathGraph.addNode(additionalLocation); logicalPathGraph.addEdge(entryPackageLocation, additionalLocation); // eslint-disable-next-line no-await-in-loop await graphPackage( additionalDescriptor.name, readDescriptor, canonical, graph, { packageLocation: additionalLocation, packageDescriptor: additionalDescriptor, }, conditions, true, languageOptions, strict, logicalPathGraph, { commonDependencyDescriptors: {}, log, packageDependenciesHook, policy }, ); if (additionalModules && graph[additionalLocation]) { const node = graph[additionalLocation]; for (const modulePath of additionalModules) { node.externalAliases[modulePath] = modulePath; } } } makeAttenuatorsNode(graph, graph[entryPackageLocation], policy); /** * @type {CanonicalNameMap} */ const canonicalNameMap = new Map(); const finalGraph = finalizeGraph( graph, logicalPathGraph, entryPackageLocation, canonicalNameMap, ); // if policy exists, cross-reference the policy "resources" against the list // of known canonical names and fire the `unknownCanonicalName` hook for each // unknown resource, if found if (policy) { const canonicalNames = new Set(canonicalNameMap.keys()); const issues = validatePolicyResources(canonicalNames, policy) ?? []; // Call default handler first if policy exists for (const { message, canonicalName, path, suggestion } of issues) { const hookInput = { canonicalName, message, path, log, }; if (suggestion) { hookInput.suggestion = suggestion; } defaultUnknownCanonicalNameHandler(hookInput); // Then call user-provided hook if it exists if (unknownCanonicalNameHook) { unknownCanonicalNameHook(hookInput); } } } // Fire packageData hook with all package data before translateGraph if (packageDataHook) { const packageData = /** @type {Map<PackageCompartmentDescriptorName, PackageData>} */ ( new Map( values(finalGraph).map(node => [ node.label, { name: node.name, packageDescriptor: node.packageDescriptor, location: node.location, canonicalName: node.label, }, ]), ) ); packageDataHook({ packageData, log, }); } const compartmentMap = translateGraph( entryPackageLocation, entryModuleSpecifier, finalGraph, conditions, { policy, log, packageDependenciesHook }, ); return compartmentMap; }; /** * Creates a {@link CompartmentMapDescriptor} from the module at * `moduleLocation`, considering dependencies found in `node_modules`. * * Locates the {@link PackageDescriptor} for the module at `moduleLocation` * * @param {ReadFn | ReadPowers<FileUrlString> | MaybeReadPowers<FileUrlString>} readPowers * @param {string} moduleLocation * @param {MapNodeModulesOptions} [options] * @returns {Promise<PackageCompartmentMapDescriptor>} */ export const mapNodeModules = async ( readPowers, moduleLocation, { tags = new Set(), conditions = tags, log = noop, unknownCanonicalNameHook, packageDataHook, packageDependenciesHook, policy, ...otherOptions } = {}, ) => { const { packageLocation, packageDescriptorText, packageDescriptorLocation, moduleSpecifier, } = await search(readPowers, moduleLocation, { log }); const packageDescriptor = /** @type {typeof parseLocatedJson<unknown>} */ ( parseLocatedJson )(packageDescriptorText, packageDescriptorLocation); assertPackageDescriptor(packageDescriptor); assertPackageDescriptorHasName(packageDescriptor, packageDescriptorLocation); assertFileUrlString(packageLocation); return compartmentMapForNodeModules_( readPowers, packageLocation, conditions, packageDescriptor, moduleSpecifier, { log, policy, unknownCanonicalNameHook, packageDependenciesHook, packageDataHook, ...otherOptions, }, ); }; /** * @deprecated Use {@link mapNodeModules} instead. */ export const compartmentMapForNodeModules = compartmentMapForNodeModules_;