diff --git a/packages/compartment-mapper/NEWS.md b/packages/compartment-mapper/NEWS.md index 71bb592244..cdf7bae0ad 100644 --- a/packages/compartment-mapper/NEWS.md +++ b/packages/compartment-mapper/NEWS.md @@ -2,6 +2,32 @@ User-visible changes to `@endo/compartment-mapper`: # Next release +- **Breaking:** `CompartmentMapDescriptor` no longer has a `path` property. +- **Breaking:** `CompartmentMapDescriptor`'s `label` property is now a + _canonical name_ (a string of one or more npm package names separated by `>`). +- **Breaking:** The `CompartmentMapDescriptor` returned by `captureFromMap()` + now uses canonical names as the keys in its `compartments` property. +- Breaking types: `CompartmentMapDescriptor`, `CompartmentDescriptor`, + `ModuleConfiguration` (renamed from `ModuleDescriptor`) and `ModuleSource` + have all been narrowed into discrete subtypes. +- `captureFromMap()`, `loadLocation()` and `importLocation()` now accept a + `moduleSourceHook` option. This hook is called when processing each module + source, receiving the module source data (location, language, bytes, or error + information) and the canonical name of the containing package. +- `captureFromMap()` now accepts a `packageConnectionsHook` option. This hook is + called for each retained compartment with its canonical name and the set of + canonical names of compartments it links to (its connections). Useful for + analyzing or visualizing the dependency graph. +- `mapNodeModules()`, `loadLocation()`, `importLocation()`, `makeScript()`, + `makeFunctor()`, and `writeScript()` now accept the following hook options: + - `unknownCanonicalNameHook`: Called for each canonical name mentioned in + policy but not found in the compartment map. Useful for detecting policy + misconfigurations. + - `packageDependenciesHook`: Called for each package with its set of + dependencies. Can return partial updates to modify the dependencies, + enabling dependency filtering or injection based on policy. + - `packageDataHook`: Called once with data about all packages found while + crawling `node_modules`, just prior to creation of a compartment map. - When dynamic requires are enabled via configuration, execution now takes policy into consideration when no other relationship (for example, a dependent/dependee relationship) between two Compartments exists. When policy diff --git a/packages/compartment-mapper/package.json b/packages/compartment-mapper/package.json index 9f39219f57..75a5752a25 100644 --- a/packages/compartment-mapper/package.json +++ b/packages/compartment-mapper/package.json @@ -68,6 +68,7 @@ "ses": "workspace:^" }, "devDependencies": { + "@endo/env-options": "workspace:^", "ava": "catalog:dev", "c8": "catalog:dev", "eslint": "catalog:dev", diff --git a/packages/compartment-mapper/src/archive-lite.js b/packages/compartment-mapper/src/archive-lite.js index 088c90ba99..97bec7ee5b 100644 --- a/packages/compartment-mapper/src/archive-lite.js +++ b/packages/compartment-mapper/src/archive-lite.js @@ -1,4 +1,5 @@ -/* Provides functions to create an archive (zip file with a +/** + * Provides functions to create an archive (zip file with a * compartment-map.json) from a partially completed compartment map (it must * mention all packages/compartments as well as inter-compartment references * but does not contain an entry for every module reachable from its entry @@ -26,6 +27,8 @@ * In fruition of https://github.com/endojs/endo/issues/400, we will be able to * use original source archives on XS and Node.js, but not on the web until the * web platform makes further progress on virtual module loaers. + * + * @module */ /* eslint no-shadow: 0 */ @@ -36,8 +39,10 @@ * ArchiveResult, * ArchiveWriter, * CaptureSourceLocationHook, - * CompartmentMapDescriptor, + * CompartmentsRenameFn, + * FileUrlString, * HashPowers, + * PackageCompartmentMapDescriptor, * ReadFn, * ReadPowers, * Sources, @@ -55,19 +60,12 @@ import { import { unpackReadPowers } from './powers.js'; import { detectAttenuators } from './policy.js'; import { digestCompartmentMap } from './digest.js'; +import { stringCompare } from './compartment-map.js'; +import { ATTENUATORS_COMPARTMENT } from './policy-format.js'; const textEncoder = new TextEncoder(); -const { assign, create, freeze } = Object; - -/** - * @param {string} rel - a relative URL - * @param {string} abs - a fully qualified URL - * @returns {string} - */ -const resolveLocation = (rel, abs) => new URL(rel, abs).toString(); - -const { keys } = Object; +const { assign, create, freeze, keys } = Object; /** * @param {ArchiveWriter} archive @@ -77,12 +75,10 @@ const addSourcesToArchive = async (archive, sources) => { await null; for (const compartment of keys(sources).sort()) { const modules = sources[compartment]; - const compartmentLocation = resolveLocation(`${compartment}/`, 'file:///'); for (const specifier of keys(modules).sort()) { - const { bytes, location } = modules[specifier]; - if (location !== undefined) { - const moduleLocation = resolveLocation(location, compartmentLocation); - const path = new URL(moduleLocation).pathname.slice(1); // elide initial "/" + if ('location' in modules[specifier]) { + const { bytes, location } = modules[specifier]; + const path = `${compartment}/${location}`; if (bytes !== undefined) { // eslint-disable-next-line no-await-in-loop await archive.write(path, bytes); @@ -100,8 +96,8 @@ const captureSourceLocations = async (sources, captureSourceLocation) => { for (const compartmentName of keys(sources).sort()) { const modules = sources[compartmentName]; for (const moduleSpecifier of keys(modules).sort()) { - const { sourceLocation } = modules[moduleSpecifier]; - if (sourceLocation !== undefined) { + if ('sourceLocation' in modules[moduleSpecifier]) { + const { sourceLocation } = modules[moduleSpecifier]; captureSourceLocation(compartmentName, moduleSpecifier, sourceLocation); } } @@ -109,18 +105,69 @@ const captureSourceLocations = async (sources, captureSourceLocation) => { }; /** - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {Sources} sources * @returns {ArchiveResult} */ export const makeArchiveCompartmentMap = (compartmentMap, sources) => { + /** @type {CompartmentsRenameFn} */ + const renameCompartments = compartments => { + /** @type {Record} */ + const compartmentRenames = create(null); + + /** + * Get the new name of format `packageName-v${version}` compartments (except + * for the attenuators compartment) + * @param {string} name + * @param {string} version + * @returns {string} + */ + const getCompartmentName = (name, version) => { + const compartment = compartments[name]; + return ATTENUATORS_COMPARTMENT === compartment.name + ? compartment.name + : `${compartment.name}-v${version}`; + }; + + // The sort below combines two comparators to avoid depending on sort + // stability, which became standard as recently as 2019. + // If that date seems quaint, please accept my regards from the distant past. + // We are very proud of you. + const compartmentsByLabel = + /** @type {Array<{name: FileUrlString, packageName: string, compartmentName: string}>} */ ( + Object.entries(compartments) + .map(([name, compartment]) => ({ + name, + packageName: compartments[name].name, + compartmentName: getCompartmentName(name, compartment.version), + })) + .sort((a, b) => stringCompare(a.compartmentName, b.compartmentName)) + ); + + /** @type {string|undefined} */ + let prev; + let index = 1; + for (const { name, packageName, compartmentName } of compartmentsByLabel) { + if (packageName === prev) { + compartmentRenames[name] = `${compartmentName}-n${index}`; // Added numeric suffix for duplicates + index += 1; + } else { + compartmentRenames[name] = compartmentName; + prev = packageName; + index = 1; + } + } + return compartmentRenames; + }; + const { compartmentMap: archiveCompartmentMap, sources: archiveSources, oldToNewCompartmentNames, newToOldCompartmentNames, compartmentRenames, - } = digestCompartmentMap(compartmentMap, sources); + } = digestCompartmentMap(compartmentMap, sources, { renameCompartments }); + return { archiveCompartmentMap, archiveSources, @@ -130,9 +177,11 @@ export const makeArchiveCompartmentMap = (compartmentMap, sources) => { }; }; +const noop = () => {}; + /** * @param {ReadFn | ReadPowers} powers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ArchiveLiteOptions} [options] * @returns {Promise<{sources: Sources, compartmentMapBytes: Uint8Array, sha512?: string}>} */ @@ -146,6 +195,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => { policy = undefined, sourceMapHook = undefined, parserForLanguage: parserForLanguageOption = {}, + log: _log = noop, } = options; const parserForLanguage = freeze( @@ -179,6 +229,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => { importHook: consolidatedExitModuleImportHook, sourceMapHook, }); + // Induce importHook to record all the necessary modules to import the given module specifier. const { compartment, attenuatorsCompartment } = link(compartmentMap, { resolve, @@ -229,7 +280,7 @@ const digestFromMap = async (powers, compartmentMap, options = {}) => { /** * @param {ReadFn | ReadPowers} powers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ArchiveLiteOptions} [options] * @returns {Promise<{bytes: Uint8Array, sha512?: string}>} */ @@ -254,7 +305,7 @@ export const makeAndHashArchiveFromMap = async ( /** * @param {ReadFn | ReadPowers} powers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ArchiveLiteOptions} [options] * @returns {Promise} */ @@ -269,7 +320,7 @@ export const makeArchiveFromMap = async (powers, compartmentMap, options) => { /** * @param {ReadFn | ReadPowers} powers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ArchiveLiteOptions} [options] * @returns {Promise} */ @@ -284,7 +335,7 @@ export const mapFromMap = async (powers, compartmentMap, options) => { /** * @param {HashPowers} powers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ArchiveLiteOptions} [options] * @returns {Promise} */ @@ -302,7 +353,7 @@ export const hashFromMap = async (powers, compartmentMap, options) => { * @param {WriteFn} write * @param {ReadFn | ReadPowers} readPowers * @param {string} archiveLocation - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ArchiveLiteOptions} [options] */ export const writeArchiveFromMap = async ( diff --git a/packages/compartment-mapper/src/archive.js b/packages/compartment-mapper/src/archive.js index 9bb7708436..80ce846932 100644 --- a/packages/compartment-mapper/src/archive.js +++ b/packages/compartment-mapper/src/archive.js @@ -24,6 +24,7 @@ * ReadPowers, * HashPowers, * WriteFn, + * LogFn, * } from './types.js' */ @@ -169,6 +170,9 @@ export const mapLocation = async (powers, moduleLocation, options = {}) => { }); }; +/** @type {LogFn} */ +const noop = () => {}; + /** * @param {HashPowers} powers * @param {string} moduleLocation @@ -191,10 +195,12 @@ export const hashLocation = async (powers, moduleLocation, options = {}) => { workspaceLanguageForExtension, workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, + log = noop, ...otherOptions } = assignParserForLanguage(options); const compartmentMap = await mapNodeModules(powers, moduleLocation, { + log, dev, strict, conditions, @@ -212,6 +218,7 @@ export const hashLocation = async (powers, moduleLocation, options = {}) => { return hashFromMap(powers, compartmentMap, { parserForLanguage, policy, + log, ...otherOptions, }); }; diff --git a/packages/compartment-mapper/src/bundle-lite.js b/packages/compartment-mapper/src/bundle-lite.js index 4ea4ff12a2..ed78021b0f 100644 --- a/packages/compartment-mapper/src/bundle-lite.js +++ b/packages/compartment-mapper/src/bundle-lite.js @@ -7,9 +7,9 @@ * } from 'ses' * @import { * BundleOptions, - * CompartmentDescriptor, - * CompartmentMapDescriptor, * CompartmentSources, + * PackageCompartmentDescriptors, + * PackageCompartmentMapDescriptor, * MaybeReadPowers, * ReadFn, * ReadPowers, @@ -110,6 +110,11 @@ import { defaultParserForLanguage } from './archive-parsers.js'; import mjsSupport from './bundle-mjs.js'; import cjsSupport from './bundle-cjs.js'; import jsonSupport from './bundle-json.js'; +import { + isErrorModuleSource, + isExitModuleSource, + isLocalModuleSource, +} from './guards.js'; const { quote: q } = assert; @@ -144,7 +149,7 @@ null, * The first modules are place-holders for the modules that exit * the compartment map to the host's module system. * - * @param {Record} compartmentDescriptors + * @param {PackageCompartmentDescriptors} compartmentDescriptors * @param {Record} compartmentSources * @param {string} entryCompartmentName * @param {string} entryModuleSpecifier @@ -188,28 +193,18 @@ const sortedModules = ( const source = compartmentSources[compartmentName][moduleSpecifier]; if (source !== undefined) { - const { record, parser, deferredError, bytes, sourceDirname, exit } = - source; - if (exit !== undefined) { - return exit; - } - assert( - bytes !== undefined, - `No bytes for ${moduleSpecifier} in ${compartmentName}`, - ); - assert( - parser !== undefined, - `No parser for ${moduleSpecifier} in ${compartmentName}`, - ); - assert( - sourceDirname !== undefined, - `No sourceDirname for ${moduleSpecifier} in ${compartmentName}`, - ); - if (deferredError) { + if (isErrorModuleSource(source)) { throw Error( - `Cannot bundle: encountered deferredError ${deferredError}`, + `Cannot bundle: encountered deferredError ${source.deferredError}`, ); } + if (isExitModuleSource(source)) { + return source.exit; + } + if (!isLocalModuleSource(source)) { + throw new TypeError(`Unexpected source type ${JSON.stringify(source)}`); + } + const { record, parser, bytes, sourceDirname } = source; if (record) { const { imports = [], reexports = [] } = /** @type {PrecompiledStaticModuleInterface} */ (record); @@ -309,7 +304,7 @@ const getBundlerKitForModule = (module, params) => { /** * @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {BundleOptions} [options] * @returns {Promise} */ @@ -651,7 +646,7 @@ ${m.bundlerKit.getFunctor()}`, /** * @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {BundleOptions} [options] * @returns {Promise} */ diff --git a/packages/compartment-mapper/src/bundle.js b/packages/compartment-mapper/src/bundle.js index 4ccb7ae259..29c82e11e5 100644 --- a/packages/compartment-mapper/src/bundle.js +++ b/packages/compartment-mapper/src/bundle.js @@ -7,9 +7,9 @@ * } from 'ses' * @import { * BundleOptions, - * CompartmentDescriptor, - * CompartmentMapDescriptor, * CompartmentSources, + * PackageCompartmentDescriptors, + * PackageCompartmentMapDescriptor, * MaybeReadPowers, * ReadFn, * ReadPowers, @@ -112,6 +112,11 @@ import { defaultParserForLanguage } from './archive-parsers.js'; import mjsSupport from './bundle-mjs.js'; import cjsSupport from './bundle-cjs.js'; import jsonSupport from './bundle-json.js'; +import { + isErrorModuleSource, + isExitModuleSource, + isLocalModuleSource, +} from './guards.js'; const textEncoder = new TextEncoder(); @@ -148,7 +153,7 @@ null, * The first modules are place-holders for the modules that exit * the compartment map to the host's module system. * - * @param {Record} compartmentDescriptors + * @param {PackageCompartmentDescriptors} compartmentDescriptors * @param {Record} compartmentSources * @param {string} entryCompartmentName * @param {string} entryModuleSpecifier @@ -192,28 +197,18 @@ const sortedModules = ( const source = compartmentSources[compartmentName][moduleSpecifier]; if (source !== undefined) { - const { record, parser, deferredError, bytes, sourceDirname, exit } = - source; - if (exit !== undefined) { - return exit; - } - assert( - bytes !== undefined, - `No bytes for ${moduleSpecifier} in ${compartmentName}`, - ); - assert( - parser !== undefined, - `No parser for ${moduleSpecifier} in ${compartmentName}`, - ); - assert( - sourceDirname !== undefined, - `No sourceDirname for ${moduleSpecifier} in ${compartmentName}`, - ); - if (deferredError) { + if (isErrorModuleSource(source)) { throw Error( - `Cannot bundle: encountered deferredError ${deferredError}`, + `Cannot bundle: encountered deferredError ${source.deferredError}`, ); } + if (isExitModuleSource(source)) { + return source.exit; + } + if (!isLocalModuleSource(source)) { + throw new TypeError(`Unexpected source type ${JSON.stringify(source)}`); + } + const { record, parser, bytes, sourceDirname } = source; if (record) { const { imports = [], reexports = [] } = /** @type {PrecompiledStaticModuleInterface} */ (record); @@ -314,7 +309,7 @@ const getBundlerKitForModule = (module, params) => { /** * @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {BundleOptions} [options] * @returns {Promise} */ @@ -657,7 +652,7 @@ ${m.bundlerKit.getFunctor()}`, /** * @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {BundleOptions} [options] * @returns {Promise} */ diff --git a/packages/compartment-mapper/src/capture-lite.js b/packages/compartment-mapper/src/capture-lite.js index 5ad476dae8..7e93f8a701 100644 --- a/packages/compartment-mapper/src/capture-lite.js +++ b/packages/compartment-mapper/src/capture-lite.js @@ -36,14 +36,13 @@ * @import { * CaptureLiteOptions, * CaptureResult, - * CompartmentMapDescriptor, + * PackageCompartmentMapDescriptor, * PreloadOption, - * LogFn, - * LogOptions, - * PolicyOption, + * MakeLoadCompartmentsOptions, * ReadFn, * ReadPowers, * Sources, + * CaptureCompartmentMapOptions, * } from './types.js' */ @@ -54,12 +53,18 @@ import { } from './import-hook.js'; import { link } from './link.js'; import { resolve } from './node-module-specifier.js'; -import { ATTENUATORS_COMPARTMENT } from './policy-format.js'; +import { ATTENUATORS_COMPARTMENT, ENTRY_COMPARTMENT } from './policy-format.js'; import { detectAttenuators } from './policy.js'; import { unpackReadPowers } from './powers.js'; -const { freeze, assign, create, keys } = Object; -const { stringify: q } = JSON; +const { freeze, assign, create, keys, entries } = Object; +const { quote: q } = assert; +const noop = () => {}; + +/** + * The name of the module to preload if no entry is provided in a {@link PreloadOption} array + */ +const DEFAULT_PRELOAD_ENTRY = '.'; const DefaultCompartment = /** @type {typeof Compartment} */ ( // @ts-expect-error globalThis.Compartment is definitely on globalThis. @@ -67,18 +72,26 @@ const DefaultCompartment = /** @type {typeof Compartment} */ ( ); /** - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {Sources} sources + * @param {CaptureCompartmentMapOptions} options * @returns {CaptureResult} */ -const captureCompartmentMap = (compartmentMap, sources) => { +const captureCompartmentMap = ( + compartmentMap, + sources, + { packageConnectionsHook, log = noop } = {}, +) => { const { compartmentMap: captureCompartmentMap, sources: captureSources, newToOldCompartmentNames, compartmentRenames, oldToNewCompartmentNames, - } = digestCompartmentMap(compartmentMap, sources); + } = digestCompartmentMap(compartmentMap, sources, { + packageConnectionsHook, + log, + }); return { captureCompartmentMap, captureSources, @@ -88,22 +101,12 @@ const captureCompartmentMap = (compartmentMap, sources) => { }; }; -/** - * @type {LogFn} - */ -const noop = () => {}; - -/** - * The name of the module to preload if no entry is provided in a {@link PreloadOption} array - */ -const DEFAULT_PRELOAD_ENTRY = '.'; - /** * Factory for a function that loads compartments. * - * @param {CompartmentMapDescriptor} compartmentMap Compartment map + * @param {PackageCompartmentMapDescriptor} compartmentMap Compartment map * @param {Sources} sources Sources - * @param {LogOptions & PolicyOption & PreloadOption} [options] + * @param {MakeLoadCompartmentsOptions} [options] * @returns {(linkedCompartments: Record, entryCompartment: Compartment, attenuatorsCompartment: Compartment) => Promise} */ const makePreloader = ( @@ -116,9 +119,8 @@ const makePreloader = ( } = compartmentMap; /** - * Given {@link CompartmentDescriptor CompartmentDescriptors}, loads any which - * a) are present in the {@link preload forceLoad array}, and b) have not - * yet been loaded. + * Iterates over canonical names in the {@link preload preload array} + * and loads those which have not yet been loaded. * * Will not load the "attenuators" `Compartment`, nor will it load any * `Compartment` having a non-empty value in `sources` (since it is presumed @@ -131,32 +133,41 @@ const makePreloader = ( const preloader = async compartments => { /** @type {[compartmentName: string, compartment: Compartment, moduleSpecifier: string][]} */ const compartmentsToLoad = []; + for (const preloadValue of preload) { /** @type {string} */ - let compartmentName; + let canonicalName; /** @type {string} */ let entry; if (typeof preloadValue === 'string') { - compartmentName = preloadValue; + canonicalName = preloadValue; entry = DEFAULT_PRELOAD_ENTRY; } else { - compartmentName = preloadValue.compartment; + canonicalName = preloadValue.compartment; entry = preloadValue.entry; } // skip; should already be loaded if ( - compartmentName === ATTENUATORS_COMPARTMENT || - compartmentName === compartmentMap.entry.compartment + canonicalName !== ATTENUATORS_COMPARTMENT && + canonicalName !== ENTRY_COMPARTMENT ) { - // skip - } else { - const compartmentDescriptor = - compartmentMap.compartments[compartmentName]; + // TODO: A mapping of canonical name to compartment name is generated by + // mapNodeModules(), but it is not exposed. Expose it as an option on + // mapNodeModules() to be mutated and allow it as an option to + // captureFromMap() so we do not have to do this extra work. The data we + // need is _also_ generated by digestCompartmentMap() as the + // newToOldCompartmentNames property, but we cannot time-travel. + const [compartmentName, compartmentDescriptor] = entries( + compartmentMap.compartments, + ).find(([, compartment]) => compartment.label === canonicalName) ?? [ + canonicalName, + compartmentMap.compartments[canonicalName], + ]; if (!compartmentDescriptor) { throw new ReferenceError( - `Failed attempting to force-load unknown compartment ${q(compartmentName)}`, + `Failed attempting to preload unknown compartment ${q(canonicalName)}`, ); } @@ -164,17 +175,17 @@ const makePreloader = ( if (keys(compartmentSources).length) { log( - `Refusing to force-load Compartment ${q(compartmentName)}; already loaded`, + `Refusing to preload Compartment ${q(canonicalName)}; already loaded`, ); } else { const compartment = compartments[compartmentName]; if (!compartment) { throw new ReferenceError( - `No compartment found for ${q(compartmentName)}`, + `No compartment found for ${q(canonicalName)}`, ); } - compartmentsToLoad.push([compartmentName, compartment, entry]); + compartmentsToLoad.push([canonicalName, compartment, entry]); } } } @@ -242,7 +253,7 @@ const makePreloader = ( * policy generation. * * @param {ReadFn | ReadPowers} readPowers Powers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {CaptureLiteOptions} [options] * @returns {Promise} */ @@ -263,6 +274,8 @@ export const captureFromMap = async ( Compartment: CompartmentOption = DefaultCompartment, log = noop, _preload: preload = [], + packageConnectionsHook, + moduleSourceHook, } = options; const parserForLanguage = freeze( assign(create(null), parserForLanguageOption), @@ -300,6 +313,7 @@ export const captureFromMap = async ( entryModuleSpecifier, importHook: consolidatedExitModuleImportHook, sourceMapHook, + moduleSourceHook, }); // Induce importHook to record all the necessary modules to import the given module specifier. @@ -323,5 +337,8 @@ export const captureFromMap = async ( attenuatorsCompartment, ); - return captureCompartmentMap(compartmentMap, sources); + return captureCompartmentMap(compartmentMap, sources, { + packageConnectionsHook, + log, + }); }; diff --git a/packages/compartment-mapper/src/compartment-map.js b/packages/compartment-mapper/src/compartment-map.js index 32b5910a75..88ab3b61fd 100644 --- a/packages/compartment-mapper/src/compartment-map.js +++ b/packages/compartment-mapper/src/compartment-map.js @@ -1,14 +1,43 @@ /* Validates a compartment map against its schema. */ -import { assertPackagePolicy } from './policy-format.js'; +import { + assertPackagePolicy, + ATTENUATORS_COMPARTMENT, + ENTRY_COMPARTMENT, +} from './policy-format.js'; -/** @import {CompartmentMapDescriptor} from './types.js' */ +/** + * @import { + * FileCompartmentDescriptor, + * FileCompartmentMapDescriptor, + * FileModuleConfiguration, + * CompartmentMapDescriptor, + * EntryDescriptor, + * ModuleConfiguration, + * ExitModuleConfiguration, + * CompartmentModuleConfiguration, + * CompartmentDescriptor, + * ScopeDescriptor, + * BaseModuleConfiguration, + * DigestedCompartmentMapDescriptor, + * PackageCompartmentMapDescriptor, + * PackageCompartmentDescriptor, + * FileUrlString, + * LanguageForExtension, + * LanguageForModuleSpecifier, + * ModuleConfigurationKind, + * ModuleConfigurationKindToType, + * ErrorModuleConfiguration, + * DigestedCompartmentDescriptor} from './types.js' + */ // TODO convert to the new `||` assert style. // Deferred because this file pervasively uses simple template strings rather than // template strings tagged with `assert.details` (aka `X`), and uses // this definition of `q` rather than `assert.quote` const q = JSON.stringify; +const { keys, entries } = Object; +const { isArray } = Array; /** @type {(a: string, b: string) => number} */ // eslint-disable-next-line no-nested-ternary @@ -26,432 +55,886 @@ function* enumerate(iterable) { } } +/** + * Type guard for a string value. + * + * @overload + * @param {unknown} value + * @param {string} keypath + * @param {string} url + * @returns {asserts value is string} + */ + +/** + * Type guard for a string value with a custom assertion failure message. + * + * @overload + * @param {unknown} value + * @param {string} message + * @returns {asserts value is string} + */ + +/** + * Type guard for a string value. + * + * @param {unknown} value + * @param {string} pathOrMessage + * @param {string} url + * @returns {asserts value is string} + */ +const assertString = (value, pathOrMessage, url) => { + const keypath = pathOrMessage; + assert.typeof( + value, + 'string', + `${keypath} in ${q(url)} must be a string; got ${q(value)}`, + ); +}; + +/** + * Asserts the `label` field valid + * + * @param {unknown} allegedLabel + * @param {string} keypath + * @param {string} url + * @returns {asserts alleged is string} + */ +const assertLabel = (allegedLabel, keypath, url) => { + assertString(allegedLabel, keypath, url); + if (allegedLabel === ATTENUATORS_COMPARTMENT) { + return; + } + if (allegedLabel === ENTRY_COMPARTMENT) { + return; + } + assert( + /^(?:@[a-z][a-z0-9-.]*\/)?[a-z][a-z0-9-.]*(?:>(?:@[a-z][a-z0-9-.]*\/)?[a-z][a-z0-9-.]*)*$/.test( + allegedLabel, + ), + `${keypath} must be a canonical name in ${q(url)}; got ${q(allegedLabel)}`, + ); +}; + +/** + * @param {unknown} allegedObject + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedObject is Record} + */ +const assertPlainObject = (allegedObject, keypath, url) => { + const object = Object(allegedObject); + assert( + object === allegedObject && + !isArray(object) && + !(typeof object === 'function'), + `${keypath} must be an object; got ${q(allegedObject)} of type ${q(typeof allegedObject)} in ${q(url)}`, + ); +}; + +/** + * + * @param {unknown} value + * @param {string} keypath + * @param {string} url + * @returns {asserts value is boolean} + */ +const assertBoolean = (value, keypath, url) => { + assert.typeof( + value, + 'boolean', + `${keypath} in ${q(url)} must be a boolean; got ${q(value)}`, + ); +}; + /** * @param {Record} object * @param {string} message */ const assertEmptyObject = (object, message) => { - assert(Object.keys(object).length === 0, message); + assert(keys(object).length === 0, message); }; /** * @param {unknown} conditions * @param {string} url + * @returns {asserts conditions is CompartmentMapDescriptor['tags']} */ const assertConditions = (conditions, url) => { if (conditions === undefined) return; assert( - Array.isArray(conditions), - `conditions must be an array, got ${conditions} in ${q(url)}`, + isArray(conditions), + `conditions must be an array; got ${conditions} in ${q(url)}`, ); for (const [index, value] of enumerate(conditions)) { - assert.typeof( - value, - 'string', - `conditions[${index}] must be a string, got ${value} in ${q(url)}`, - ); + assertString(value, `conditions[${index}]`, url); } }; /** - * @param {Record} allegedModule - * @param {string} path + * @template {Partial} T + * @param {T} allegedModule + * @returns {Omit} + */ +const getModuleConfigurationSpecificProperties = allegedModule => { + const { + __createdBy: _createdBy, + retained: _retained, + deferredError: _deferredError, + ...other + } = allegedModule; + return other; +}; + +/** + * + * @param {Record} allegedModule + * @param {string} keypath * @param {string} url + * @returns {asserts allegedModule is ModuleConfiguration} */ -const assertCompartmentModule = (allegedModule, path, url) => { - const { compartment, module, retained, ...extra } = allegedModule; +const assertBaseModuleConfiguration = (allegedModule, keypath, url) => { + const { deferredError, retained, createdBy } = allegedModule; + if (deferredError !== undefined) { + assertString(deferredError, `${keypath}.deferredError`, url); + } + if (retained !== undefined) { + assertBoolean(retained, `${keypath}.retained`, url); + } + if (createdBy !== undefined) { + assertString(createdBy, `${keypath}.createdBy`, url); + } +}; + +/** + * @param {ModuleConfiguration} moduleDescriptor + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedModule is CompartmentModuleConfiguration} + */ +const assertCompartmentModuleConfiguration = ( + moduleDescriptor, + keypath, + url, +) => { + const { compartment, module, ...extra } = + getModuleConfigurationSpecificProperties( + /** @type {CompartmentModuleConfiguration} */ (moduleDescriptor), + ); assertEmptyObject( extra, - `${path} must not have extra properties, got ${q({ - extra, - compartment, - })} in ${q(url)}`, + `${keypath} must not have extra properties; got ${q(extra)} in ${q(url)}`, ); - assert.typeof( - compartment, - 'string', - `${path}.compartment must be a string, got ${q(compartment)} in ${q(url)}`, - ); - assert.typeof( - module, - 'string', - `${path}.module must be a string, got ${q(module)} in ${q(url)}`, - ); - if (retained !== undefined) { - assert.typeof( - retained, - 'boolean', - `${path}.retained must be a boolean, got ${q(retained)} in ${q(url)}`, - ); - } + + assertString(compartment, `${keypath}.compartment`, url); + assertString(module, `${keypath}.module`, url); }; /** - * @param {Record} allegedModule - * @param {string} path + * @param {ModuleConfiguration} moduleDescriptor + * @param {string} keypath * @param {string} url + * @returns {asserts allegedModule is FileModuleConfiguration} */ -const assertFileModule = (allegedModule, path, url) => { - const { location, parser, sha512, ...extra } = allegedModule; +const assertFileModuleConfiguration = (moduleDescriptor, keypath, url) => { + const { location, parser, sha512, ...extra } = + getModuleConfigurationSpecificProperties( + /** @type {FileModuleConfiguration} */ (moduleDescriptor), + ); assertEmptyObject( extra, - `${path} must not have extra properties, got ${q( - Object.keys(extra), + `${keypath} must not have extra properties; got ${q( + keys(extra), )} in ${q(url)}`, ); - assert.typeof( - location, - 'string', - `${path}.location must be a string, got ${q(location)} in ${q(url)}`, - ); - assert.typeof( - parser, - 'string', - `${path}.parser must be a string, got ${q(parser)} in ${q(url)}`, - ); + if (location !== undefined) { + assertString(location, `${keypath}.location`, url); + } + assertString(parser, `${keypath}.parser`, url); if (sha512 !== undefined) { - assert.typeof( - sha512, - 'string', - `${path}.sha512 must be a string, got ${q(sha512)} in ${q(url)}`, - ); + assertString(sha512, `${keypath}.sha512`, url); } }; /** - * @param {Record} allegedModule - * @param {string} path + * @param {ModuleConfiguration} moduleDescriptor + * @param {string} keypath * @param {string} url + * @returns {asserts allegedModule is ExitModuleConfiguration} */ -const assertExitModule = (allegedModule, path, url) => { - const { exit, ...extra } = allegedModule; +const assertExitModuleConfiguration = (moduleDescriptor, keypath, url) => { + const { exit, ...extra } = getModuleConfigurationSpecificProperties( + /** @type {ExitModuleConfiguration} */ (moduleDescriptor), + ); assertEmptyObject( extra, - `${path} must not have extra properties, got ${q( - Object.keys(extra), + `${keypath} must not have extra properties; got ${q( + keys(extra), )} in ${q(url)}`, ); - assert.typeof( - exit, - 'string', - `${path}.exit must be a string, got ${q(exit)} in ${q(url)}`, - ); + assertString(exit, `${keypath}.exit`, url); }; /** + * + * @param {ModuleConfiguration} moduleDescriptor + * @param {string} keypath + * @param {string} url + * @returns {asserts moduleDescriptor is ErrorModuleConfiguration} + */ +const assertErrorModuleConfiguration = (moduleDescriptor, keypath, url) => { + const { deferredError } = moduleDescriptor; + if (deferredError) { + assertString(deferredError, `${keypath}.deferredError`, url); + } +}; + +/** + * @template {ModuleConfigurationKind[]} Kinds + * @overload * @param {unknown} allegedModule - * @param {string} path + * @param {string} keypath * @param {string} url + * @param {Kinds} kinds + * @returns {asserts allegedModule is ModuleConfigurationKindToType} */ -const assertModule = (allegedModule, path, url) => { - const moduleDescriptor = Object(allegedModule); + +/** + * @overload + * @param {unknown} allegedModule + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedModule is ModuleConfiguration} + */ + +/** + * @param {unknown} allegedModule + * @param {string} keypath + * @param {string} url + * @param {ModuleConfigurationKind[]} kinds + */ +function assertModuleConfiguration(allegedModule, keypath, url, kinds) { + assertPlainObject(allegedModule, keypath, url); + assertBaseModuleConfiguration(allegedModule, keypath, url); + + const finalKinds = + kinds.length > 0 + ? kinds + : /** @type {ModuleConfigurationKind[]} */ ([ + 'compartment', + 'file', + 'exit', + 'error', + ]); + /** @type {Error[]} */ + const errors = []; + for (const kind of finalKinds) { + switch (kind) { + case 'compartment': { + try { + assertCompartmentModuleConfiguration(allegedModule, keypath, url); + } catch (error) { + errors.push(error); + } + break; + } + case 'file': { + try { + assertFileModuleConfiguration(allegedModule, keypath, url); + } catch (error) { + errors.push(error); + } + break; + } + case 'exit': { + try { + assertExitModuleConfiguration(allegedModule, keypath, url); + } catch (error) { + errors.push(error); + } + break; + } + case 'error': { + try { + assertErrorModuleConfiguration(allegedModule, keypath, url); + } catch (error) { + errors.push(error); + } + break; + } + default: + throw new TypeError( + `Unknown module descriptor kind ${q(kind)} in ${q(url)}`, + ); + } + } + assert( - allegedModule === moduleDescriptor && !Array.isArray(moduleDescriptor), - `${path} must be an object, got ${allegedModule} in ${q(url)}`, + errors.length < finalKinds.length, + `invalid module descriptor in ${q(url)} at ${q(keypath)}; expected to match one of ${q(kinds)}: ${errors.map(err => err.message).join('; ')}`, ); +} - const { compartment, module, location, parser, exit, deferredError } = - moduleDescriptor; - if (compartment !== undefined || module !== undefined) { - assertCompartmentModule(moduleDescriptor, path, url); - } else if (location !== undefined || parser !== undefined) { - assertFileModule(moduleDescriptor, path, url); - } else if (exit !== undefined) { - assertExitModule(moduleDescriptor, path, url); - } else if (deferredError !== undefined) { - assert.typeof( - deferredError, - 'string', - `${path}.deferredError must be a string contaiing an error message`, +/** + * @param {unknown} allegedModules + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedModules is Record} + */ +const assertModuleConfigurations = (allegedModules, keypath, url) => { + assertPlainObject(allegedModules, keypath, url); + for (const [key, value] of entries(allegedModules)) { + assertString( + key, + `all keys of ${keypath}.modules must be strings; got ${key} in ${q(url)}`, ); - } else { - assert.fail( - `${path} is not a valid module descriptor, got ${q(allegedModule)} in ${q( - url, - )}`, + assertModuleConfiguration(value, `${keypath}.modules[${q(key)}]`, url); + } +}; + +/** + * @param {unknown} allegedModules + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedModules is Record} + */ +const assertFileModuleConfigurations = (allegedModules, keypath, url) => { + assertPlainObject(allegedModules, keypath, url); + for (const [key, value] of entries(allegedModules)) { + assertString( + key, + `all keys of ${keypath}.modules must be strings; got ${key} in ${q(url)}`, ); + assertModuleConfiguration(value, `${keypath}.modules[${q(key)}]`, url, [ + 'file', + 'compartment', + 'error', + ]); } }; /** * @param {unknown} allegedModules - * @param {string} path + * @param {string} keypath * @param {string} url + * @returns {asserts allegedModules is Record} */ -const assertModules = (allegedModules, path, url) => { - const modules = Object(allegedModules); - assert( - allegedModules === modules || !Array.isArray(modules), - `modules must be an object, got ${q(allegedModules)} in ${q(url)}`, - ); - for (const [key, value] of Object.entries(modules)) { - assertModule(value, `${path}.modules[${q(key)}]`, url); +const assertDigestedModuleConfigurations = (allegedModules, keypath, url) => { + assertPlainObject(allegedModules, keypath, url); + for (const [key, value] of entries(allegedModules)) { + assertString( + key, + `all keys of ${keypath}.modules must be strings; got ${key} in ${q(url)}`, + ); + assertModuleConfiguration(value, `${keypath}.modules[${q(key)}]`, url, [ + 'file', + 'exit', + 'error', + ]); } }; /** * @param {unknown} allegedParsers - * @param {string} path + * @param {string} keypath * @param {string} url + * @returns {asserts allegedParsers is LanguageForExtension} */ -const assertParsers = (allegedParsers, path, url) => { - if (allegedParsers === undefined) { - return; - } - const parsers = Object(allegedParsers); - assert( - allegedParsers === parsers && !Array.isArray(parsers), - `${path}.parsers must be an object, got ${allegedParsers} in ${q(url)}`, - ); +const assertParsers = (allegedParsers, keypath, url) => { + assertPlainObject(allegedParsers, `${keypath}.parsers`, url); - for (const [key, value] of Object.entries(parsers)) { - assert.typeof( + for (const [key, value] of entries(allegedParsers)) { + assertString( key, - 'string', - `all keys of ${path}.parsers must be strings, got ${key} in ${q(url)}`, - ); - assert.typeof( - value, - 'string', - `${path}.parsers[${q(key)}] must be a string, got ${value} in ${q(url)}`, + `all keys of ${keypath}.parsers must be strings; got ${key} in ${q(url)}`, ); + assertString(value, `${keypath}.parsers[${q(key)}]`, url); } }; /** - * @param {unknown} allegedScope - * @param {string} path + * @overload + * @param {unknown} allegedTruthyValue + * @param {string} keypath * @param {string} url + * @returns {asserts allegedTruthyValue is NonNullable} */ -const assertScope = (allegedScope, path, url) => { - const scope = Object(allegedScope); + +/** + * + * @overload + * @param {unknown} allegedTruthyValue + * @param {string} message + * @returns {asserts allegedTruthyValue is NonNullable} + */ + +/** + * + * @param {unknown} allegedTruthyValue + * @param {string} keypath + * @param {string} [url] + * @returns {asserts allegedTruthyValue is NonNullable} + */ +const assertTruthy = (allegedTruthyValue, keypath, url) => { assert( - allegedScope === scope && !Array.isArray(scope), - `${path} must be an object, got ${allegedScope} in ${q(url)}`, + allegedTruthyValue, + url + ? `${keypath} in ${q(url)} must be truthy; got ${q(allegedTruthyValue)}` + : url, ); +}; + +/** + * @template [T=string] + * @typedef {(value: unknown, keypath: string, url: string) => void} AssertFn + */ + +/** + * @template [T=string] + * @param {unknown} allegedScope + * @param {string} keypath + * @param {string} url + * @param {AssertFn} [assertCompartmentValue] + * @returns {asserts allegedScope is ScopeDescriptor} + */ +const assertScope = (allegedScope, keypath, url, assertCompartmentValue) => { + assertPlainObject(allegedScope, keypath, url); - const { compartment, ...extra } = scope; + const { compartment, ...extra } = allegedScope; assertEmptyObject( extra, - `${path} must not have extra properties, got ${q( - Object.keys(extra), + `${keypath} must not have extra properties; got ${q( + keys(extra), )} in ${q(url)}`, ); - assert.typeof( - compartment, - 'string', - `${path}.compartment must be a string, got ${q(compartment)} in ${q(url)}`, - ); + if (assertCompartmentValue) { + assertCompartmentValue(compartment, `${keypath}.compartment`, url); + } else { + assertString(compartment, `${keypath}.compartment`, url); + } }; /** + * @template [T=string] * @param {unknown} allegedScopes - * @param {string} path + * @param {string} keypath * @param {string} url + * @param {AssertFn} [assertCompartmentValue] + * @returns {asserts allegedScopes is Record>} */ -const assertScopes = (allegedScopes, path, url) => { - if (allegedScopes === undefined) { - return; - } - const scopes = Object(allegedScopes); - assert( - allegedScopes === scopes && !Array.isArray(scopes), - `${path}.scopes must be an object, got ${q(allegedScopes)} in ${q(url)}`, - ); +const assertScopes = ( + allegedScopes, + keypath, + url, + assertCompartmentValue = assertString, +) => { + assertPlainObject(allegedScopes, keypath, url); - for (const [key, value] of Object.entries(scopes)) { - assert.typeof( + for (const [key, value] of entries(allegedScopes)) { + assertString( key, - 'string', - `all keys of ${path}.scopes must be strings, got ${key} in ${q(url)}`, + `all keys of ${keypath}.scopes must be strings; got ${key} in ${q(url)}`, + ); + assertScope( + value, + `${keypath}.scopes[${q(key)}]`, + url, + assertCompartmentValue, ); - assertScope(value, `${path}.scopes[${q(key)}]`, url); } }; /** * @param {unknown} allegedTypes - * @param {string} path + * @param {string} keypath * @param {string} url + * @returns {asserts allegedTypes is LanguageForModuleSpecifier} */ -const assertTypes = (allegedTypes, path, url) => { - if (allegedTypes === undefined) { - return; +const assertTypes = (allegedTypes, keypath, url) => { + assertPlainObject(allegedTypes, `${keypath}.types`, url); + + for (const [key, value] of entries(allegedTypes)) { + assertString( + key, + `all keys of ${keypath}.types must be strings; got ${key} in ${q(url)}`, + ); + assertString(value, `${keypath}.types[${q(key)}]`, url); } - const types = Object(allegedTypes); +}; + +/** + * @template {Record} [M=Record] + * @param {unknown} allegedCompartment + * @param {string} keypath + * @param {string} url + * @param {AssertFn} [moduleConfigurationAssertionFn] + * @returns {asserts allegedCompartment is CompartmentDescriptor} + */ +const assertCompartmentDescriptor = ( + allegedCompartment, + keypath, + url, + moduleConfigurationAssertionFn = assertModuleConfigurations, +) => { + assertPlainObject(allegedCompartment, keypath, url); + + const { + location, + name, + parsers, + types, + scopes, + modules, + policy, + sourceDirname, + retained, + } = allegedCompartment; + + assertString(location, `${keypath}.location`, url); + assertString(name, `${keypath}.name`, url); + + // TODO: It may be prudent to assert that there exists some module referring + // to its own compartment + + moduleConfigurationAssertionFn(modules, keypath, url); + + if (parsers !== undefined) { + assertParsers(parsers, keypath, url); + } + if (scopes !== undefined) { + assertScopes(scopes, keypath, url); + } + if (types !== undefined) { + assertTypes(types, keypath, url); + } + if (policy !== undefined) { + assertPackagePolicy(policy, keypath, url); + } + if (sourceDirname !== undefined) { + assertString(sourceDirname, `${keypath}.sourceDirname`, url); + } + if (retained !== undefined) { + assertBoolean(retained, `${keypath}.retained`, url); + } +}; + +/** + * Ensures a string is a file URL (a {@link FileUrlString}) + * + * @param {unknown} allegedFileUrlString - a package location to assert + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedFileUrlString is FileUrlString} + */ +const assertFileUrlString = (allegedFileUrlString, keypath, url) => { + assertString(allegedFileUrlString, keypath, url); assert( - allegedTypes === types && !Array.isArray(types), - `${path}.types must be an object, got ${allegedTypes} in ${q(url)}`, + allegedFileUrlString.startsWith('file://'), + `${keypath} must be a file URL in ${q(url)}; got ${q(allegedFileUrlString)}`, ); + assert( + allegedFileUrlString.length > 7, + `${keypath} must contain a non-empty path in ${q(url)}; got ${q(allegedFileUrlString)}`, + ); +}; - for (const [key, value] of Object.entries(types)) { - assert.typeof( +/** + * @param {unknown} allegedModules + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedModules is Record} + */ +const assertPackageModuleConfigurations = (allegedModules, keypath, url) => { + assertPlainObject(allegedModules, keypath, url); + for (const [key, value] of entries(allegedModules)) { + assertString( key, - 'string', - `all keys of ${path}.types must be strings, got ${key} in ${q(url)}`, - ); - assert.typeof( - value, - 'string', - `${path}.types[${q(key)}] must be a string, got ${value} in ${q(url)}`, + `all keys of ${keypath}.modules must be strings; got ${key} in ${q(url)}`, ); + assertModuleConfiguration(value, `${keypath}.modules[${q(key)}]`, url, [ + 'compartment', + ]); } }; /** - * @param {unknown} allegedPolicy - * @param {string} path - * @param {string} [url] + * + * @param {unknown} allegedLocation + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedLocation is PackageCompartmentDescriptor['location']} */ - -const assertPolicy = ( - allegedPolicy, - path, - url = '', -) => { - assertPackagePolicy(allegedPolicy, `${path}.policy`, url); +const assertPackageLocation = (allegedLocation, keypath, url) => { + if (allegedLocation === ATTENUATORS_COMPARTMENT) { + return; + } + assertFileUrlString(allegedLocation, keypath, url); }; /** * @param {unknown} allegedCompartment - * @param {string} path + * @param {string} keypath * @param {string} url + * @returns {asserts allegedCompartment is PackageCompartmentDescriptor} */ -const assertCompartment = (allegedCompartment, path, url) => { - const compartment = Object(allegedCompartment); - assert( - allegedCompartment === compartment && !Array.isArray(compartment), - `${path} must be an object, got ${allegedCompartment} in ${q(url)}`, +const assertPackageCompartmentDescriptor = ( + allegedCompartment, + keypath, + url, +) => { + assertCompartmentDescriptor( + allegedCompartment, + keypath, + url, + assertPackageModuleConfigurations, ); const { location, - name, - label, - parsers, - types, scopes, - modules, - policy, + label, + // these unused vars already validated by assertPackageModuleConfigurations + name: _name, + sourceDirname: _sourceDirname, + modules: _modules, + parsers: _parsers, + types: _types, + policy: _policy, + version: _version, ...extra - } = compartment; + } = /** @type {PackageCompartmentDescriptor} */ (allegedCompartment); assertEmptyObject( extra, - `${path} must not have extra properties, got ${q( - Object.keys(extra), + `${keypath} must not have extra properties; got ${q( + keys(extra), )} in ${q(url)}`, ); - assert.typeof( - location, - 'string', - `${path}.location in ${q(url)} must be string, got ${q(location)}`, + assertPackageLocation(location, `${keypath}.location`, url); + assertLabel(label, `${keypath}.label`, url); + assertScopes(scopes, `${keypath}.scopes`, url, assertFileUrlString); +}; + +/** + * + * @param {unknown} allegedCompartment + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedCompartment is DigestedCompartmentDescriptor} + */ +const assertDigestedCompartmentDescriptor = ( + allegedCompartment, + keypath, + url, +) => { + assertCompartmentDescriptor( + allegedCompartment, + keypath, + url, + assertDigestedModuleConfigurations, ); - assert.typeof( - name, - 'string', - `${path}.name in ${q(url)} must be string, got ${q(name)}`, + + const { + name: _name, + label: _label, + modules: _modules, + policy: _policy, + location: _location, + ...extra + } = allegedCompartment; + + assertEmptyObject( + extra, + `${keypath} must not have extra properties; got ${q( + keys(extra), + )} in ${q(url)}`, ); - assert.typeof( +}; + +/** + * @param {unknown} allegedCompartment + * @param {string} keypath + * @param {string} url + * @returns {asserts allegedCompartment is FileCompartmentDescriptor} + */ +const assertFileCompartmentDescriptor = (allegedCompartment, keypath, url) => { + assertCompartmentDescriptor( + allegedCompartment, + keypath, + url, + assertFileModuleConfigurations, + ); + + const { + location: _location, + name: _name, label, - 'string', - `${path}.label in ${q(url)} must be string, got ${q(label)}`, + modules: _modules, + policy: _policy, + ...extra + } = /** @type {FileCompartmentDescriptor} */ (allegedCompartment); + + assertEmptyObject( + extra, + `${keypath} must not have extra properties; got ${q( + keys(extra), + )} in ${q(url)}`, ); - assertModules(modules, path, url); - assertParsers(parsers, path, url); - assertScopes(scopes, path, url); - assertTypes(types, path, url); - assertPolicy(policy, path, url); + assertString(label, `${keypath}.label`, url); }; /** * @param {unknown} allegedCompartments * @param {string} url + * @returns {asserts allegedCompartments is Record} */ -const assertCompartments = (allegedCompartments, url) => { - const compartments = Object(allegedCompartments); +const assertCompartmentDescriptors = (allegedCompartments, url) => { + assertPlainObject(allegedCompartments, 'compartments', url); + const compartmentNames = keys(allegedCompartments); assert( - allegedCompartments === compartments || !Array.isArray(compartments), - `compartments must be an object, got ${q(allegedCompartments)} in ${q( - url, - )}`, + compartmentNames.length > 0, + `compartments must not be empty in ${q(url)}`, ); - for (const [key, value] of Object.entries(compartments)) { - assertCompartment(value, `compartments[${q(key)}]`, url); + for (const key of keys(allegedCompartments)) { + assertString( + key, + `all keys of compartments must be strings; got ${key} in ${q(url)}`, + ); + } + assert( + compartmentNames.every(name => typeof name === 'string'), + `all keys of compartments must be strings; got ${q(compartmentNames)} in ${q(url)}`, + ); +}; + +/** + * @param {unknown} allegedCompartments + * @param {string} url + * @returns {asserts allegedCompartments is Record} + */ +const assertFileCompartmentDescriptors = (allegedCompartments, url) => { + assertCompartmentDescriptors(allegedCompartments, url); + for (const [key, value] of entries(allegedCompartments)) { + assertFileCompartmentDescriptor(value, `compartments[${q(key)}]`, url); } }; +/** + * @param {unknown} allegedCompartments + * @param {string} url + * @returns {asserts allegedCompartments is Record} + */ +const assertPackageCompartmentDescriptors = (allegedCompartments, url) => { + assertCompartmentDescriptors(allegedCompartments, url); + for (const [key, value] of entries(allegedCompartments)) { + assertPackageCompartmentDescriptor(value, `compartments[${q(key)}]`, url); + } +}; /** * @param {unknown} allegedEntry * @param {string} url + * @returns {asserts allegedEntry is EntryDescriptor} */ const assertEntry = (allegedEntry, url) => { - const entry = Object(allegedEntry); - assert( - allegedEntry === entry && !Array.isArray(entry), - `"entry" must be an object in compartment map, got ${allegedEntry} in ${q( - url, - )}`, - ); - const { compartment, module, ...extra } = entry; + assertPlainObject(allegedEntry, 'entry', url); + const { compartment, module, ...extra } = allegedEntry; assertEmptyObject( extra, - `"entry" must not have extra properties in compartment map, got ${q( - Object.keys(extra), + `"entry" must not have extra properties in compartment map; got ${q( + keys(extra), )} in ${q(url)}`, ); - assert.typeof( - compartment, - 'string', - `entry.compartment must be a string in compartment map, got ${compartment} in ${q( - url, - )}`, - ); - assert.typeof( - module, - 'string', - `entry.module must be a string in compartment map, got ${module} in ${q( - url, - )}`, - ); + assertString(compartment, 'entry.compartment', url); + assertString(module, 'entry.module', url); }; /** * @param {unknown} allegedCompartmentMap - * @param {string} [url] + * @param {string} url * @returns {asserts allegedCompartmentMap is CompartmentMapDescriptor} */ - -export const assertCompartmentMap = ( - allegedCompartmentMap, - url = '', -) => { - const compartmentMap = Object(allegedCompartmentMap); - assert( - allegedCompartmentMap === compartmentMap && !Array.isArray(compartmentMap), - `Compartment map must be an object, got ${allegedCompartmentMap} in ${q( - url, - )}`, - ); +const assertCompartmentMap = (allegedCompartmentMap, url) => { + assertPlainObject(allegedCompartmentMap, 'compartment map', url); const { // TODO migrate tags to conditions // https://github.com/endojs/endo/issues/2388 tags: conditions, entry, - compartments, + compartments: _compartments, ...extra - } = Object(compartmentMap); + } = allegedCompartmentMap; assertEmptyObject( extra, - `Compartment map must not have extra properties, got ${q( - Object.keys(extra), + `Compartment map must not have extra properties; got ${q( + keys(extra), )} in ${q(url)}`, ); assertConditions(conditions, url); assertEntry(entry, url); - assertCompartments(compartments, url); + assertTruthy( + allegedCompartmentMap.compartments?.[entry.compartment], + `compartments must contain entry compartment "${entry.compartment}" in ${q(url)}`, + ); +}; + +/** + * @param {unknown} allegedCompartmentMap + * @param {string} [url] + * @returns {asserts allegedCompartmentMap is FileCompartmentMapDescriptor} + */ +export const assertFileCompartmentMap = ( + allegedCompartmentMap, + url = '', +) => { + assertCompartmentMap(allegedCompartmentMap, url); + const { compartments } = allegedCompartmentMap; + assertFileCompartmentDescriptors(compartments, url); +}; + +/** + * + * @param {unknown} allegedCompartments + * @param {string} url + * @returns {asserts allegedCompartments is Record} + */ +export const assertDigestedCompartmentDescriptors = ( + allegedCompartments, + url = '', +) => { + assertCompartmentDescriptors(allegedCompartments, url); + for (const [key, value] of entries(allegedCompartments)) { + assertDigestedCompartmentDescriptor(value, `compartments[${q(key)}]`, url); + } +}; + +/** + * + * @param {unknown} allegedCompartmentMap + * @param {string} [url] + * @returns {asserts allegedCompartmentMap is DigestedCompartmentMapDescriptor} + */ +export const assertDigestedCompartmentMap = ( + allegedCompartmentMap, + url = '', +) => { + assertCompartmentMap(allegedCompartmentMap, url); + const { compartments } = allegedCompartmentMap; + assertDigestedCompartmentDescriptors(compartments, url); +}; + +/** + * @param {unknown} allegedCompartmentMap + * @param {string} [url] + * @returns {asserts allegedCompartmentMap is PackageCompartmentMapDescriptor} + */ +export const assertPackageCompartmentMap = ( + allegedCompartmentMap, + url = '', +) => { + assertCompartmentMap(allegedCompartmentMap, url); + const { compartments } = allegedCompartmentMap; + assertPackageCompartmentDescriptors(compartments, url); }; diff --git a/packages/compartment-mapper/src/digest.js b/packages/compartment-mapper/src/digest.js index 86b09f4230..14358421b3 100644 --- a/packages/compartment-mapper/src/digest.js +++ b/packages/compartment-mapper/src/digest.js @@ -8,18 +8,41 @@ /** * @import { - * CompartmentDescriptor, - * CompartmentMapDescriptor, + * DigestedCompartmentDescriptors, * DigestResult, - * ModuleDescriptor, + * PackageCompartmentDescriptors, + * PackageCompartmentMapDescriptor, + * ModuleConfiguration, * Sources, + * ExitModuleConfiguration, + * FileModuleConfiguration, + * ErrorModuleConfiguration, + * DigestedCompartmentMapDescriptor, + * DigestedCompartmentDescriptor, + * DigestCompartmentMapOptions, + * PackageCompartmentDescriptor, + * PackageCompartmentDescriptorName, + * CompartmentModuleConfiguration, + * CanonicalName, + * CompartmentsRenameFn, + FileUrlString, * } from './types.js' */ -import { pathCompare } from '@endo/path-compare'; -import { assertCompartmentMap, stringCompare } from './compartment-map.js'; +import { + assertDigestedCompartmentMap, + stringCompare, +} from './compartment-map.js'; +import { + isErrorModuleSource, + isExitModuleSource, + isLocalModuleSource, +} from './guards.js'; -const { create, fromEntries, entries, keys } = Object; +const { create, fromEntries, entries, keys, values } = Object; +const { quote: q } = assert; + +const noop = () => {}; /** * We attempt to produce compartment maps that are consistent regardless of @@ -49,71 +72,94 @@ const { create, fromEntries, entries, keys } = Object; * actual installation location, so should be orthogonal to the vagaries of the * package manager's deduplication algorithm. * - * @param {Record} compartments - * @returns {Record} map from old to new compartment names. + * @type {CompartmentsRenameFn} */ -const renameCompartments = compartments => { - /** @type {Record} */ +const defaultRenameCompartments = compartments => { + /** @type {Record} */ const compartmentRenames = create(null); - let index = 0; - let prev = ''; // The sort below combines two comparators to avoid depending on sort // stability, which became standard as recently as 2019. // If that date seems quaint, please accept my regards from the distant past. // We are very proud of you. - const compartmentsByPath = Object.entries(compartments) - .map(([name, compartment]) => ({ - name, - path: compartment.path, - label: compartment.label, - })) - .sort((a, b) => { - if (a.label === b.label) { - assert(a.path !== undefined && b.path !== undefined); - return pathCompare(a.path, b.path); - } - return stringCompare(a.label, b.label); - }); - - for (const { name, label } of compartmentsByPath) { - if (label === prev) { - compartmentRenames[name] = `${label}-n${index}`; - index += 1; - } else { - compartmentRenames[name] = label; - prev = label; - index = 1; - } + const comaprtmentNamesToLabel = + /** @type {Array<{name: FileUrlString, label: PackageCompartmentDescriptorName}>} */ ( + Object.entries(compartments) + .map(([name, compartment]) => ({ + name, + label: compartment.label, + })) + .sort((a, b) => stringCompare(a.label, b.label)) + ); + + for (const { name, label } of comaprtmentNamesToLabel) { + compartmentRenames[name] = label; } return compartmentRenames; }; /** - * @param {Record} compartments + * @template {string} [OldCompartmentName=FileUrlString] + * @template {string} [NewCompartmentName=PackageCompartmentDescriptorName] + * @overload + * @param {PackageCompartmentDescriptors} compartmentDescriptors * @param {Sources} sources - * @param {Record} compartmentRenames + * @param {Record} compartmentRenames + * @param {DigestCompartmentMapOptions} [options] + * @returns {DigestedCompartmentDescriptors} */ -const translateCompartmentMap = (compartments, sources, compartmentRenames) => { +/** + * @overload + * @param {PackageCompartmentDescriptors} compartmentDescriptors + * @param {Sources} sources + * @param {Record} compartmentRenames + * @param {DigestCompartmentMapOptions} [options] + * @returns {DigestedCompartmentDescriptors} + */ + +/** + * @template {string} [OldCompartmentName=FileUrlString] + * @template {string} [NewCompartmentName=PackageCompartmentDescriptorName] + * @param {PackageCompartmentDescriptors} compartmentDescriptors + * @param {Sources} sources + * @param {Record} compartmentRenames + * @param {DigestCompartmentMapOptions} [options] + */ +const translateCompartmentMap = ( + compartmentDescriptors, + sources, + compartmentRenames, + { packageConnectionsHook, log = noop } = {}, +) => { const result = create(null); for (const compartmentName of keys(compartmentRenames)) { - const compartment = compartments[compartmentName]; - const { name, label, retained: compartmentRetained, policy } = compartment; + /** @type {PackageCompartmentDescriptor} */ + const compartmentDescriptor = compartmentDescriptors[compartmentName]; + const { + name, + label, + retained: compartmentRetained, + policy, + } = compartmentDescriptor; + /** @type {Record} */ + const renamedModuleConfigurations = {}; if (compartmentRetained) { // rename module compartments - /** @type {Record} */ + /** @type {Record} */ const modules = create(null); - const compartmentModules = compartment.modules; - if (compartment.modules) { + const compartmentModules = compartmentDescriptor.modules; + if (compartmentDescriptor.modules) { for (const name of keys(compartmentModules).sort()) { const { retained: moduleRetained, ...retainedModule } = compartmentModules[name]; if (moduleRetained) { if (retainedModule.compartment !== undefined) { + /** @type {CompartmentModuleConfiguration} */ modules[name] = { ...retainedModule, compartment: compartmentRenames[retainedModule.compartment], }; + renamedModuleConfigurations[name] = retainedModule.compartment; } else { modules[name] = retainedModule; } @@ -126,25 +172,40 @@ const translateCompartmentMap = (compartments, sources, compartmentRenames) => { if (compartmentSources) { for (const name of keys(compartmentSources).sort()) { const source = compartmentSources[name]; - const { location, parser, exit, sha512, deferredError } = source; - if (location !== undefined) { + if (isLocalModuleSource(source)) { + const { location, parser, sha512 } = source; + /** @type {FileModuleConfiguration} */ modules[name] = { location, parser, sha512, + __createdBy: 'digest', }; - } else if (exit !== undefined) { + } else if (isExitModuleSource(source)) { + const { exit } = source; + /** @type {ExitModuleConfiguration} */ modules[name] = { exit, + __createdBy: 'digest', }; - } else if (deferredError !== undefined) { + } else if (isErrorModuleSource(source)) { + const { deferredError } = source; + /** @type {ErrorModuleConfiguration} */ modules[name] = { deferredError, + __createdBy: 'digest', }; + } else { + throw new TypeError( + `Unexpected source type for compartment ${compartmentName} module ${name}: ${q( + source, + )}`, + ); } } } + /** @type {DigestedCompartmentDescriptor} */ result[compartmentRenames[compartmentName]] = { name, label, @@ -154,6 +215,23 @@ const translateCompartmentMap = (compartments, sources, compartmentRenames) => { // `scopes`, `types`, and `parsers` are not necessary since every // loadable module is captured in `modules`. }; + + const links = /** @type {Set} */ ( + values(modules).reduce((acc, moduleDescriptorConfig) => { + if ('compartment' in moduleDescriptorConfig) { + acc.add(moduleDescriptorConfig.compartment); + } + return acc; + }, new Set()) + ); + + if (packageConnectionsHook) { + packageConnectionsHook({ + canonicalName: label, + connections: links, + log, + }); + } } } @@ -175,26 +253,53 @@ const renameSources = (sources, compartmentRenames) => { }; /** - * @param {CompartmentMapDescriptor} compartmentMap + * @template {string} [OldCompartmentName=FileUrlString] + * @template {string} [NewCompartmentName=PackageCompartmentDescriptorName] + * @overload + * @param {PackageCompartmentMapDescriptor} compartmentMap + * @param {Sources} sources + * @param {DigestCompartmentMapOptions} [options] + * @returns {DigestResult} + */ + +/** + * @overload + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {Sources} sources - * @returns {DigestResult} + * @param {DigestCompartmentMapOptions} [options] + * @returns {DigestResult} */ -export const digestCompartmentMap = (compartmentMap, sources) => { + +/** + * @template {string} [OldCompartmentName=FileUrlString] + * @template {string} [NewCompartmentName=PackageCompartmentDescriptorName] + * @param {PackageCompartmentMapDescriptor} compartmentMap + * @param {Sources} sources + * @param {DigestCompartmentMapOptions} [options] + */ +export const digestCompartmentMap = ( + compartmentMap, + sources, + { packageConnectionsHook, log = noop, renameCompartments } = {}, +) => { const { compartments, entry: { compartment: entryCompartmentName, module: entryModuleSpecifier }, } = compartmentMap; - const oldToNewCompartmentNames = renameCompartments(compartments); + const renameCompartmentsFn = renameCompartments ?? defaultRenameCompartments; + const oldToNewCompartmentNames = renameCompartmentsFn(compartments); const digestCompartments = translateCompartmentMap( compartments, sources, oldToNewCompartmentNames, + { packageConnectionsHook, log }, ); const digestEntryCompartmentName = oldToNewCompartmentNames[entryCompartmentName]; const digestSources = renameSources(sources, oldToNewCompartmentNames); + /** @type {DigestedCompartmentMapDescriptor} */ const digestCompartmentMap = { // TODO graceful migration from tags to conditions // https://github.com/endojs/endo/issues/2388 @@ -210,8 +315,18 @@ export const digestCompartmentMap = (compartmentMap, sources) => { // We assert that we have constructed a valid compartment map, not because it // might not be, but to ensure that the assertCompartmentMap function can // accept all valid compartment maps. - assertCompartmentMap(digestCompartmentMap); + try { + assertDigestedCompartmentMap(digestCompartmentMap); + } catch (err) { + throw new TypeError( + `Invalid compartment map; ${JSON.stringify( + digestCompartmentMap, + )}:\n${err.message}`, + { cause: err }, + ); + } + /** @type {Record} */ const newToOldCompartmentNames = fromEntries( entries(oldToNewCompartmentNames).map(([oldName, newName]) => [ newName, @@ -219,14 +334,22 @@ export const digestCompartmentMap = (compartmentMap, sources) => { ]), ); - /** @type {DigestResult} */ - const digestResult = { + if (renameCompartments === defaultRenameCompartments) { + /** @type {DigestResult} */ + return { + compartmentMap: digestCompartmentMap, + sources: digestSources, + oldToNewCompartmentNames, + newToOldCompartmentNames, + compartmentRenames: newToOldCompartmentNames, + }; + } + /** @type {DigestResult} */ + return { compartmentMap: digestCompartmentMap, sources: digestSources, oldToNewCompartmentNames, newToOldCompartmentNames, compartmentRenames: newToOldCompartmentNames, }; - - return digestResult; }; diff --git a/packages/compartment-mapper/src/generic-graph.js b/packages/compartment-mapper/src/generic-graph.js index f9f33e4c6a..1f6dce34b7 100644 --- a/packages/compartment-mapper/src/generic-graph.js +++ b/packages/compartment-mapper/src/generic-graph.js @@ -13,7 +13,7 @@ * @import {TraversalContext} from './types/generic-graph.js'; */ -const { stringify: q } = JSON; +const { quote: q } = assert; /** * Remove the node with the minimum weight from the priority queue. @@ -94,6 +94,11 @@ const getPath = ({ predecessors }, source, target) => { /** @type {T[]} */ const nodeList = []; + assert( + source !== target, + `Source ${q(source)} cannot be the same as target ${q(target)}`, + ); + let node = target; while (predecessors.has(node)) { @@ -108,7 +113,7 @@ const getPath = ({ predecessors }, source, target) => { assert( nodeList.length >= 2, - `The path from ${source} to ${target} should have a least two nodes`, + `The path from ${source} to ${target} should have at least two nodes`, ); return /** @type {[T, T, ...T[]]} */ (nodeList.reverse()); diff --git a/packages/compartment-mapper/src/guards.js b/packages/compartment-mapper/src/guards.js new file mode 100644 index 0000000000..7686dc1b63 --- /dev/null +++ b/packages/compartment-mapper/src/guards.js @@ -0,0 +1,109 @@ +/** + * Common type guards. + * + * @module + */ + +const { hasOwn } = Object; + +/** + * @import { + * ModuleConfiguration, + * FileModuleConfiguration, + * ErrorModuleConfiguration, + * ModuleSource, + * ExitModuleSource, + * ErrorModuleSource, + * LocalModuleSource, + * ExitModuleConfiguration, + * CompartmentModuleConfiguration + * } from './types.js'; + */ + +/** + * Type guard for an {@link ErrorModuleConfiguration}. + * @param {ModuleConfiguration} value + * @returns {value is ErrorModuleConfiguration} + */ +export const isErrorModuleConfiguration = value => + hasOwn(value, 'deferredError') && + /** @type {any} */ (value).deferredError !== undefined; + +/** + * Type guard for a {@link FileModuleConfiguration}. + * + * @param {ModuleConfiguration} value + * @returns {value is FileModuleConfiguration} + */ +export const isFileModuleConfiguration = value => + hasOwn(value, 'parser') && + /** @type {any} */ (value).parser !== undefined && + !isErrorModuleConfiguration(value); + +/** + * Type guard for an {@link ExitModuleConfiguration}. + * @param {ModuleConfiguration} value + * @returns {value is ExitModuleConfiguration} + */ +export const isExitModuleConfiguration = value => + hasOwn(value, 'exit') && + /** @type {any} */ (value).exit !== undefined && + !isErrorModuleConfiguration(value); + +/** + * Type guard for an {@link CompartmentModuleConfiguration}. + * @param {ModuleConfiguration} value + * @returns {value is CompartmentModuleConfiguration} + */ +export const isCompartmentModuleConfiguration = value => + hasOwn(value, 'compartment') && + /** @type {any} */ (value).compartment !== undefined && + hasOwn(value, 'module') && + /** @type {any} */ (value).module !== undefined && + !isErrorModuleConfiguration(value); +/** + * Type guard for an {@link ErrorModuleSource}. + * + * @param {ModuleSource} value + * @returns {value is ErrorModuleSource} + */ +export const isErrorModuleSource = value => + hasOwn(value, 'deferredError') && + /** @type {any} */ (value).deferredError !== undefined; + +/** + * Type guard for an {@link ExitModuleSource}. + * + * @param {ModuleSource} value + * @returns {value is ExitModuleSource} + */ +export const isExitModuleSource = value => + hasOwn(value, 'exit') && + /** @type {any} */ (value).exit !== undefined && + !isErrorModuleSource(value); + +/** + * Type guard for an {@link LocalModuleSource}. + * + * @param {ModuleSource} value + * @returns {value is LocalModuleSource} + */ +export const isLocalModuleSource = value => + hasOwn(value, 'bytes') && + /** @type {any} */ (value).bytes !== undefined && + hasOwn(value, 'parser') && + /** @type {any} */ (value).parser !== undefined && + hasOwn(value, 'sourceDirname') && + /** @type {any} */ (value).sourceDirname !== undefined && + hasOwn(value, 'location') && + /** @type {any} */ (value).location !== undefined && + !isErrorModuleSource(value); + +/** + * Type guard for a non-nullable object + * + * @param {unknown} value + * @returns {value is object} + */ +export const isNonNullableObject = value => + typeof value === 'object' && value !== null; diff --git a/packages/compartment-mapper/src/hooks.md b/packages/compartment-mapper/src/hooks.md new file mode 100644 index 0000000000..9ff6aa066e --- /dev/null +++ b/packages/compartment-mapper/src/hooks.md @@ -0,0 +1,124 @@ +# Review of compartment-mapper hooks + + + +| Hook Name | Description | +|-------------------------- | --- | +| `packageDataHook` | Receives all found package descriptors data before graph translation. | +| `packageDependenciesHook` | Allows dynamic mutation of dependencies during node_modules graph translation. | +| `unknownCanonicalNameHook`| Called when the policy references unknown canonical names, can suggest typos/similar names. | +| `moduleSourceHook` | Invoked when a module source is created. | +| `packageConnectionsHook` | Surfaces connections during digest. (ignored in archiving) | + + + +[Type declarations for the hooks](./types/external.ts) + +```mermaid +graph TB + + exports((public exports)) + exports -.- compartmentMapForNodeModules + exports -.- mapNodeModules + exports -.- loadLocation + exports -.- importLocation + exports -.- captureFromMap + exports -.- importFromMap + exports -.- loadFromMap + exports -.- digestCompartmentMap + exports -.- makeFunctor + exports -.- makeScript + + +subgraph "import-hook.js" + moduleSourceHook{{moduleSourceHook}} -.- note5["for all module sources read"] + makeDeferError --> moduleSourceHook + makeImportHookMaker --> makeDeferError + makeImportNowHookMaker --> makeDeferError + makeImportNowHookMaker -- via importNowHook --> chooseModuleDescriptor --> executeLocalModuleSourceHook--> moduleSourceHook + makeImportHookMaker --via importHook exitModule logic--> moduleSourceHook + makeImportHookMaker --via importHook call--> chooseModuleDescriptor +end + +subgraph "node-modules.js" + compartmentMapForNodeModules --> packageDataHook{{packageDataHook}} -.- note0["called once"] + compartmentMapForNodeModules --> unknownCanonicalNameHook{{unknownCanonicalNameHook}} -.- note1["for all issues from policy;
after defaultUnknownCanonicalNameHandler"] + compartmentMapForNodeModules --> translateGraph --> packageDependenciesHook{{packageDependenciesHook}} -.-note3["for all locatons in graph
after defaultPackageDependenciesFilter"] + mapNodeModules --> compartmentMapForNodeModules + +end + +subgraph "digest.js" + digestCompartmentMap --> translateCompartmentMap --> packageConnectionsHook{{packageConnectionsHook}} -.- note4["for all retained compartments"] +end + +subgraph "bundle.js" + makeFunctor -- options:can include hooks ----------> mapNodeModules + makeScript -- options:can include hooks ----------> mapNodeModules +end + + +subgraph "import-lite.js" + importFromMap --> loadFromMap ---> makeImportHookMaker + loadFromMap ----> makeImportNowHookMaker +end + + +subgraph "capture-lite.js" + captureFromMap -----> makeImportHookMaker + captureFromMap --> captureCompartmentMap --> digestCompartmentMap +end + + +subgraph "import.js" + loadLocation ----------> mapNodeModules + importLocation --> loadLocation + loadLocation --> loadFromMap +end + + +%% STYLING +classDef note fill:#999, stroke:#ccb +class note0,note1,note2,note3,note4,note5 note + +``` + + + +
+Bundle and Archive bits of the diagram that don't use hooks + +These are calling the functions accepting hooks but don't pass them + +> [TODO] +> copy-paste this to the main diagram whenever the connections are made. + +```mermaid + +graph TB + +subgraph "bundle.js" + makeFunctor -- options:transparently ----------> mapNodeModules + makeScript -- options:transparently ----------> mapNodeModules + makeFunctorFromMap -- no moduleSourceHook ----x makeImportHookMaker +end + +subgraph "bundle-lite.js" +makeFunctorFromMap2 --no moduleSourceHook -----x makeImportHookMaker +end + +subgraph "archive-lite.js" +digestFromMap -- no moduleSourceHook -----x makeImportHookMaker +makeArchiveCompartmentMap --no packageConnectionsHook----x digestCompartmentMap +end + +subgraph "archive.js" +archive(("multiple
methods")) --no hooks passed-----------x mapNodeModules +end + +``` + +
+ + + diff --git a/packages/compartment-mapper/src/import-archive-lite.js b/packages/compartment-mapper/src/import-archive-lite.js index 35c69f39f5..8eaa8fb6e4 100644 --- a/packages/compartment-mapper/src/import-archive-lite.js +++ b/packages/compartment-mapper/src/import-archive-lite.js @@ -23,7 +23,7 @@ * } from 'ses'; * @import { * Application, - * CompartmentDescriptor, + * FileCompartmentDescriptor, * ComputeSourceLocationHook, * ComputeSourceMapLocationHook, * ExecuteFn, @@ -44,9 +44,13 @@ import { link } from './link.js'; import { parseLocatedJson } from './json.js'; import { unpackReadPowers } from './powers.js'; import { join } from './node-module-specifier.js'; -import { assertCompartmentMap } from './compartment-map.js'; +import { assertFileCompartmentMap } from './compartment-map.js'; import { exitModuleImportHookMaker } from './import-hook.js'; -import { attenuateModuleHook, enforceModulePolicy } from './policy.js'; +import { attenuateModuleHook, enforcePolicyByModule } from './policy.js'; +import { + isErrorModuleConfiguration, + isFileModuleConfiguration, +} from './guards.js'; const { Fail, quote: q } = assert; @@ -77,7 +81,7 @@ const postponeErrorToExecute = errorMessage => { /** * @param {(path: string) => Uint8Array} get - * @param {Record} compartments + * @param {Record} compartments * @param {string} archiveLocation * @param {ParserForLanguage} parserForLanguage * @param {HashFn} [computeSha512] @@ -118,7 +122,7 @@ const makeArchiveImportHookMaker = ( // At this point in archive importing, if a module is not found and // exitModuleImportHook exists, the only possibility is that the // module is a "builtin" module and the policy needs to be enforced. - enforceModulePolicy(moduleSpecifier, compartmentDescriptor, { + enforcePolicyByModule(moduleSpecifier, compartmentDescriptor, { exit: true, errorHint: `Blocked in loading. ${q( moduleSpecifier, @@ -161,22 +165,22 @@ const makeArchiveImportHookMaker = ( )} in archive ${q(archiveLocation)}`, ); } - if (module.deferredError !== undefined) { + if (isErrorModuleConfiguration(module)) { return postponeErrorToExecute(module.deferredError); } - if (module.parser === undefined) { + if (!isFileModuleConfiguration(module)) { throw Error( `Cannot parse module ${q(moduleSpecifier)} in package ${q( packageLocation, - )} in archive ${q(archiveLocation)}`, + )} in archive ${q(archiveLocation)}; missing parser`, ); } const parser = parserForLanguage[module.parser]; if (parser === undefined) { throw Error( - `Cannot parse ${q(module.parser)} module ${q( + `Cannot parse module ${q( moduleSpecifier, - )} in package ${q(packageLocation)} in archive ${q(archiveLocation)}`, + )} in package ${q(packageLocation)} in archive ${q(archiveLocation)}; unknown parser (${q(module.parser)})`, ); } const { parse } = parser; @@ -306,7 +310,7 @@ export const parseArchive = async ( compartmentMapText, 'compartment-map.json', ); - assertCompartmentMap(compartmentMap, archiveLocation); + assertFileCompartmentMap(compartmentMap, archiveLocation); const { compartments, diff --git a/packages/compartment-mapper/src/import-archive.js b/packages/compartment-mapper/src/import-archive.js index d2b8aed23c..8b1929a029 100644 --- a/packages/compartment-mapper/src/import-archive.js +++ b/packages/compartment-mapper/src/import-archive.js @@ -19,14 +19,12 @@ /** * @import { * Application, - * ComputeSourceLocationHook, - * ComputeSourceMapLocationHook, * ExecuteOptions, - * ExitModuleImportHook, - * HashFn, * LoadArchiveOptions, * ReadPowers, * ParserForLanguage, + * ParseArchiveOptions, + * ReadFn, * } from './types.js' */ @@ -39,28 +37,10 @@ import { const { assign, create, freeze } = Object; -// Must give the type of Compartment a name to capture the external meaning of -// Compartment Otherwise @param {typeof Compartment} takes the Compartment to -// mean the const variable defined within the function. -// -/** @typedef {typeof Compartment} CompartmentConstructor */ - -/** - * @typedef {object} Options - * @property {string} [expectedSha512] - * @property {HashFn} [computeSha512] - * @property {Record} [modules] - * @property {ExitModuleImportHook} [importHook] - * @property {CompartmentConstructor} [Compartment] - * @property {ComputeSourceLocationHook} [computeSourceLocation] - * @property {ComputeSourceMapLocationHook} [computeSourceMapLocation] - * @property {ParserForLanguage} [parserForLanguage] - */ - /** * Add the default parserForLanguage option. - * @param {Options} [options] - * @returns {Options} + * @param {ParseArchiveOptions} [options] + * @returns {ParseArchiveOptions} */ const assignParserForLanguage = (options = {}) => { const { parserForLanguage: parserForLanguageOption, ...rest } = options; @@ -74,7 +54,7 @@ const assignParserForLanguage = (options = {}) => { /** * @param {Uint8Array} archiveBytes * @param {string} [archiveLocation] - * @param {Options} [options] + * @param {ParseArchiveOptions} [options] * @returns {Promise} */ export const parseArchive = async ( @@ -89,7 +69,7 @@ export const parseArchive = async ( ); /** - * @param {import('@endo/zip').ReadFn | ReadPowers} readPowers + * @param {ReadFn | ReadPowers} readPowers * @param {string} archiveLocation * @param {LoadArchiveOptions} [options] * @returns {Promise} @@ -102,7 +82,7 @@ export const loadArchive = async (readPowers, archiveLocation, options) => ); /** - * @param {import('@endo/zip').ReadFn | ReadPowers} readPowers + * @param {ReadFn | ReadPowers} readPowers * @param {string} archiveLocation * @param {ExecuteOptions & LoadArchiveOptions} options * @returns {Promise} diff --git a/packages/compartment-mapper/src/import-hook.js b/packages/compartment-mapper/src/import-hook.js index bb13f0ffad..061ba13e9d 100644 --- a/packages/compartment-mapper/src/import-hook.js +++ b/packages/compartment-mapper/src/import-hook.js @@ -29,14 +29,19 @@ * ImportNowHookMaker, * MakeImportHookMakerOptions, * MakeImportNowHookMakerOptions, - * ModuleDescriptor, * ParseResult, * ReadFn, * ReadPowers, * ReadNowPowers, * StrictlyRequiredFn, + * DeferErrorFn, + * ErrorModuleSource, + * FileUrlString, * CompartmentSources, - * DeferErrorFn + * CompartmentModuleConfiguration, + * LogOptions, + * CanonicalName, + * LocalModuleSource * } from './types.js' */ @@ -44,15 +49,14 @@ import { asyncTrampoline, syncTrampoline } from '@endo/trampoline'; import { resolve } from './node-module-specifier.js'; import { attenuateModuleHook, - enforceModulePolicy, - enforcePackagePolicyByPath, + enforcePolicyByModule, + enforcePackagePolicyByCanonicalName, } from './policy.js'; import { ATTENUATORS_COMPARTMENT } from './policy-format.js'; import { unpackReadPowers } from './powers.js'; // q, as in quote, for quoting strings in error messages. -const q = JSON.stringify; - +const { quote: q } = assert; const { apply } = Reflect; /** @@ -63,7 +67,7 @@ const { apply } = Reflect; */ const freeze = Object.freeze; -const { entries, keys, assign, create } = Object; +const { keys, assign, create } = Object; const { hasOwnProperty } = Object.prototype; /** @@ -72,12 +76,15 @@ const { hasOwnProperty } = Object.prototype; */ const has = (haystack, needle) => apply(hasOwnProperty, haystack, [needle]); +const noop = () => {}; + /** * @param {string} rel - a relative URL - * @param {string} abs - a fully qualified URL - * @returns {string} + * @param {FileUrlString} abs - a fully qualified URL + * @returns {FileUrlString} */ -const resolveLocation = (rel, abs) => new URL(rel, abs).toString(); +const resolveLocation = (rel, abs) => + /** @type {FileUrlString} */ (new URL(rel, abs).toString()); // this is annoying function getImportsFromRecord(record) { @@ -99,14 +106,14 @@ const nodejsConventionSearchSuffixes = [ /** * Returns `true` if `absoluteModuleSpecifier` is within the path `compartmentLocation`. - * @param {string} absoluteModudeSpecifier Absolute path to module specifier + * @param {string} absoluteModuleSpecifier Absolute path to module specifier * @param {string} compartmentLocation Absolute path to compartment location * @returns {boolean} */ const isLocationWithinCompartment = ( - absoluteModudeSpecifier, + absoluteModuleSpecifier, compartmentLocation, -) => absoluteModudeSpecifier.startsWith(compartmentLocation); +) => absoluteModuleSpecifier.startsWith(compartmentLocation); /** * Computes the relative path to a module from its compartment location (including a leading `./`) @@ -160,44 +167,8 @@ const findRedirect = ({ return undefined; } - // this tests the compartment referred to by the absolute path - // is a dependency of the compartment descriptor - if (compartmentDescriptor.compartments.has(location)) { - return { - specifier: relativeSpecifier(moduleSpecifierLocation, location), - compartment: compartments[location], - }; - } - - // this tests if the compartment descriptor is a dependency of the - // compartment referred to by the absolute path. - // it may be in scope, but disallowed by policy. - if ( - someCompartmentDescriptor.compartments.has( - compartmentDescriptor.location, - ) - ) { - enforceModulePolicy( - compartmentDescriptor.name, - someCompartmentDescriptor, - { - errorHint: `Blocked in importNow hook by relationship. ${q(absoluteModuleSpecifier)} is part of the compartment map and resolves to ${location}`, - }, - ); - return { - specifier: relativeSpecifier(moduleSpecifierLocation, location), - compartment: compartments[location], - }; - } - if (compartmentDescriptor.policy) { - /* c8 ignore next */ - if (!someCompartmentDescriptor.path) { - throw new Error( - `Cannot enforce package policy: compartment descriptor for ${location} unexpectedly missing a path; please report this issue`, - ); - } - enforcePackagePolicyByPath( + enforcePackagePolicyByCanonicalName( someCompartmentDescriptor, compartmentDescriptor, { @@ -286,6 +257,66 @@ const nominateCandidates = (moduleSpecifier, searchSuffixes) => { return candidates; }; +/** + * Executes the moduleSource hook for a {@link LocalModuleSource}. + * + * Preprocesses the fields for the hook. + * + * @param {import('./types.js').ModuleSourceHook | undefined} moduleSourceHook Hook function + * @param {LocalModuleSource} moduleSource Original `LocalModuleSource` object + * @param {CanonicalName} canonicalName Canonical name of the compartment/package + * @param {LogOptions} options Options + * @returns {void} + */ +const executeLocalModuleSourceHook = ( + moduleSourceHook, + moduleSource, + canonicalName, + { log = noop } = {}, +) => { + if (!moduleSourceHook) { + return; + } + + const { + sourceLocation: location, + parser: language, + bytes, + record, + sha512, + } = moduleSource; + /** @type {string[]|undefined} */ + let imports; + /** @type {string[]|undefined} */ + let exports; + /** @type {string[]|undefined} */ + let reexports; + + if ('imports' in record) { + ({ imports } = record); + } + if ('exports' in record) { + ({ exports } = record); + } + if ('reexports' in record) { + ({ reexports } = record); + } + + moduleSourceHook({ + moduleSource: { + location, + language, + bytes, + imports, + exports, + reexports, + sha512, + }, + canonicalName, + log, + }); +}; + /** * Returns a generator which applies {@link ChooseModuleDescriptorOperators} in * `operators` using the options in options to ultimately result in a @@ -319,7 +350,9 @@ function* chooseModuleDescriptor( readPowers, archiveOnly, sourceMapHook, + moduleSourceHook, strictlyRequiredForCompartment, + log = noop, }, { maybeRead, parse, shouldDeferError = () => false }, ) { @@ -416,6 +449,7 @@ function* chooseModuleDescriptor( retained: true, module: candidateSpecifier, compartment: packageLocation, + __createdBy: 'import-hook', }; } /** @type {StaticModuleType} */ @@ -442,7 +476,9 @@ function* chooseModuleDescriptor( const packageRelativeLocation = moduleLocation.slice( packageLocation.length, ); - packageSources[candidateSpecifier] = { + + /** @type {LocalModuleSource} */ + const localModuleSource = { location: packageRelativeLocation, sourceLocation: moduleLocation, sourceDirname, @@ -451,6 +487,16 @@ function* chooseModuleDescriptor( record: concreteRecord, sha512, }; + + packageSources[candidateSpecifier] = localModuleSource; + + executeLocalModuleSourceHook( + moduleSourceHook, + localModuleSource, + compartmentDescriptor.label, + { log }, + ); + if (!shouldDeferError(parser)) { for (const importSpecifier of getImportsFromRecord(record)) { strictlyRequiredForCompartment(packageLocation).add( @@ -480,9 +526,7 @@ const makeDeferError = ( packageSources, ) => { /** - * @param {string} specifier - * @param {Error} error - error to throw on execute - * @returns {StaticModuleType} + * @type {DeferErrorFn} */ const deferError = (specifier, error) => { // strictlyRequired is populated with imports declared by modules whose parser is not using heuristics to figure @@ -504,9 +548,11 @@ const makeDeferError = ( throw error; }, }); - packageSources[specifier] = { + /** @type {ErrorModuleSource} */ + const moduleSource = { deferredError: error.message, }; + packageSources[specifier] = moduleSource; return record; }; @@ -515,7 +561,7 @@ const makeDeferError = ( /** * @param {ReadFn|ReadPowers} readPowers - * @param {string} baseLocation + * @param {FileUrlString} baseLocation * @param {MakeImportHookMakerOptions} options * @returns {ImportHookMaker} */ @@ -532,6 +578,8 @@ export const makeImportHookMaker = ( entryCompartmentName, entryModuleSpecifier, importHook: exitModuleImportHook = undefined, + moduleSourceHook, + log = noop, }, ) => { // Set of specifiers for modules (scoped to compartment) whose parser is not @@ -603,12 +651,23 @@ export const makeImportHookMaker = ( if (record) { // It'd be nice to check the policy before importing it, but we can only throw a policy error if the // hook returns something. Otherwise, we need to fall back to the 'cannot find' error below. - enforceModulePolicy(moduleSpecifier, compartmentDescriptor, { + enforcePolicyByModule(moduleSpecifier, compartmentDescriptor, { exit: true, errorHint: `Blocked in loading. ${q( moduleSpecifier, )} was not in the compartment map and an attempt was made to load it as a builtin`, }); + + if (moduleSourceHook) { + moduleSourceHook({ + moduleSource: { + exit: moduleSpecifier, + }, + canonicalName: compartmentDescriptor.label, + log, + }); + } + if (archiveOnly) { // Return a place-holder. // Archived compartments are not executed. @@ -652,7 +711,9 @@ export const makeImportHookMaker = ( readPowers, archiveOnly, sourceMapHook, + moduleSourceHook, strictlyRequiredForCompartment, + log, }, { maybeRead, parse, shouldDeferError }, ); @@ -681,7 +742,7 @@ export const makeImportHookMaker = ( * Synchronous import for dynamic requires. * * @param {ReadNowPowers} readPowers - * @param {string} baseLocation + * @param {FileUrlString} baseLocation * @param {MakeImportNowHookMakerOptions} options * @returns {ImportNowHookMaker} */ @@ -696,13 +757,14 @@ export function makeImportNowHookMaker( archiveOnly = false, sourceMapHook = undefined, importNowHook: exitModuleImportNowHook = undefined, + moduleSourceHook, + log = noop, }, ) { // Set of specifiers for modules (scoped to compartment) whose parser is not // using heuristics to determine imports. /** @type {Map>} compartment name ->* module specifier */ const strictlyRequired = new Map(); - /** * @param {string} compartmentName */ @@ -726,10 +788,13 @@ export function makeImportNowHookMaker( compartments, shouldDeferError, }) => { + packageLocation = resolveLocation(packageLocation, baseLocation); + const packageSources = sources[packageLocation] || create(null); + sources[packageLocation] = packageSources; const deferError = makeDeferError( strictlyRequiredForCompartment, packageLocation, - sources, + packageSources, ); /** @@ -751,9 +816,9 @@ export function makeImportNowHookMaker( if (exitRecord) { // It'd be nice to check the policy before importing it, but we can only throw a policy error if the // hook returns something. Otherwise, we need to fall back to the 'cannot find' error below. - enforceModulePolicy(moduleSpecifier, compartmentDescriptor, { + enforcePolicyByModule(moduleSpecifier, compartmentDescriptor, { exit: true, - errorHint: `Blocked in loading. ${q( + errorHint: `Blocked exit module in loading. ${q( moduleSpecifier, )} was not in the compartment map and an attempt was made to load it as a builtin`, }); @@ -781,37 +846,17 @@ export function makeImportNowHookMaker( }; } - const compartmentDescriptor = compartmentDescriptors[packageLocation] || {}; + const compartmentDescriptor = + compartmentDescriptors[packageLocation] || create(null); - packageLocation = resolveLocation(packageLocation, baseLocation); - const packageSources = sources[packageLocation] || create(null); - sources[packageLocation] = packageSources; const { modules: - moduleDescriptors = /** @type {Record} */ ( + moduleDescriptors = /** @type {Record} */ ( create(null) ), } = compartmentDescriptor; - compartmentDescriptor.modules = moduleDescriptors; - let { policy } = compartmentDescriptor; - policy = policy || create(null); - - // Associates modules with compartment descriptors based on policy - // in cases where the association was not made when building the - // compartment map but is indicated by the policy. - if ('packages' in policy && typeof policy.packages === 'object') { - for (const [packageName, packagePolicyItem] of entries(policy.packages)) { - if ( - !(packageName in compartmentDescriptor.modules) && - packageName in compartmentDescriptor.scopes && - packagePolicyItem - ) { - compartmentDescriptor.modules[packageName] = - compartmentDescriptor.scopes[packageName]; - } - } - } + compartmentDescriptor.modules = moduleDescriptors; const { maybeReadNow, isAbsolute } = readPowers; @@ -872,7 +917,9 @@ export function makeImportNowHookMaker( readPowers, archiveOnly, sourceMapHook, + moduleSourceHook, strictlyRequiredForCompartment, + log, }, { maybeRead: maybeReadNow, diff --git a/packages/compartment-mapper/src/import-lite.js b/packages/compartment-mapper/src/import-lite.js index 3a78b51d7d..dfac744815 100644 --- a/packages/compartment-mapper/src/import-lite.js +++ b/packages/compartment-mapper/src/import-lite.js @@ -21,7 +21,6 @@ /** * @import { - * CompartmentMapDescriptor, * SyncImportLocationOptions, * ImportNowHookMaker, * ReadNowPowers, @@ -31,6 +30,7 @@ * ReadFn, * ReadPowers, * SomeObject, + * PackageCompartmentMapDescriptor, * } from './types.js' */ @@ -73,7 +73,7 @@ const isSyncOptions = value => { /** * @overload * @param {ReadNowPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {SyncImportLocationOptions} [opts] * @returns {Promise} */ @@ -81,14 +81,14 @@ const isSyncOptions = value => { /** * @overload * @param {ReadFn | ReadPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ImportLocationOptions} [opts] * @returns {Promise} */ /** * @param {ReadFn|ReadPowers|ReadNowPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ImportLocationOptions} [options] * @returns {Promise} */ @@ -98,6 +98,7 @@ export const loadFromMap = async (readPowers, compartmentMap, options = {}) => { searchSuffixes = undefined, parserForLanguage: parserForLanguageOption = {}, Compartment: LoadCompartmentOption = Compartment, + moduleSourceHook, } = options; const parserForLanguage = freeze( @@ -172,6 +173,7 @@ export const loadFromMap = async (readPowers, compartmentMap, options = {}) => { entryCompartmentName, entryModuleSpecifier, importHook: compartmentExitModuleImportHook, + moduleSourceHook, }, ); @@ -195,6 +197,7 @@ export const loadFromMap = async (readPowers, compartmentMap, options = {}) => { compartmentDescriptors: compartmentMap.compartments, searchSuffixes, importNowHook: exitModuleImportNowHook, + moduleSourceHook, }, ); ({ compartment, pendingJobsPromise } = link(compartmentMap, { @@ -235,7 +238,7 @@ export const loadFromMap = async (readPowers, compartmentMap, options = {}) => { /** * @param {ReadFn | ReadPowers} readPowers - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {ImportLocationOptions} [options] * @returns {Promise} the object of the imported modules exported * names. diff --git a/packages/compartment-mapper/src/import.js b/packages/compartment-mapper/src/import.js index 30530dbecd..5edb5ff03b 100644 --- a/packages/compartment-mapper/src/import.js +++ b/packages/compartment-mapper/src/import.js @@ -18,26 +18,25 @@ * Application, * SyncImportLocationOptions, * ImportLocationOptions, - * SyncArchiveOptions, * LoadLocationOptions, * SomeObject, * ReadNowPowers, - * ArchiveOptions, * ReadFn, * ReadPowers, + SyncLoadLocationOptions, * } from './types.js' */ +import { loadFromMap } from './import-lite.js'; import { defaultParserForLanguage } from './import-parsers.js'; import { mapNodeModules } from './node-modules.js'; -import { loadFromMap } from './import-lite.js'; const { assign, create, freeze } = Object; /** * Add the default parserForLanguage option. - * @param {ArchiveOptions} [options] - * @returns {ArchiveOptions} + * @param {LoadLocationOptions} [options] + * @returns {LoadLocationOptions} */ const assignParserForLanguage = (options = {}) => { const { parserForLanguage: parserForLanguageOption, ...rest } = options; @@ -52,7 +51,7 @@ const assignParserForLanguage = (options = {}) => { * @overload * @param {ReadNowPowers} readPowers * @param {string} moduleLocation - * @param {SyncArchiveOptions} options + * @param {SyncLoadLocationOptions} options * @returns {Promise} */ @@ -82,6 +81,7 @@ export const loadLocation = async ( commonDependencies, policy, parserForLanguage, + log, languages, languageForExtension, commonjsLanguageForExtension, @@ -89,6 +89,10 @@ export const loadLocation = async ( workspaceLanguageForExtension, workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, + unknownCanonicalNameHook, + packageDataHook, + packageDependenciesHook, + moduleSourceHook, ...otherOptions } = assignParserForLanguage(options); // conditions are not present in SyncArchiveOptions @@ -107,9 +111,15 @@ export const loadLocation = async ( workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, languages, + log, + unknownCanonicalNameHook, + packageDataHook, + packageDependenciesHook, }); return loadFromMap(readPowers, compartmentMap, { parserForLanguage, + log, + moduleSourceHook, ...otherOptions, }); }; diff --git a/packages/compartment-mapper/src/link.js b/packages/compartment-mapper/src/link.js index 5c24e6bba6..7f0e42fb59 100644 --- a/packages/compartment-mapper/src/link.js +++ b/packages/compartment-mapper/src/link.js @@ -13,17 +13,23 @@ /** * @import {ModuleMapHook} from 'ses' * @import { - * CompartmentDescriptor, - * CompartmentMapDescriptor, * ImportNowHookMaker, * LinkOptions, * LinkResult, - * ModuleDescriptor, * ParseFn, * AsyncParseFn, * ParserForLanguage, * ParserImplementation, * ShouldDeferError, + * ScopeDescriptor, + * CompartmentModuleConfiguration, + * PackageCompartmentMapDescriptor, + * FileUrlString, + * PackageCompartmentDescriptor, + * FileCompartmentMapDescriptor, + * FileCompartmentDescriptor, + * FileModuleConfiguration, + * MakeModuleMapHookOptions, * } from './types.js' */ @@ -31,10 +37,14 @@ import { makeMapParsers } from './map-parser.js'; import { resolve as resolveFallback } from './node-module-specifier.js'; import { attenuateGlobals, - enforceModulePolicy, + enforcePolicyByModule, makeDeferredAttenuatorsProvider, } from './policy.js'; import { ATTENUATORS_COMPARTMENT } from './policy-format.js'; +import { + isCompartmentModuleConfiguration, + isExitModuleConfiguration, +} from './guards.js'; const { assign, create, entries, freeze } = Object; const { hasOwnProperty } = Object.prototype; @@ -47,17 +57,17 @@ const { allSettled } = Promise; */ const promiseAllSettled = allSettled.bind(Promise); -const defaultCompartment = Compartment; - -// q, as in quote, for strings in error messages. -const q = JSON.stringify; +const DefaultCompartment = Compartment; /** + * TODO: can we just use `Object.hasOwn` instead? * @param {Record} object * @param {string} key * @returns {boolean} */ const has = (object, key) => apply(hasOwnProperty, object, [key]); +// q, as in quote, for strings in error messages. +const { quote: q } = assert; /** * For a full, absolute module specifier like "dependency", @@ -89,11 +99,11 @@ const trimModuleSpecifierPrefix = (moduleSpecifier, prefix) => { * Any module specifier with an absolute prefix should be captured by * the `moduleMap` or `moduleMapHook`. * - * @param {CompartmentDescriptor} compartmentDescriptor + * @param {FileCompartmentDescriptor|PackageCompartmentDescriptor} compartmentDescriptor * @param {Record} compartments * @param {string} compartmentName - * @param {Record} moduleDescriptors - * @param {Record} scopeDescriptors + * @param {Record} moduleDescriptors + * @param {Record>} scopeDescriptors * @returns {ModuleMapHook | undefined} */ const makeModuleMapHook = ( @@ -104,8 +114,7 @@ const makeModuleMapHook = ( scopeDescriptors, ) => { /** - * @param {string} moduleSpecifier - * @returns {string | object | undefined} + * @type {ModuleMapHook} */ const moduleMapHook = moduleSpecifier => { compartmentDescriptor.retained = true; @@ -114,40 +123,42 @@ const makeModuleMapHook = ( if (moduleDescriptor !== undefined) { moduleDescriptor.retained = true; + if (isExitModuleConfiguration(moduleDescriptor)) { + return undefined; + } // "foreignCompartmentName" refers to the compartment which // may differ from the current compartment - const { - compartment: foreignCompartmentName = compartmentName, - module: foreignModuleSpecifier, - exit, - } = moduleDescriptor; - if (exit !== undefined) { - return undefined; // fall through to import hook - } - if (foreignModuleSpecifier !== undefined) { - // archive goes through foreignModuleSpecifier for local modules too - if (!moduleSpecifier.startsWith('./')) { - // This code path seems to only be reached on subsequent imports of the same specifier in the same compartment. - // The check should be redundant and is only left here out of abundance of caution. - enforceModulePolicy(moduleSpecifier, compartmentDescriptor, { - exit: false, - errorHint: - 'This check should not be reachable. If you see this error, please file an issue.', - }); - } - - const foreignCompartment = compartments[foreignCompartmentName]; - if (foreignCompartment === undefined) { - throw Error( - `Cannot import from missing compartment ${q( - foreignCompartmentName, - )}}`, - ); + if (isCompartmentModuleConfiguration(moduleDescriptor)) { + const { + compartment: foreignCompartmentName = compartmentName, + module: foreignModuleSpecifier, + } = moduleDescriptor; + if (foreignModuleSpecifier !== undefined) { + // archive goes through foreignModuleSpecifier for local modules too + if (!moduleSpecifier.startsWith('./')) { + // This code path seems to only be reached on subsequent imports of the same specifier in the same compartment. + // The check should be redundant and is only left here out of abundance of caution. + enforcePolicyByModule(moduleSpecifier, compartmentDescriptor, { + exit: false, + errorHint: + 'This check should not be reachable. If you see this error, please file an issue.', + }); + } + + const foreignCompartment = compartments[foreignCompartmentName]; + if (foreignCompartment === undefined) { + throw Error( + `Cannot import from missing compartment ${q( + foreignCompartmentName, + )}}`, + ); + } + // actual module descriptor + return { + compartment: foreignCompartment, + namespace: foreignModuleSpecifier, + }; } - return { - compartment: foreignCompartment, - namespace: foreignModuleSpecifier, - }; } } @@ -178,7 +189,7 @@ const makeModuleMapHook = ( ); } - enforceModulePolicy(scopePrefix, compartmentDescriptor, { + enforcePolicyByModule(scopePrefix, compartmentDescriptor, { exit: false, errorHint: `Blocked in linking. ${q( moduleSpecifier, @@ -198,7 +209,9 @@ const makeModuleMapHook = ( retained: true, compartment: foreignCompartmentName, module: foreignModuleSpecifier, + __createdBy: 'link', }; + // actual module descriptor return { compartment: foreignCompartment, namespace: foreignModuleSpecifier, @@ -236,16 +249,10 @@ const impossibleImportNowHookMaker = () => { * - Passes the given globals and external modules into the root compartment * only. * - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor|FileCompartmentMapDescriptor} compartmentMap * @param {LinkOptions} options * @returns {LinkResult} the root compartment of the compartment DAG */ - -/** - * @param {CompartmentMapDescriptor} compartmentMap - * @param {LinkOptions} options - * @returns {LinkResult} - */ export const link = ( { entry, compartments: compartmentDescriptors }, options, @@ -262,7 +269,7 @@ export const link = ( __shimTransforms__ = [], __native__ = false, archiveOnly = false, - Compartment = defaultCompartment, + Compartment = DefaultCompartment, } = options; const { compartment: entryCompartmentName } = entry; @@ -291,9 +298,14 @@ export const link = ( syncModuleTransforms, }); - for (const [compartmentName, compartmentDescriptor] of entries( - compartmentDescriptors, - )) { + const compartmentDescriptorEntries = + /** @type {[string, PackageCompartmentDescriptor|FileCompartmentDescriptor][]} */ ( + entries(compartmentDescriptors) + ); + for (const [ + compartmentName, + compartmentDescriptor, + ] of compartmentDescriptorEntries) { const { location, name, @@ -418,7 +430,7 @@ export const link = ( }; /** - * @param {CompartmentMapDescriptor} compartmentMap + * @param {PackageCompartmentMapDescriptor} compartmentMap * @param {LinkOptions} options * @deprecated Use {@link link}. */ diff --git a/packages/compartment-mapper/src/node-modules.js b/packages/compartment-mapper/src/node-modules.js index 673e02970c..0fb980fa8a 100644 --- a/packages/compartment-mapper/src/node-modules.js +++ b/packages/compartment-mapper/src/node-modules.js @@ -1,3 +1,4 @@ +/* eslint-disable no-underscore-dangle */ /** * Provides functions for constructing a compartment map that has a * compartment descriptor corresponding to every reachable package from an @@ -16,8 +17,13 @@ import { inferExportsAndAliases } from './infer-exports.js'; import { parseLocatedJson } from './json.js'; import { join } from './node-module-specifier.js'; -import { assertPolicy, ATTENUATORS_COMPARTMENT } from './policy-format.js'; -import { dependencyAllowedByPolicy, getPolicyForPackage } from './policy.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'; @@ -37,8 +43,16 @@ import { GenericGraph, makeShortestPath } from './generic-graph.js'; * PackageDescriptor, * ReadFn, * ReadPowers, - * SomePackagePolicy, * SomePolicy, + * LogFn, + * CompartmentModuleConfiguration, + * PackageCompartmentDescriptor, + * PackageCompartmentMapDescriptor, + * ScopeDescriptor, + * CanonicalName, + * SomePackagePolicy, + * PackageCompartmentDescriptorName, + * PackageData, * } from './types.js' * @import { * Graph, @@ -50,21 +64,78 @@ import { GenericGraph, makeShortestPath } from './generic-graph.js'; * GraphPackagesOptions, * LogicalPathGraph, * PackageDetails, + * FinalGraph, + * CanonicalNameMap, + * FinalNode, + TranslateGraphOptions, * } from './types/node-modules.js' */ -const { assign, create, keys, values, entries } = Object; +const { assign, create, keys, values, entries, freeze } = Object; const decoder = new TextDecoder(); // q, as in quote, for enquoting strings in error messages. -const q = JSON.stringify; +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>} 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 }> | 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. * @@ -364,7 +435,7 @@ const calculatePackageWeight = packageName => { * @param {LanguageOptions} languageOptions * @param {boolean} strict * @param {LogicalPathGraph} logicalPathGraph - * @param {GraphPackageOptions} [options] + * @param {GraphPackageOptions} options * @returns {Promise} */ const graphPackage = async ( @@ -378,7 +449,12 @@ const graphPackage = async ( languageOptions, strict, logicalPathGraph, - { commonDependencyDescriptors = {}, logicalPath = [], log = noop } = {}, + { + commonDependencyDescriptors = {}, + log = noop, + packageDependenciesHook, + policy, + } = {}, ) => { if (graph[packageLocation] !== undefined) { // Returning the promise here would create a causal cycle and stall recursion. @@ -393,7 +469,7 @@ const graphPackage = async ( }); } - const result = /** @type {Node} */ ({}); + const result = /** @type {Node} */ ({ location: packageLocation }); graph[packageLocation] = result; /** @type {Node['dependencyLocations']} */ @@ -457,7 +533,6 @@ const graphPackage = async ( for (const dependencyName of [...allDependencies].sort()) { const optional = optionals.has(dependencyName); - const childLogicalPath = [...logicalPath, dependencyName]; children.push( // Mutual recursion ahead: // eslint-disable-next-line no-use-before-define @@ -473,10 +548,11 @@ const graphPackage = async ( strict, logicalPathGraph, { - childLogicalPath, optional, commonDependencyDescriptors, log, + packageDependenciesHook, + policy, }, ), ); @@ -518,9 +594,9 @@ const graphPackage = async ( const sourceDirname = basename(packageLocation); - assign(result, { + /** @type {Partial} */ + const partialNode = { name, - path: logicalPath, label: `${name}${version ? `-v${version}` : ''}`, sourceDirname, explicitExports: exportsDescriptor !== undefined, @@ -529,7 +605,9 @@ const graphPackage = async ( dependencyLocations, types, parsers, - }); + packageDescriptor, + }; + assign(result, partialNode); await Promise.all( values(result.externalAliases).map(async item => { @@ -601,10 +679,11 @@ const gatherDependency = async ( strict, logicalPathGraph, { - childLogicalPath = [], optional = false, commonDependencyDescriptors = {}, log = noop, + packageDependenciesHook, + policy, } = {}, ) => { const dependency = await findPackage( @@ -613,6 +692,7 @@ const gatherDependency = async ( packageLocation, name, ); + if (dependency === undefined) { // allow the dependency to be missing if optional if (optional || !strict) { @@ -620,6 +700,7 @@ const gatherDependency = async ( } throw Error(`Cannot find dependency ${name} for ${packageLocation}`); } + dependencyLocations[name] = dependency.packageLocation; logicalPathGraph.addEdge( @@ -641,8 +722,9 @@ const gatherDependency = async ( logicalPathGraph, { commonDependencyDescriptors, - logicalPath: childLogicalPath, log, + packageDependenciesHook, + policy, }, ); }; @@ -665,7 +747,7 @@ const gatherDependency = async ( * @param {LanguageOptions} languageOptions * @param {boolean} strict * @param {LogicalPathGraph} logicalPathGraph - * @param {GraphPackagesOptions} [options] + * @param {GraphPackagesOptions} options * @returns {Promise} */ const graphPackages = async ( @@ -679,7 +761,7 @@ const graphPackages = async ( languageOptions, strict, logicalPathGraph, - { log = noop } = {}, + { log = noop, packageDependenciesHook, policy } = {}, ) => { const memo = create(null); /** @@ -745,6 +827,8 @@ const graphPackages = async ( { commonDependencyDescriptors, log, + packageDependenciesHook, + policy, }, ); return graph; @@ -759,19 +843,101 @@ const graphPackages = async ( * @param {Graph} graph * @param {Set} conditions - build conditions about the target environment * for selecting relevant exports, e.g., "browser" or "node". - * @param {SomePolicy} [policy] - * @returns {CompartmentMapDescriptor} + * @param {TranslateGraphOptions} [options] + * @returns {PackageCompartmentMapDescriptor} */ const translateGraph = ( entryPackageLocation, entryModuleSpecifier, graph, conditions, - policy, + { policy, log = noop, packageDependenciesHook } = {}, ) => { - /** @type {CompartmentMapDescriptor['compartments']} */ + /** @type {Record} */ const compartments = create(null); + /** + * Execute package dependencies hooks: default first (if policy exists), then user-provided. + * + * @param {CanonicalName} label + * @param {Record} dependencyLocations + * @returns {Record} + */ + 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 @@ -781,36 +947,24 @@ const translateGraph = ( // 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 keys(graph).sort()) { + for (const dependeeLocation of /** @type {PackageCompartmentDescriptorName[]} */ ( + keys(graph).sort() + )) { const { name, - path, label, sourceDirname, - dependencyLocations, internalAliases, parsers, types, + packageDescriptor, } = graph[dependeeLocation]; - /** @type {CompartmentDescriptor['modules']} */ + /** @type {Record} */ const moduleDescriptors = create(null); - /** @type {CompartmentDescriptor['scopes']} */ + /** @type {Record>} */ const scopes = create(null); - /** - * List of all the compartments (by name) that this compartment can import from. - * - * @type {Set} - */ - const compartmentNames = new Set(); - const packagePolicy = getPolicyForPackage( - { - isEntry: dependeeLocation === entryPackageLocation, - name, - path, - }, - policy, - ); + const packagePolicy = makePackagePolicy(label, { policy }); /* c8 ignore next */ if (policy && !packagePolicy) { @@ -818,34 +972,29 @@ const translateGraph = ( throw new TypeError('Unexpectedly falsy package policy'); } + let dependencyLocations = graph[dependeeLocation].dependencyLocations; + dependencyLocations = executePackageDependenciesHook( + label, + dependencyLocations, + ); + /** * @param {string} dependencyName - * @param {string} packageLocation + * @param {PackageCompartmentDescriptorName} packageLocation */ const digestExternalAliases = (dependencyName, packageLocation) => { - const { externalAliases, explicitExports, name, path } = - graph[packageLocation]; + const { externalAliases, explicitExports } = 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 + // as in the case of browser field dependency replacements. + // note that policy still applies const localPath = join(dependencyName, exportPath); - if ( - !policy || - (packagePolicy && - dependencyAllowedByPolicy( - { - name, - path, - }, - packagePolicy, - )) - ) { - moduleDescriptors[localPath] = { - compartment: packageLocation, - module: targetPath, - }; - } + // if we have policy, this has already been vetted + moduleDescriptors[localPath] = { + compartment: packageLocation, + module: targetPath, + }; } // if the exports field is not present, then all modules must be accessible if (!explicitExports) { @@ -860,7 +1009,6 @@ const translateGraph = ( for (const dependencyName of keys(dependencyLocations).sort()) { const dependencyLocation = dependencyLocations[dependencyName]; digestExternalAliases(dependencyName, dependencyLocation); - compartmentNames.add(dependencyLocation); } // digest own internal aliases for (const modulePath of keys(internalAliases).sort()) { @@ -877,9 +1025,9 @@ const translateGraph = ( } compartments[dependeeLocation] = { + version: packageDescriptor.version ? packageDescriptor.version : '', label, name, - path, location: dependeeLocation, sourceDirname, modules: moduleDescriptors, @@ -887,7 +1035,6 @@ const translateGraph = ( parsers, types, policy: /** @type {SomePackagePolicy} */ (packagePolicy), - compartments: compartmentNames, }; } @@ -896,7 +1043,7 @@ const translateGraph = ( // https://github.com/endojs/endo/issues/2388 tags: [...conditions], entry: { - compartment: entryPackageLocation, + compartment: /** @type {FileUrlString} */ (entryPackageLocation), module: entryModuleSpecifier, }, compartments, @@ -970,23 +1117,200 @@ const makeLanguageOptions = ({ 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} + */ +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} */ + finalGraph[entryPackageLocation] = freeze({ + ...entryNode, + label: generateCanonicalName({ + isEntry: true, + path: [], + }), + }); + + canonicalNameMap.set(ENTRY_COMPARTMENT, entryPackageLocation); + + if (attenuatorsNode) { + /** @type {Readonly} */ + 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} */ + 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} 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 | MaybeReadPowers} readPowers - * @param {FileUrlString} packageLocation + * @param {FileUrlString} entryPackageLocation * @param {Set} conditionsOption * @param {PackageDescriptor} packageDescriptor - * @param {string} moduleSpecifier + * @param {string} entryModuleSpecifier * @param {CompartmentMapForNodeModulesOptions} [options] - * @returns {Promise} - * @deprecated Use {@link mapNodeModules} instead. + * @returns {Promise} */ -export const compartmentMapForNodeModules = async ( +export const compartmentMapForNodeModules_ = async ( readPowers, - packageLocation, + entryPackageLocation, conditionsOption, packageDescriptor, - moduleSpecifier, + entryModuleSpecifier, options = {}, ) => { const { @@ -995,6 +1319,9 @@ export const compartmentMapForNodeModules = async ( policy, strict = false, log = noop, + unknownCanonicalNameHook, + packageDataHook, + packageDependenciesHook, } = options; const { maybeRead, canonical } = unpackReadPowers(readPowers); const languageOptions = makeLanguageOptions(options); @@ -1017,7 +1344,7 @@ export const compartmentMapForNodeModules = async ( const graph = await graphPackages( maybeRead, canonical, - packageLocation, + entryPackageLocation, conditions, packageDescriptor, dev || (conditions && conditions.has('development')), @@ -1025,52 +1352,76 @@ export const compartmentMapForNodeModules = async ( languageOptions, strict, logicalPathGraph, - { log }, + { log, policy, packageDependenciesHook }, ); - if (policy) { - assertPolicy(policy); - - assert( - graph[ATTENUATORS_COMPARTMENT] === undefined, - `${q(ATTENUATORS_COMPARTMENT)} is a reserved compartment name`, - ); + makeAttenuatorsNode(graph, graph[entryPackageLocation], policy); - graph[ATTENUATORS_COMPARTMENT] = { - ...graph[packageLocation], - externalAliases: {}, - label: ATTENUATORS_COMPARTMENT, - name: ATTENUATORS_COMPARTMENT, - }; - } + /** + * @type {CanonicalNameMap} + */ + const canonicalNameMap = new Map(); - const shortestPath = makeShortestPath(logicalPathGraph); - // neither the entry package nor the attenuators compartment have a path; omit - const { - [ATTENUATORS_COMPARTMENT]: _, - [packageLocation]: __, - ...subgraph - } = graph; + const finalGraph = finalizeGraph( + graph, + logicalPathGraph, + entryPackageLocation, + canonicalNameMap, + ); - for (const [location, node] of entries(subgraph)) { - const shortestLogicalPath = shortestPath( - packageLocation, - // entries() loses some type information - /** @type {FileUrlString} */ (location), - ); + // 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); + } + } + } - // the first element will always be the root package location; this is omitted from the path. - shortestLogicalPath.shift(); - node.path = shortestLogicalPath.map(location => graph[location].name); - log(`Canonical name for package at ${location}: ${node.path.join('>')}`); + // Fire packageData hook with all package data before translateGraph + if (packageDataHook) { + const packageData = + /** @type {Map} */ ( + 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( - packageLocation, - moduleSpecifier, - graph, + entryPackageLocation, + entryModuleSpecifier, + finalGraph, conditions, - policy, + { policy, log, packageDependenciesHook }, ); return compartmentMap; @@ -1085,12 +1436,21 @@ export const compartmentMapForNodeModules = async ( * @param {ReadFn | ReadPowers | MaybeReadPowers} readPowers * @param {string} moduleLocation * @param {MapNodeModulesOptions} [options] - * @returns {Promise} + * @returns {Promise} */ export const mapNodeModules = async ( readPowers, moduleLocation, - { tags = new Set(), conditions = tags, log = noop, ...otherOptions } = {}, + { + tags = new Set(), + conditions = tags, + log = noop, + unknownCanonicalNameHook, + packageDataHook, + packageDependenciesHook, + policy, + ...otherOptions + } = {}, ) => { const { packageLocation, @@ -1106,12 +1466,24 @@ export const mapNodeModules = async ( assertPackageDescriptor(packageDescriptor); assertFileUrlString(packageLocation); - return compartmentMapForNodeModules( + return compartmentMapForNodeModules_( readPowers, packageLocation, conditions, packageDescriptor, moduleSpecifier, - { log, ...otherOptions }, + { + log, + policy, + unknownCanonicalNameHook, + packageDependenciesHook, + packageDataHook, + ...otherOptions, + }, ); }; + +/** + * @deprecated Use {@link mapNodeModules} instead. + */ +export const compartmentMapForNodeModules = compartmentMapForNodeModules_; diff --git a/packages/compartment-mapper/src/policy-format.js b/packages/compartment-mapper/src/policy-format.js index c524c2a956..9e1211446e 100644 --- a/packages/compartment-mapper/src/policy-format.js +++ b/packages/compartment-mapper/src/policy-format.js @@ -7,11 +7,9 @@ /** * @import {SomePackagePolicy, - * SomePolicy, * PackagePolicy, * AttenuationDefinition, * PolicyEnforcementField, - * WildcardPolicy, * UnifiedAttenuationDefinition, * PolicyItem, * TypeGuard, @@ -20,11 +18,13 @@ * ImplicitAttenuationDefinition, * FullAttenuationDefinition, * UnionToIntersection, - * PackageNamingKit + * PackageNamingKit, + * WildcardPolicy, + * SomePolicy *} from './types.js' */ -const { entries, keys } = Object; +const { entries, keys, hasOwn } = Object; const { isArray } = Array; const q = JSON.stringify; @@ -33,6 +33,8 @@ const q = JSON.stringify; */ export const ATTENUATORS_COMPARTMENT = ''; +export const ENTRY_COMPARTMENT = '$root$'; + /** * @satisfies {keyof FullAttenuationDefinition} */ @@ -52,7 +54,7 @@ const ATTENUATOR_PARAMS_KEY = 'params'; export const generateCanonicalName = ({ isEntry = false, name, path }) => { if (isEntry) { - throw Error('Entry module cannot be identified with a canonicalName'); + return ENTRY_COMPARTMENT; } if (name === ATTENUATORS_COMPARTMENT) { return ATTENUATORS_COMPARTMENT; @@ -60,9 +62,6 @@ export const generateCanonicalName = ({ isEntry = false, name, path }) => { return path.join('>'); }; -/** - * @type {WildcardPolicy} - */ export const WILDCARD_POLICY_VALUE = 'any'; /** @@ -129,11 +128,10 @@ const isEmpty = item => keys(item).length === 0; * * @param {PackagePolicy} packagePolicy Package policy * @param {PolicyEnforcementField} field Package policy field to look up - * @param {string|string[]} nameOrPath A canonical name or a path which can - * be converted to a canonical name + * @param {string} canonicalName A canonical name * @returns {boolean | AttenuationDefinition} */ -export const policyLookupHelper = (packagePolicy, field, nameOrPath) => { +export const policyLookupHelper = (packagePolicy, field, canonicalName) => { assert( POLICY_ENFORCEMENT_FIELDS.includes(field), `Unknown or unsupported policy field ${q(field)}`, @@ -153,15 +151,8 @@ export const policyLookupHelper = (packagePolicy, field, nameOrPath) => { return true; } - if (isArray(nameOrPath)) { - nameOrPath = generateCanonicalName({ - path: nameOrPath, - isEntry: nameOrPath.length === 0, - }); - } - - if (nameOrPath in policyDefinition) { - return policyDefinition[nameOrPath]; + if (hasOwn(policyDefinition, canonicalName)) { + return policyDefinition[canonicalName]; } return false; @@ -405,9 +396,8 @@ export const assertPackagePolicy = (allegedPackagePolicy, keypath, url) => { builtins, globals, noGlobalFreeze, - defaultAttenuator: _ignore, // a carve out for the default attenuator in compartment map - // eslint-disable-next-line no-unused-vars - options, // any extra options + defaultAttenuator: _defaultAttenuator, // a carve out for the default attenuator in compartment map + options: _options, // any extra options ...extra } = allegedPackagePolicy; diff --git a/packages/compartment-mapper/src/policy.js b/packages/compartment-mapper/src/policy.js index 9c5ca3b653..ce76d662ca 100644 --- a/packages/compartment-mapper/src/policy.js +++ b/packages/compartment-mapper/src/policy.js @@ -9,24 +9,27 @@ * Policy, * PackagePolicy, * AttenuationDefinition, - * PackageNamingKit, * DeferredAttenuatorsProvider, * CompartmentDescriptor, * Attenuator, * SomePolicy, * PolicyEnforcementField, * SomePackagePolicy, + * CompartmentDescriptorWithPolicy, + * ModuleConfiguration, + * CanonicalName, * } from './types.js' * @import {ThirdPartyStaticModuleInterface} from 'ses' */ import { ATTENUATORS_COMPARTMENT, - generateCanonicalName, + ENTRY_COMPARTMENT, getAttenuatorFromDefinition, isAllowingEverything, isAttenuationDefinition, policyLookupHelper, + WILDCARD_POLICY_VALUE, } from './policy-format.js'; const { @@ -115,60 +118,51 @@ export const detectAttenuators = policy => { * Verifies if a module identified by `namingKit` can be a dependency of a package per `packagePolicy`. * `packagePolicy` is required, when policy is not set, skipping needs to be handled by the caller. * - * @param {PackageNamingKit} namingKit + * @param {CanonicalName} canonicalName * @param {PackagePolicy} packagePolicy * @returns {boolean} */ -export const dependencyAllowedByPolicy = (namingKit, packagePolicy) => { - if (namingKit.isEntry) { - // dependency on entry compartment should never be allowed - return false; - } - const canonicalName = generateCanonicalName(namingKit); +export const dependencyAllowedByPolicy = (canonicalName, packagePolicy) => { return !!policyLookupHelper(packagePolicy, 'packages', canonicalName); }; /** - * Returns the policy applicable to the canonicalName of the package - * - * @overload - * @param {PackageNamingKit} namingKit - a key in the policy resources spec is derived from these - * @param {SomePolicy} policy - user supplied policy - * @returns {SomePackagePolicy} packagePolicy if policy was specified - */ - -/** - * Returns `undefined` + * Generates the {@link SomePackagePolicy} value to be used in + * {@link CompartmentDescriptor.policy} * - * @overload - * @param {PackageNamingKit} namingKit - a key in the policy resources spec is derived from these - * @param {SomePolicy} [policy] - user supplied policy - * @returns {SomePackagePolicy|undefined} packagePolicy if policy was specified + * @param {CanonicalName | typeof ATTENUATORS_COMPARTMENT | typeof ENTRY_COMPARTMENT} label + * @param {object} [options] Options + * @param {SomePolicy} [options.policy] User-supplied policy + * @returns {SomePackagePolicy|undefined} Package policy from `policy` or empty + * object; returns `params.packagePolicy` if provided. If entry compartment, + * returns the `entry` property of the policy verbatim. */ - -/** - * Returns the policy applicable to the canonicalName of the package - * - * @param {PackageNamingKit} namingKit - a key in the policy resources spec is derived from these - * @param {SomePolicy} [policy] - user supplied policy - */ -export const getPolicyForPackage = (namingKit, policy) => { - if (!policy) { - return undefined; - } - if (namingKit.isEntry) { - return policy.entry; - } - const canonicalName = generateCanonicalName(namingKit); - if (canonicalName === ATTENUATORS_COMPARTMENT) { - return { defaultAttenuator: policy.defaultAttenuator, packages: 'any' }; +export const makePackagePolicy = (label, { policy } = {}) => { + /** @type {SomePackagePolicy|undefined} */ + let packagePolicy; + if (!label) { + throw new TypeError( + `Invalid arguments: label must be a non-empty string; got ${q(label)}`, + ); } - if (policy.resources && policy.resources[canonicalName] !== undefined) { - return policy.resources[canonicalName]; - } else { - // Allow skipping policy entries for packages with no powers. - return create(null); + if (policy) { + if (label === ATTENUATORS_COMPARTMENT) { + packagePolicy = { + defaultAttenuator: policy.defaultAttenuator, + packages: WILDCARD_POLICY_VALUE, + }; + } else if (label === ENTRY_COMPARTMENT) { + packagePolicy = policy.entry; + // If policy.entry is `undefined`, we return `undefined` which is + // equivalent to "allow everything". + return packagePolicy; + } else if (label) { + packagePolicy = policy.resources?.[label]; + } + // An empty object for a package policy is equivalent to "allow nothing" + return packagePolicy ?? create(null); } + return undefined; }; /** @@ -237,6 +231,11 @@ export const makeDeferredAttenuatorsProvider = ( throw Error(`No attenuators specified in policy`); }; } else { + if (!compartmentDescriptors[ATTENUATORS_COMPARTMENT].policy) { + throw Error( + `${q(ATTENUATORS_COMPARTMENT)} is missing the required policy; this is likely a bug`, + ); + } defaultAttenuator = compartmentDescriptors[ATTENUATORS_COMPARTMENT].policy.defaultAttenuator; @@ -309,7 +308,7 @@ async function attenuateGlobalThis({ * * @param {object} globalThis * @param {object} globals - * @param {PackagePolicy} packagePolicy + * @param {PackagePolicy|undefined} packagePolicy * @param {DeferredAttenuatorsProvider} attenuators * @param {Array} pendingJobs * @param {string} name @@ -367,28 +366,21 @@ export const attenuateGlobals = ( * Generates a helpful error message for a policy enforcement failure * * @param {string} specifier - * @param {CompartmentDescriptor} referrerCompartmentDescriptor + * @param {CompartmentDescriptorWithPolicy} referrerCompartmentDescriptor * @param {object} options - * @param {string[]} [options.compartmentDescriptorPath] + * @param {string} [options.resourceCanonicalName] * @param {string} [options.errorHint] - * @param {string} [options.resourceNameFromPath] * @param {PolicyEnforcementField} [options.policyField] * @returns {string} */ const policyEnforcementFailureMessage = ( specifier, { label, policy }, - { - compartmentDescriptorPath, - resourceNameFromPath, - errorHint, - policyField = 'packages', - } = {}, + { resourceCanonicalName, errorHint, policyField = 'packages' } = {}, ) => { let message = `Importing ${q(specifier)}`; - if (compartmentDescriptorPath) { - resourceNameFromPath ??= compartmentDescriptorPath.join('>'); - message += ` in resource ${q(resourceNameFromPath)}`; + if (resourceCanonicalName) { + message += ` in resource ${q(resourceCanonicalName)}`; } message += ` in ${q(label)} was not allowed by`; if (keys(policy[policyField] ?? {}).length > 0) { @@ -403,7 +395,14 @@ const policyEnforcementFailureMessage = ( }; /** - * Options for {@link enforceModulePolicy} + * @template {ModuleConfiguration} T + * @param {CompartmentDescriptor} compartmentDescriptor + * @returns {compartmentDescriptor is CompartmentDescriptorWithPolicy} + */ +const hasPolicy = compartmentDescriptor => !!compartmentDescriptor.policy; + +/** + * Options for {@link enforcePolicyByModule} * @typedef EnforceModulePolicyOptions * @property {boolean} [exit] - Whether it is an exit module * @property {string} [errorHint] - Error hint message @@ -416,15 +415,16 @@ const policyEnforcementFailureMessage = ( * @param {CompartmentDescriptor} compartmentDescriptor * @param {EnforceModulePolicyOptions} [options] */ -export const enforceModulePolicy = ( +export const enforcePolicyByModule = ( specifier, compartmentDescriptor, { exit, errorHint } = {}, ) => { - const { policy, modules } = compartmentDescriptor; - if (!policy) { + if (!hasPolicy(compartmentDescriptor)) { + // No policy, no enforcement return; } + const { policy, modules } = compartmentDescriptor; if (!exit) { if (!modules[specifier]) { @@ -455,39 +455,27 @@ export const enforceModulePolicy = ( * @param {CompartmentDescriptor} referrerCompartmentDescriptor * @param {EnforceModulePolicyOptions} [options] */ -export const enforcePackagePolicyByPath = ( +export const enforcePackagePolicyByCanonicalName = ( compartmentDescriptor, referrerCompartmentDescriptor, { errorHint } = {}, ) => { - const { policy: referrerPolicy } = referrerCompartmentDescriptor; - if (!referrerPolicy) { - throw Error( + if (!hasPolicy(referrerCompartmentDescriptor)) { + throw new Error( `Cannot enforce policy via ${q(referrerCompartmentDescriptor.label)}: no package policy defined`, ); } - const { path, name } = compartmentDescriptor; - - assert( - path, - `Compartment descriptor ${q(compartmentDescriptor.label)} does not have a path; cannot enforce policy via ${q(referrerCompartmentDescriptor.label)}`, - ); - - const resourceNameFromPath = generateCanonicalName({ - path, - name, - isEntry: path.length === 0, - }); + const { policy: referrerPolicy } = referrerCompartmentDescriptor; + const { label: resourceCanonicalName } = compartmentDescriptor; - if (!policyLookupHelper(referrerPolicy, 'packages', resourceNameFromPath)) { - throw Error( + if (!policyLookupHelper(referrerPolicy, 'packages', resourceCanonicalName)) { + throw new Error( policyEnforcementFailureMessage( - resourceNameFromPath, + resourceCanonicalName, referrerCompartmentDescriptor, { errorHint, - compartmentDescriptorPath: path, - resourceNameFromPath, + resourceCanonicalName, }, ), ); @@ -516,19 +504,21 @@ async function attenuateModule({ // An async attenuator maker could be introduced here to return a synchronous attenuator. // For async attenuators see PR https://github.com/endojs/endo/pull/1535 - return freeze({ - imports: originalModuleRecord.imports, - // It seems ok to declare the exports but then let the attenuator trim the values. - // Seems ok for attenuation to leave them undefined - accessing them is malicious behavior. - exports: originalModuleRecord.exports, - execute: (moduleExports, compartment, resolvedImports) => { - const ns = {}; - originalModuleRecord.execute(ns, compartment, resolvedImports); - const attenuated = attenuate(ns); - moduleExports.default = attenuated; - assign(moduleExports, attenuated); - }, - }); + return freeze( + /** @type {ThirdPartyStaticModuleInterface} */ ({ + imports: originalModuleRecord.imports, + // It seems ok to declare the exports but then let the attenuator trim the values. + // Seems ok for attenuation to leave them undefined - accessing them is malicious behavior. + exports: originalModuleRecord.exports, + execute: (moduleExports, compartment, resolvedImports) => { + const ns = {}; + originalModuleRecord.execute(ns, compartment, resolvedImports); + const attenuated = attenuate(ns); + moduleExports.default = attenuated; + assign(moduleExports, attenuated); + }, + }), + ); } /** @@ -536,7 +526,7 @@ async function attenuateModule({ * * @param {string} specifier - exit module name * @param {ThirdPartyStaticModuleInterface} originalModuleRecord - reference to the exit module - * @param {PackagePolicy} policy - local compartment policy + * @param {PackagePolicy|undefined} policy - local compartment policy * @param {DeferredAttenuatorsProvider} attenuators - a key-value where attenuations can be found * @returns {Promise} - the attenuated module */ @@ -546,8 +536,11 @@ export const attenuateModuleHook = async ( policy, attenuators, ) => { + if (!policy) { + return originalModuleRecord; + } const policyValue = policyLookupHelper(policy, 'builtins', specifier); - if (!policy || policyValue === true) { + if (policyValue === true) { return originalModuleRecord; } diff --git a/packages/compartment-mapper/src/types/canonical-name.ts b/packages/compartment-mapper/src/types/canonical-name.ts new file mode 100644 index 0000000000..fd89c3e3da --- /dev/null +++ b/packages/compartment-mapper/src/types/canonical-name.ts @@ -0,0 +1,151 @@ +/** + * Fairly exhaustive, excruciatingly pedantic _type-level_ helpers for + * representing and validating **Canonical Names** and npm package names. + * + * A {@link CanonicalName | Canonical Name} is a string containing one or more + * npm package names (scoped or unscoped) delimited by a `>` character. + * + * The following rules about npm package names are enforced: + * + * - ✅ Length > 0 + * - ✅ Can contain hyphens + * - ✅ No leading `.` or `_` + * - ✅ No spaces + * - ✅ No `~)('!*` characters + * + * The following rules are not enforced: + * + * - ❌ All lowercase - Not feasible due to recursion limits & legacy package + * names + * - ❌ Not a reserved name - unmaintainable list of node builtin module names + * - ❌ Length ≤ 214 - Not feasible due to recursion limits & legacy package + * names + * + * "Legacy" package names may contain uppercase letters and be longer than 214 + * characters. + * + * @module + * @see {@link https://www.npmjs.com/package/validate-npm-package-name} + */ + +/** + * Characters that are explicitly forbidden in npm package names. These include: + * ` `, `~`, `)`, `(`, `'`, `!`, `*` + * + * We check each one individually because TypeScript's template literal types + * can detect if a string contains a specific substring. + * + * Returns `true` if the string contains a forbidden character, `false` + * otherwise. + */ +type ContainsForbiddenChar = S extends + | `${string} ${string}` + | `${string}~${string}` + | `${string})${string}` + | `${string}(${string}` + | `${string}'${string}` + | `${string}!${string}` + | `${string}*${string}` + ? true + : false; + +/** + * Validates that a string doesn't start with `.` or `_`. + * + * Returns `true` if the string doesn't start with `.` or `_`, `false` + * otherwise. + */ +type HasValidStart = S extends `.${string}` | `_${string}` + ? false + : true; + +/** + * Validates that a string is non-empty. + * + * Returns `true` if the string is non-empty, `false` otherwise. + */ +type IsNonEmpty = S extends '' ? false : true; + +/** + * Combines all validation checks for a package name segment. + * + * Returns `true` if the string passes all checks, `false` otherwise. + */ +type IsValidPackageNameSegment = + IsNonEmpty extends false + ? false + : HasValidStart extends false + ? false + : ContainsForbiddenChar extends true + ? false + : true; + +// ============================================================================ +// Scoped and Unscoped Package Names +// ============================================================================ + +/** A scoped npm package name, like "@scope/pkg" */ +export type ScopedPackageName = + S extends `@${infer Scope}/${infer Name}` + ? IsValidPackageNameSegment extends true + ? IsValidPackageNameSegment extends true + ? S + : never + : never + : never; + +/** + * An unscoped npm package name. + * + * Must pass all validation checks and must not contain a `/` (which would + * indicate a scoped package or a subpath). + * + * Note: Package names containing uppercase letters are technically invalid per + * npm rules, but they exist in the wild. TypeScript cannot reliably validate + * case at the type level, so we don't enforce this. + */ +export type UnscopedPackageName = + S extends `${string}/${string}` + ? never + : IsValidPackageNameSegment extends true + ? S + : never; + +/** + * A scoped or unscoped npm package name. + */ +export type NpmPackageName = + S extends `@${string}/${string}` + ? ScopedPackageName + : UnscopedPackageName; + +/** + * Split a string on `>`—the canonical name delimiter—into a tuple of segments. + */ +export type SplitOnDelimiter = + S extends `${infer Head}>${infer Tail}` + ? [Head, ...SplitOnDelimiter] + : [S]; + +/** + * Validate that every element in a tuple of strings is a valid npm package + * name. + */ +export type AllValidPackageNames = + Parts extends [ + infer Head extends string, + ...infer Tail extends readonly string[], + ] + ? NpmPackageName extends never + ? never + : AllValidPackageNames + : Parts; + +/** + * A Canonical Name string comprised of one or more npm package names separated + * by `>` (e.g., `foo`, `@scope/foo>bar`, `foo>@scope/bar>baz`). + * + * When given a string literal type, invalid shapes narrow to `never`. + */ +export type CanonicalName = + AllValidPackageNames> extends never ? never : S; diff --git a/packages/compartment-mapper/src/types/compartment-map-schema.ts b/packages/compartment-mapper/src/types/compartment-map-schema.ts index 0691058fba..632c0ab0bd 100644 --- a/packages/compartment-mapper/src/types/compartment-map-schema.ts +++ b/packages/compartment-mapper/src/types/compartment-map-schema.ts @@ -8,80 +8,229 @@ /* eslint-disable no-use-before-define */ +import type { + ATTENUATORS_COMPARTMENT, + ENTRY_COMPARTMENT, +} from '../policy-format.js'; +import type { CanonicalName } from './canonical-name.js'; +import type { FileUrlString } from './external.js'; import type { SomePackagePolicy } from './policy-schema.js'; import type { LiteralUnion } from './typescript.js'; +/** + * The type of a {@link CompartmentMapDescriptor.compartments} property. + */ +export type CompartmentDescriptors< + T extends CompartmentDescriptor = CompartmentDescriptor, + K extends string = string, +> = Record; + /** * A compartment map describes how to construct an application as a graph of * Compartments, each corresponding to Node.js style packaged modules. */ -export type CompartmentMapDescriptor = { +export type CompartmentMapDescriptor< + T extends CompartmentDescriptor = CompartmentDescriptor, + Name extends string = string, + EntryName extends string = Name, +> = { tags: Array; - entry: EntryDescriptor; - compartments: Record; + entry: EntryDescriptor; + compartments: CompartmentDescriptors; }; +/** + * The type of a {@link PackageCompartmentMapDescriptor.compartments} property. + */ +export type PackageCompartmentDescriptors = CompartmentDescriptors< + PackageCompartmentDescriptor, + PackageCompartmentDescriptorName +>; + +/** + * The {@link CompartmentDescriptor} type in the + * {@link PackageCompartmentMapDescriptor} returned by `mapNodeModules()` + */ +export type PackageCompartmentMapDescriptor = CompartmentMapDescriptor< + PackageCompartmentDescriptor, + PackageCompartmentDescriptorName, + FileUrlString +>; + +export interface FileCompartmentDescriptor + extends CompartmentDescriptor< + FileModuleConfiguration | CompartmentModuleConfiguration + > { + location: FileUrlString; +} + +export type FileCompartmentDescriptors = + CompartmentDescriptors; + +export type FileCompartmentMapDescriptor = + CompartmentMapDescriptor; + /** * The entry descriptor of a compartment map denotes the root module of an * application and the compartment that contains it. */ -export type EntryDescriptor = { - compartment: string; +export type EntryDescriptor = { + compartment: K; module: string; }; +/** + * Keys of {@link PackageCompartmentMapDescriptor.compartments} + */ +export type PackageCompartmentDescriptorName = LiteralUnion< + typeof ATTENUATORS_COMPARTMENT, + FileUrlString +>; + +export interface PackageCompartmentDescriptor + extends CompartmentDescriptor { + label: LiteralUnion< + typeof ATTENUATORS_COMPARTMENT | typeof ENTRY_COMPARTMENT, + string + >; + + version: string; + + location: PackageCompartmentDescriptorName; + + name: LiteralUnion< + typeof ATTENUATORS_COMPARTMENT | typeof ENTRY_COMPARTMENT, + string + >; + + scopes: Record>; + + sourceDirname: string; +} + /** * A compartment descriptor corresponds to a single Compartment * of an assembled Application and describes how to construct * one for a given library or application `package.json`. */ -export type CompartmentDescriptor = { - label: string; - /** - * name of the parent directory of the package from which the compartment is derived, - * for purposes of generating sourceURL comments that are most likely to unite with the original sources in an IDE workspace. - */ - sourceDirname?: string; - /** shortest path of dependency names to this compartment */ - path?: Array; +export interface CompartmentDescriptor< + T extends ModuleConfiguration = ModuleConfiguration, + U extends string = string, +> { + label: CanonicalName; /** * the name of the originating package suitable for constructing a sourceURL * prefix that will match it to files in a developer workspace. */ name: string; + modules: Record; + scopes?: Record; + /** language for extension */ + parsers?: LanguageForExtension; + /** language for module specifier */ + types?: LanguageForModuleSpecifier; + /** policy specific to compartment */ + policy?: SomePackagePolicy; + location: string; + /** + * name of the parent directory of the package from which the compartment is derived, + * for purposes of generating sourceURL comments that are most likely to unite with the original sources in an IDE workspace. + */ + sourceDirname?: string; + /** * whether this compartment was retained by any module in the solution. This * property should never appear in an archived compartment map. */ - retained?: boolean; - modules: Record; - scopes: Record; - /** language for extension */ - parsers: LanguageForExtension; - /** language for module specifier */ - types: LanguageForModuleSpecifier; - /** policy specific to compartment */ - policy: SomePackagePolicy; - /** List of compartment names this Compartment depends upon */ - compartments: Set; -}; + retained?: true; +} + +export type CompartmentDescriptorWithPolicy< + T extends ModuleConfiguration = ModuleConfiguration, +> = Omit, 'policy'> & { policy: SomePackagePolicy }; + +/** + * A compartment descriptor digested by `digestCompartmentMap()` + */ +export interface DigestedCompartmentDescriptor + extends CompartmentDescriptor { + path: never; + retained: never; + scopes: never; + parsers: never; + types: never; + __createdBy: never; + sourceDirname: never; +} + +export type DigestedCompartmentDescriptors = + CompartmentDescriptors; + +export type DigestedCompartmentMapDescriptor = + CompartmentMapDescriptor; /** * For every module explicitly mentioned in an `exports` field of a - * `package.json`, there is a corresponding module descriptor. + * `package.json`, there is a corresponding `ModuleConfiguration`. */ -export type ModuleDescriptor = { - compartment?: string; - module?: string; +export type ModuleConfiguration = + | ErrorModuleConfiguration + | ExitModuleConfiguration + | FileModuleConfiguration + | CompartmentModuleConfiguration; + +export type ModuleConfigurationCreator = + | 'link' + | 'transform' + | 'import-hook' + | 'digest' + | 'node-modules'; + +export interface BaseModuleConfiguration { + deferredError?: string; + + retained?: true; + + __createdBy?: ModuleConfigurationCreator; +} + +export interface ErrorModuleConfiguration extends BaseModuleConfiguration { + deferredError: string; +} + +/** + * This module configuration is a reference to another module in a a compartment descriptor (it may be the same compartment descriptor) + */ +export interface CompartmentModuleConfiguration + extends BaseModuleConfiguration { + /** + * The name of the compartment that contains this module. + */ + compartment: LiteralUnion; + /** + * The module name within {@link CompartmentDescriptor.modules} of the + * `CompartmentDescriptor` referred to by {@link compartment} + */ + module: string; +} + +/** + * A module configuration representing an exit module + */ +export interface ExitModuleConfiguration extends BaseModuleConfiguration { + exit: string; +} + +/** + * A module configuration representing a file on disk + */ +export interface FileModuleConfiguration extends BaseModuleConfiguration { location?: string; - parser?: Language; + parser: Language; /** in base 16, hex */ sha512?: string; - exit?: string; - deferredError?: string; - retained?: boolean; -}; +} /** * Scope descriptors link all names under a prefix to modules in another @@ -90,8 +239,11 @@ export type ModuleDescriptor = { * in a `package.json` file, when that `package.json` file does not have * an explicit `exports` map. */ -export type ScopeDescriptor = { - compartment: string; +export type ScopeDescriptor = { + /** + * A compartment name; not a `Compartment`. + */ + compartment: T; module?: string; }; @@ -121,3 +273,16 @@ export type LanguageForExtension = Record; * Mapping of module specifier to {@link Language Languages}. */ export type LanguageForModuleSpecifier = Record; + +export type ModuleConfigurationKind = 'file' | 'compartment' | 'exit' | 'error'; + +export type ModuleConfigurationKindToType = + T extends 'file' + ? FileModuleConfiguration + : T extends 'compartment' + ? CompartmentModuleConfiguration + : T extends 'exit' + ? ExitModuleConfiguration + : T extends 'error' + ? ErrorModuleConfiguration + : never; diff --git a/packages/compartment-mapper/src/types/external.ts b/packages/compartment-mapper/src/types/external.ts index 7899ffe860..32ec122d2a 100644 --- a/packages/compartment-mapper/src/types/external.ts +++ b/packages/compartment-mapper/src/types/external.ts @@ -15,11 +15,105 @@ import type { import type { CompartmentDescriptor, CompartmentMapDescriptor, + DigestedCompartmentMapDescriptor, Language, LanguageForExtension, + PackageCompartmentDescriptorName, + PackageCompartmentDescriptors, } from './compartment-map-schema.js'; -import type { HashFn, ReadFn, ReadPowers } from './powers.js'; import type { SomePolicy } from './policy-schema.js'; +import type { HashFn, ReadFn, ReadPowers } from './powers.js'; +import type { CanonicalName } from './canonical-name.js'; +import type { PackageDescriptor } from './node-modules.js'; + +export type { CanonicalName }; +export type { PackageDescriptor }; + +/** + * Hook executed for each canonical name mentioned in policy but not found in the + * compartment map + */ +export type UnknownCanonicalNameHook = (params: { + canonicalName: CanonicalName; + path: string[]; + message: string; + suggestion?: CanonicalName | undefined; + log: LogFn; +}) => void; + +export type { PackageCompartmentDescriptorName }; + +/** + * Data about a package provided by a {@link PackageDataHook} + */ +export type PackageData = { + name: string; + packageDescriptor: PackageDescriptor; + location: FileUrlString; + canonicalName: PackageCompartmentDescriptorName; +}; + +/** + * Hook executed with data about all packages found while crawling `node_modules`. + * + * Called once before `translateGraph`. + */ +export type PackageDataHook = (params: { + packageData: Readonly>; + log: LogFn; +}) => void; + +/** + * Hook executed for each canonical name (corresponding to a package) in the + * `CompartmentMapDescriptor` with a list of canonical names of its + * dependencies. + * + * Can return partial updates to modify the dependencies set. + * + * Suggested use cases: + * - Adding dependencies based on policy + * - Removing dependencies based on policy + * - Filtering dependencies based on multiple criteria + */ +export type PackageDependenciesHook = (params: { + canonicalName: CanonicalName; + dependencies: Readonly>; + log: LogFn; +}) => Partial<{ dependencies: Set }> | void; + +/** + * The `moduleSource` property value for {@link ModuleSourceHook} + */ +export type ModuleSourceHookModuleSource = + | { + location: FileUrlString; + language: Language; + bytes: Uint8Array; + imports?: string[] | undefined; + exports?: string[] | undefined; + reexports?: string[] | undefined; + sha512?: string | undefined; + } + | { error: string } + | { exit: string }; + +/** + * Hook executed when processing a module source. + */ +export type ModuleSourceHook = (params: { + moduleSource: ModuleSourceHookModuleSource; + canonicalName: CanonicalName; + log: LogFn; +}) => void; + +/** + * Hook executed for each canonical name with its connections (linked compartments). + */ +export type PackageConnectionsHook = (params: { + canonicalName: CanonicalName; + connections: Set; + log: LogFn; +}) => void; /** * Set of options available in the context of code execution. @@ -66,6 +160,7 @@ export interface LogOptions { */ export type MapNodeModulesOptions = MapNodeModulesOptionsOmitPolicy & PolicyOption & + MapNodeModulesHookOptions & LogOptions; type MapNodeModulesOptionsOmitPolicy = Partial<{ @@ -128,8 +223,14 @@ type MapNodeModulesOptionsOmitPolicy = Partial<{ }>; /** - * @deprecated Use `mapNodeModules()`. + * Hook options for `mapNodeModules()` */ +export type MapNodeModulesHookOptions = { + unknownCanonicalNameHook?: UnknownCanonicalNameHook | undefined; + packageDependenciesHook?: PackageDependenciesHook | undefined; + packageDataHook?: PackageDataHook | undefined; +}; + export type CompartmentMapForNodeModulesOptions = Omit< MapNodeModulesOptions, 'conditions' | 'tags' @@ -142,7 +243,16 @@ export type CaptureLiteOptions = ImportingOptions & LinkingOptions & PolicyOption & LogOptions & - PreloadOption; + PreloadOption & + CaptureFromMapHookOptions; + +/** + * Hook options for `captureFromMap()` + */ +export type CaptureFromMapHookOptions = { + packageConnectionsHook?: PackageConnectionsHook | undefined; + moduleSourceHook?: ModuleSourceHook | undefined; +}; /** * Options bag containing a `preload` array. @@ -150,7 +260,7 @@ export type CaptureLiteOptions = ImportingOptions & export interface PreloadOption { /** * List of compartment names (the keys of - * {@link CompartmentMapDescriptor.compartments}) and entries (`ModuleDescriptorConfiguration` names) to force-load _after_ the + * {@link CompartmentMapDescriptor.compartments}) and entries (`ModuleConfiguration` names) to force-load _after_ the * entry compartment and any attenuators. * * If an array of strings is provided, the entry is assumed to be `.`. @@ -162,7 +272,8 @@ export type ArchiveLiteOptions = SyncOrAsyncArchiveOptions & ModuleTransformsOption & ImportingOptions & ExitModuleImportHookOption & - LinkingOptions; + LinkingOptions & + LogOptions; export type SyncArchiveLiteOptions = SyncOrAsyncArchiveOptions & SyncModuleTransformsOption & @@ -198,15 +309,35 @@ export type BundleOptions = ArchiveOptions & { sourceUrlPrefix?: string; }; -export type SyncArchiveOptions = Omit & +export type SyncArchiveOptions = Omit< + MapNodeModulesOptions, + | 'languages' + | 'unknownCanonicalNameHook' + | 'packageDependenciesHook' + | 'packageDataHook' +> & SyncArchiveLiteOptions; +export type SyncLoadLocationOptions = SyncArchiveOptions & + LogOptions & + PreloadOption & + LoadLocationHookOptions; + /** * Options for `loadLocation()` */ export type LoadLocationOptions = ArchiveOptions & SyncArchiveOptions & - LogOptions; + LogOptions & + PreloadOption & + LoadLocationHookOptions; + +/** + * Hook options for `loadLocation()` + */ +export type LoadLocationHookOptions = MapNodeModulesHookOptions & { + moduleSourceHook?: ModuleSourceHook | undefined; +}; /** * Options for `importLocation()` necessary (but not sufficient--see @@ -214,14 +345,17 @@ export type LoadLocationOptions = ArchiveOptions & */ export type SyncImportLocationOptions = SyncArchiveOptions & ExecuteOptions & - LogOptions; + LogOptions & + PreloadOption; /** * Options for `importLocation()` without dynamic require support */ export type ImportLocationOptions = ArchiveOptions & ExecuteOptions & - LogOptions; + LoadLocationHookOptions & + LogOptions & + PreloadOption; // //////////////////////////////////////////////////////////////////////////////// // Single Options @@ -351,11 +485,14 @@ type LinkingOptions = ParserForLanguageOption & /** * Result of `digestCompartmentMap()` */ -export interface DigestResult { +export interface DigestResult< + OldCompartmentName extends string = FileUrlString, + NewCompartmentName extends string = PackageCompartmentDescriptorName, +> { /** * Normalized `CompartmentMapDescriptor` */ - compartmentMap: CompartmentMapDescriptor; + compartmentMap: DigestedCompartmentMapDescriptor; /** * Sources found in the `CompartmentMapDescriptor` @@ -366,34 +503,37 @@ export interface DigestResult { * A record of renamed {@link CompartmentDescriptor CompartmentDescriptors} * from _new_ to _original_ name */ - newToOldCompartmentNames: Record; + newToOldCompartmentNames: Record; /** * A record of renamed {@link CompartmentDescriptor CompartmentDescriptors} * from _original_ to _new_ name */ - oldToNewCompartmentNames: Record; + oldToNewCompartmentNames: Record; /** * Alias for `newToOldCompartmentNames` * @deprecated Use {@link newToOldCompartmentNames} instead. */ - compartmentRenames: Record; + compartmentRenames: Record; } /** * The result of `captureFromMap`. */ export type CaptureResult = Omit & { - captureCompartmentMap: DigestResult['compartmentMap']; + captureCompartmentMap: DigestedCompartmentMapDescriptor; captureSources: DigestResult['sources']; }; /** * The result of `makeArchiveCompartmentMap` */ -export type ArchiveResult = Omit & { - archiveCompartmentMap: DigestResult['compartmentMap']; +export type ArchiveResult = Omit< + DigestResult, + 'compartmentMap' | 'sources' +> & { + archiveCompartmentMap: DigestedCompartmentMapDescriptor; archiveSources: DigestResult['sources']; }; @@ -404,18 +544,31 @@ export type ArchiveResult = Omit & { export type Sources = Record; export type CompartmentSources = Record; -// TODO unionize: -export type ModuleSource = Partial<{ +export type ModuleSource = + | LocalModuleSource + | ExitModuleSource + | ErrorModuleSource; + +export interface BaseModuleSource { /** module loading error deferred to later stage */ + + deferredError?: string; +} + +export interface ErrorModuleSource extends BaseModuleSource { deferredError: string; +} + +export interface LocalModuleSource extends BaseModuleSource { /** * package-relative location. * Not suitable for capture in an archive or bundle since it varies from host * to host and would frustrate integrity hash checks. */ location: string; - /** fully qualified location */ - sourceLocation: string; + parser: Language; + /** in lowercase base-16 (hexadecimal) */ + sha512?: string; /** * directory name of the original source. * This is safe to capture in a compartment map because it is _unlikely_ to @@ -431,15 +584,19 @@ export type ModuleSource = Partial<{ * https://github.com/endojs/endo/issues/2671 */ sourceDirname: string; + /** fully qualified location */ + + sourceLocation: FileUrlString; bytes: Uint8Array; - /** in lowercase base-16 (hexadecimal) */ - sha512: string; - parser: Language; - /** indicates that this is a reference that exits the mapped compartments */ - exit: string; /** module for the module */ record: StaticModuleType; -}>; +} + +export interface ExitModuleSource extends BaseModuleSource { + /** indicates that this is a reference that exits the mapped compartments */ + + exit: string; +} export type SourceMapHook = ( sourceMap: string, @@ -575,3 +732,16 @@ export type LogFn = (...args: any[]) => void; * A string that represents a file URL. */ export type FileUrlString = `file://${string}`; + +/** + * A function that renames compartments; used by `digestCompartmentMap()`. + * + * The default implementation uses {@link PackageCompartmentDescriptorName} as the key type. + * + * @returns Mapping from old compartment names to new compartment names + * @template NewName Key type + */ +export type CompartmentsRenameFn< + OldName extends string = FileUrlString, + NewName extends string = PackageCompartmentDescriptorName, +> = (compartments: PackageCompartmentDescriptors) => Record; diff --git a/packages/compartment-mapper/src/types/internal.ts b/packages/compartment-mapper/src/types/internal.ts index 2accd51b12..327ce53146 100644 --- a/packages/compartment-mapper/src/types/internal.ts +++ b/packages/compartment-mapper/src/types/internal.ts @@ -13,15 +13,11 @@ import type { Language, LanguageForExtension, LanguageForModuleSpecifier, - ModuleDescriptor, + PackageCompartmentDescriptor, + PackageCompartmentDescriptors, + CompartmentModuleConfiguration, + PackageCompartmentDescriptorName, } from './compartment-map-schema.js'; -import type { - MaybeReadFn, - MaybeReadNowFn, - ReadFn, - ReadPowers, -} from './powers.js'; -import type { DeferredAttenuatorsProvider } from './policy.js'; import type { ArchiveOnlyOption, AsyncParseFn, @@ -30,16 +26,30 @@ import type { ExecuteOptions, ExitModuleImportHookOption, ExitModuleImportNowHookOption, + FileUrlString, + ModuleSourceHook, + PackageConnectionsHook, + PreloadOption, LogOptions, ModuleTransforms, ParseFn, ParserForLanguage, + PolicyOption, SearchSuffixesOption, SourceMapHook, SourceMapHookOption, Sources, SyncModuleTransforms, + CompartmentsRenameFn, } from './external.js'; +import type { PackageDescriptor } from './node-modules.js'; +import type { DeferredAttenuatorsProvider } from './policy.js'; +import type { + MaybeReadFn, + MaybeReadNowFn, + ReadFn, + ReadPowers, +} from './powers.js'; export type LinkOptions = { resolve?: ResolveHook; @@ -50,7 +60,8 @@ export type LinkOptions = { syncModuleTransforms?: SyncModuleTransforms; __native__?: boolean; } & ArchiveOnlyOption & - ExecuteOptions; + ExecuteOptions & + LogOptions; export type LinkResult = { compartment: Compartment; @@ -76,11 +87,13 @@ export type MakeImportHookMakersOptions = { /** * For depositing captured compartment descriptors. */ - compartmentDescriptors?: Record; + compartmentDescriptors?: PackageCompartmentDescriptors; + moduleSourceHook?: ModuleSourceHook | undefined; } & ComputeSha512Option & SearchSuffixesOption & ArchiveOnlyOption & - SourceMapHookOption; + SourceMapHookOption & + LogOptions; export type MakeImportHookMakerOptions = MakeImportHookMakersOptions & ExitModuleImportHookOption; @@ -88,7 +101,7 @@ export type MakeImportNowHookMakerOptions = MakeImportHookMakersOptions & ExitModuleImportNowHookOption; export type ImportHookMaker = (params: { - packageLocation: string; + packageLocation: PackageCompartmentDescriptorName; packageName: string; attenuators: DeferredAttenuatorsProvider; parse: ParseFn | AsyncParseFn; @@ -97,7 +110,7 @@ export type ImportHookMaker = (params: { }) => ImportHook; export type ImportNowHookMaker = (params: { - packageLocation: string; + packageLocation: PackageCompartmentDescriptorName; packageName: string; parse: ParseFn | AsyncParseFn; compartments: Record; @@ -125,13 +138,13 @@ export type ChooseModuleDescriptorParams = { /** Module specifiers with each search suffix appended */ candidates: string[]; moduleSpecifier: string; - packageLocation: string; + packageLocation: FileUrlString; /** Compartment descriptor from the compartment map */ - compartmentDescriptor: CompartmentDescriptor; + compartmentDescriptor: PackageCompartmentDescriptor; /** All compartment descriptors from the compartment map */ - compartmentDescriptors: Record; + compartmentDescriptors: PackageCompartmentDescriptors; /** All module descriptors in same compartment */ - moduleDescriptors: Record; + moduleDescriptors: Record; /** All compartments */ compartments: Record; packageSources: CompartmentSources; @@ -140,11 +153,13 @@ export type ChooseModuleDescriptorParams = { * Whether to embed a sourceURL in applicable compiled sources. * Should be false for archives and bundles, but true for runtime. */ - sourceMapHook?: SourceMapHook; + sourceMapHook?: SourceMapHook | undefined; + moduleSourceHook?: ModuleSourceHook | undefined; strictlyRequiredForCompartment: StrictlyRequiredFn; } & ComputeSha512Option & - ArchiveOnlyOption; + ArchiveOnlyOption & + LogOptions; type ShouldDeferErrorOption = { shouldDeferError: ShouldDeferError; @@ -276,37 +291,6 @@ export type MaybeReadDescriptorFn = ( location: string, ) => Promise; -/** - * The type of a `package.json` file containing relevant fields; used by `graphPackages` and its ilk - */ -export interface PackageDescriptor { - /** - * TODO: In reality, this is optional, but `graphPackage` does not consider it to be. This will need to be fixed once support for "anonymous" packages lands; see https://github.com/endojs/endo/pull/2664 - */ - name: string; - version?: string; - /** - * TODO: Update with proper type when this field is handled. - */ - exports?: unknown; - type?: 'module' | 'commonjs'; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; - optionalDependencies?: Record; - bundleDependencies?: string[]; - peerDependenciesMeta?: Record< - string, - { optional?: boolean; [k: string]: unknown } - >; - module?: string; - browser?: Record | string; - - main?: string; - - [k: string]: unknown; -} - /** * Function returning a set of module names (scoped to the compartment) whose * parser is not using heuristics to determine imports. @@ -329,3 +313,20 @@ export type DeferErrorFn = ( */ error: Error, ) => StaticModuleType; + +export type MakeLoadCompartmentsOptions = LogOptions & + PolicyOption & + PreloadOption; + +export type DigestCompartmentMapOptions< + OldCompartmentName extends string = FileUrlString, + NewCompartmentName extends string = PackageCompartmentDescriptorName, +> = LogOptions & { + packageConnectionsHook?: PackageConnectionsHook | undefined; + renameCompartments?: CompartmentsRenameFn< + OldCompartmentName, + NewCompartmentName + >; +}; + +export type CaptureCompartmentMapOptions = DigestCompartmentMapOptions; diff --git a/packages/compartment-mapper/src/types/node-modules.ts b/packages/compartment-mapper/src/types/node-modules.ts index 96f8317bc8..ba63fa4498 100644 --- a/packages/compartment-mapper/src/types/node-modules.ts +++ b/packages/compartment-mapper/src/types/node-modules.ts @@ -1,10 +1,25 @@ import type { GenericGraph } from '../generic-graph.js'; +import type { + ATTENUATORS_COMPARTMENT, + ENTRY_COMPARTMENT, +} from '../policy-format.js'; +import type { + CanonicalName, + CompartmentMapDescriptor, + PackageCompartmentDescriptorName, + PolicyOption, + SomePolicy, +} from '../types.js'; import type { Language, LanguageForExtension, + PackageCompartmentMapDescriptor, } from './compartment-map-schema.js'; -import type { LogOptions, FileUrlString } from './external.js'; -import type { PackageDescriptor } from './internal.js'; +import type { + FileUrlString, + LogOptions, + PackageDependenciesHook, +} from './external.js'; import type { LiteralUnion } from './typescript.js'; export type CommonDependencyDescriptors = Record< @@ -23,18 +38,27 @@ export type CommonDependencyDescriptorsOptions = { commonDependencyDescriptors?: CommonDependencyDescriptors; }; +/** + * Options bag containing a {@link PackageDependenciesHook} + */ +export type PackageDependenciesHookOption = { + packageDependenciesHook?: PackageDependenciesHook | undefined; +}; + /** * Options for `graphPackage()` */ -export type GraphPackageOptions = { - logicalPath?: string[]; -} & LogOptions & +export type GraphPackageOptions = LogOptions & + PolicyOption & + PackageDependenciesHookOption & CommonDependencyDescriptorsOptions; /** * Options for `graphPackages()` */ -export type GraphPackagesOptions = LogOptions; +export type GraphPackagesOptions = LogOptions & + PolicyOption & + PackageDependenciesHookOption; /** * Options for `gatherDependency()` @@ -46,8 +70,46 @@ export type GatherDependencyOptions = { */ optional?: boolean; } & LogOptions & + PackageDependenciesHookOption & + PolicyOption & CommonDependencyDescriptorsOptions; +/** + * The type of a `package.json` file containing relevant fields; used by `graphPackages` and its ilk + */ +export interface PackageDescriptor { + /** + * TODO: In reality, this is optional, but `graphPackage` does not consider it + * to be. This will need to be fixed once support for "anonymous" packages + * lands; see https://github.com/endojs/endo/pull/2664 + */ + name: string; + version?: string; + /** + * TODO: Update with proper type when this field is handled. + */ + exports?: unknown; + type?: 'module' | 'commonjs'; + dependencies?: Record; + devDependencies?: Record; + peerDependencies?: Record; + optionalDependencies?: Record; + bundleDependencies?: string[]; + peerDependenciesMeta?: Record< + string, + { optional?: boolean; [k: string]: unknown } + >; + module?: string; + browser?: Record | string; + + main?: string; + + [k: string]: unknown; +} + +/** + * Value in {@link Graph} + */ export interface Node { /** * Informative compartment label based on the package name and version (if available) @@ -57,8 +119,7 @@ export interface Node { * Package name */ name: string; - path: Array; - logicalPath: Array; + location: FileUrlString; /** * `true` if the package's {@link PackageDescriptor} has an `exports` field */ @@ -77,9 +138,21 @@ export interface Node { * * The values are the keys of other {@link Node Nodes} in the {@link Graph}. */ - dependencyLocations: Record; + dependencyLocations: Record; parsers: LanguageForExtension; types: Record; + packageDescriptor: PackageDescriptor; +} + +/** + * A node in the graph that has been finalized, meaning it has a `label` and is + * ready for conversion into a `CompartmentDescriptor`. + */ +export interface FinalNode extends Node { + /** + * Canonical name of the package; used to identify it in policy + */ + label: string; } /** @@ -93,6 +166,16 @@ export interface Node { */ export type Graph = Record', FileUrlString>, Node>; +/** + * A graph, but contains {@link FinalNode}s instead of {@link Node}s. + * + * A "final node" has a `label` prop. + */ +export type FinalGraph = Record< + PackageCompartmentDescriptorName, + Readonly +>; + export interface LanguageOptions { commonjsLanguageForExtension: LanguageForExtension; moduleLanguageForExtension: LanguageForExtension; @@ -114,3 +197,18 @@ export interface PackageDetails { * used by `mapNodeModules()` and its ilk. */ export type LogicalPathGraph = GenericGraph; + +/** + * Options for `translateGraph()` + */ +export type TranslateGraphOptions = LogOptions & + PolicyOption & + PackageDependenciesHookOption; + +/** + * Mapping to enable reverse-lookups of `CompartmentDescriptor`s from policy. + */ +export type CanonicalNameMap = Map< + CanonicalName, + PackageCompartmentDescriptorName +>; diff --git a/packages/compartment-mapper/src/types/policy-schema.ts b/packages/compartment-mapper/src/types/policy-schema.ts index a39faa07bc..6189cfe467 100644 --- a/packages/compartment-mapper/src/types/policy-schema.ts +++ b/packages/compartment-mapper/src/types/policy-schema.ts @@ -5,6 +5,8 @@ * @module */ +import type { WILDCARD_POLICY_VALUE } from '../policy-format.js'; + /* eslint-disable no-use-before-define */ /** @@ -38,7 +40,7 @@ export type UnifiedAttenuationDefinition = { /** * A type representing a wildcard policy, which can be 'any'. */ -export type WildcardPolicy = 'any'; +export type WildcardPolicy = typeof WILDCARD_POLICY_VALUE; /** * A type representing a property policy, which is a record of string keys and @@ -51,7 +53,9 @@ export type PropertyPolicy = Record; * wildcard policy}, a property policy, `undefined`, or defined by an * attenuator */ -export type PolicyItem = WildcardPolicy | PropertyPolicy | T; +export type PolicyItem = [T] extends [void] + ? WildcardPolicy | PropertyPolicy + : WildcardPolicy | PropertyPolicy | T; /** * An object representing a nested attenuation definition. @@ -70,22 +74,36 @@ export type PackagePolicy< BuiltinsPolicyItem = void, ExtraOptions = unknown, > = { - /** The default attenuator. */ + /** + * The default attenuator, if any. + */ defaultAttenuator?: string | undefined; - /** The policy item for packages. */ + /** + * The policy item for packages. + */ packages?: PolicyItem | undefined; - /** The policy item or full attenuation definition for globals. */ + /** + * The policy item or full attenuation definition for globals. + */ globals?: AttenuationDefinition | PolicyItem | undefined; - /** The policy item or nested attenuation definition for builtins. */ + /** + * The policy item or nested attenuation definition for builtins. + */ builtins?: | NestedAttenuationDefinition | PolicyItem | undefined; - /** Whether to disable global freeze. */ + /** + * Whether to disable global freeze. + */ noGlobalFreeze?: boolean | undefined; - /** Whether to allow dynamic imports */ + /** + * Whether to allow dynamic imports + */ dynamic?: boolean | undefined; - /** Any additional user-defined options can be added to the policy here */ + /** + * Any additional user-defined options can be added to the policy here + */ options?: ExtraOptions | undefined; }; @@ -125,9 +143,4 @@ export type Policy< export type SomePolicy = Policy; /** Any {@link PackagePolicy} */ -export type SomePackagePolicy = PackagePolicy< - PolicyItem, - PolicyItem, - PolicyItem, - unknown ->; +export type SomePackagePolicy = PackagePolicy; diff --git a/packages/compartment-mapper/src/types/typescript.ts b/packages/compartment-mapper/src/types/typescript.ts index 1a5c914ce5..871a6b4c31 100644 --- a/packages/compartment-mapper/src/types/typescript.ts +++ b/packages/compartment-mapper/src/types/typescript.ts @@ -35,6 +35,7 @@ export type Primitive = * https://github.com/Microsoft/TypeScript/issues/29729 * Microsoft/TypeScript#29729}. It will be removed as soon as it's not needed * anymore. + * @see {@link https://www.npmjs.com/package/type-fest} */ export type LiteralUnion = | LiteralType @@ -72,4 +73,7 @@ export type UnionToIntersection = ( ? I : never; -// LiteralUnion is from https://www.npmjs.com/package/type-fest +/** + * Makes a nicer tooltip for `T` in IDEs (most of the time). + */ +export type Simplify = { [K in keyof T]: T[K] } & {}; diff --git a/packages/compartment-mapper/test/capture-lite.test.js b/packages/compartment-mapper/test/capture-lite.test.js index a70bd380aa..5393c9a4c1 100644 --- a/packages/compartment-mapper/test/capture-lite.test.js +++ b/packages/compartment-mapper/test/capture-lite.test.js @@ -8,6 +8,11 @@ import { captureFromMap } from '../capture-lite.js'; import { mapNodeModules } from '../src/node-modules.js'; import { makeReadPowers } from '../src/node-powers.js'; import { defaultParserForLanguage } from '../src/import-parsers.js'; +import { ENTRY_COMPARTMENT } from '../src/policy-format.js'; + +/** + * @import {LocalModuleSource} from '../src/types.js' + */ const { keys } = Object; @@ -29,13 +34,13 @@ test('captureFromMap() - should resolve with a CaptureResult', async t => { t.deepEqual( keys(captureSources).sort(), - ['bundle', 'bundle-dep-v0.0.0'], + [ENTRY_COMPARTMENT, 'bundle-dep'], 'captureSources should contain sources for each compartment map descriptor', ); t.deepEqual( keys(compartmentRenames).sort(), - ['bundle', 'bundle-dep-v0.0.0'], + [ENTRY_COMPARTMENT, 'bundle-dep'], 'compartmentRenames must contain same compartment names as in captureCompartmentMap', ); @@ -48,7 +53,7 @@ test('captureFromMap() - should resolve with a CaptureResult', async t => { t.deepEqual( captureCompartmentMap.entry, { - compartment: 'bundle', + compartment: ENTRY_COMPARTMENT, module: './main.js', }, 'The entry compartment should point to the "bundle" compartment map', @@ -56,12 +61,12 @@ test('captureFromMap() - should resolve with a CaptureResult', async t => { t.deepEqual( keys(captureCompartmentMap.compartments).sort(), - ['bundle', 'bundle-dep-v0.0.0'], - 'The "bundle" and "bundle-dep-v0.0.0" compartments should be present', + [ENTRY_COMPARTMENT, 'bundle-dep'], + 'The "bundle" and "bundle-dep" compartments should be present', ); }); -test('captureFromMap() - should discard unretained CompartmentDescriptors', async t => { +test('captureFromMap() - should preload with canonical name', async t => { const readPowers = makeReadPowers({ fs, url }); const moduleLocation = `${new URL( 'fixtures-digest/node_modules/app/index.js', @@ -70,32 +75,30 @@ test('captureFromMap() - should discard unretained CompartmentDescriptors', asyn const nodeCompartmentMap = await mapNodeModules(readPowers, moduleLocation); - const nodeComartmentMapSize = keys(nodeCompartmentMap.compartments).length; + const fjordCompartment = Object.values(nodeCompartmentMap.compartments).find( + c => c.name === 'fjord', + ); + if (!fjordCompartment) { + t.fail('Expected "fjord" compartment to be present in nodeCompartmentMap'); + return; + } const { captureCompartmentMap } = await captureFromMap( readPowers, nodeCompartmentMap, { + _preload: [fjordCompartment.location], parserForLanguage: defaultParserForLanguage, }, ); - const captureCompartmentMapSize = keys( - captureCompartmentMap.compartments, - ).length; - t.true( - captureCompartmentMapSize < nodeComartmentMapSize, - 'captureCompartmentMap should contain fewer CompartmentDescriptors than nodeCompartmentMap', - ); - - t.false( - 'fjord-v1.0.0' in captureCompartmentMap.compartments, - '"fjord-v1.0.0" should not be retained in captureCompartmentMap', + 'fjord' in captureCompartmentMap.compartments, + '"fjord" should be retained in captureCompartmentMap', ); }); -test('captureFromMap() - should preload default entry', async t => { +test('captureFromMap() - should discard unretained CompartmentDescriptors', async t => { const readPowers = makeReadPowers({ fs, url }); const moduleLocation = `${new URL( 'fixtures-digest/node_modules/app/index.js', @@ -104,6 +107,8 @@ test('captureFromMap() - should preload default entry', async t => { const nodeCompartmentMap = await mapNodeModules(readPowers, moduleLocation); + const nodeComartmentMapSize = keys(nodeCompartmentMap.compartments).length; + const fjordCompartment = Object.values(nodeCompartmentMap.compartments).find( c => c.name === 'fjord', ); @@ -116,14 +121,22 @@ test('captureFromMap() - should preload default entry', async t => { readPowers, nodeCompartmentMap, { - _preload: [fjordCompartment.location], parserForLanguage: defaultParserForLanguage, }, ); + const captureCompartmentMapSize = keys( + captureCompartmentMap.compartments, + ).length; + t.true( - 'fjord-v1.0.0' in captureCompartmentMap.compartments, - '"fjord-v1.0.0" should be retained in captureCompartmentMap', + captureCompartmentMapSize < nodeComartmentMapSize, + 'captureCompartmentMap should contain fewer CompartmentDescriptors than nodeCompartmentMap', + ); + + t.false( + 'fjord' in captureCompartmentMap.compartments, + '"fjord" should not be retained in captureCompartmentMap', ); }); @@ -159,13 +172,13 @@ test('captureFromMap() - should preload custom entry', async t => { ); t.true( - 'fjord-v1.0.0' in captureCompartmentMap.compartments, - '"fjord-v1.0.0" should be retained in captureCompartmentMap', + 'fjord' in captureCompartmentMap.compartments, + '"fjord" should be retained in captureCompartmentMap', ); + const fjordCompartmentDescriptor = captureCompartmentMap.compartments.fjord; t.true( - './some-other-entry.js' in - captureCompartmentMap.compartments['fjord-v1.0.0'].modules, - 'The custom entry should be in the modules object of fjord-v1.0.0', + './some-other-entry.js' in fjordCompartmentDescriptor.modules, + 'The custom entry should be in the modules object of fjord', ); }); @@ -188,10 +201,16 @@ test('captureFromMap() - should round-trip sources based on parsers', async t => ); const decoder = new TextDecoder(); + const bundleSource = /** @type {LocalModuleSource} */ ( + captureSources[ENTRY_COMPARTMENT]['./icando.cjs'] + ); // the actual source depends on the value of `parserForLanguage` above - const actual = decoder.decode(captureSources.bundle['./icando.cjs'].bytes); + const actual = decoder.decode(bundleSource.bytes); const expected = await fs.promises.readFile( - path.join(url.fileURLToPath(compartmentRenames.bundle), 'icando.cjs'), + path.join( + url.fileURLToPath(compartmentRenames[ENTRY_COMPARTMENT]), + 'icando.cjs', + ), 'utf-8', ); t.is(actual, expected, 'Source code should not be pre-compiled'); diff --git a/packages/compartment-mapper/test/dynamic-require.test.js b/packages/compartment-mapper/test/dynamic-require.test.js index fa35df70c1..7a78a89de9 100644 --- a/packages/compartment-mapper/test/dynamic-require.test.js +++ b/packages/compartment-mapper/test/dynamic-require.test.js @@ -19,7 +19,10 @@ import path from 'node:path'; import url from 'node:url'; import { importLocation } from '../src/import.js'; import { makeReadNowPowers } from '../src/node-powers.js'; -import { WILDCARD_POLICY_VALUE } from '../src/policy-format.js'; +import { + ENTRY_COMPARTMENT, + WILDCARD_POLICY_VALUE, +} from '../src/policy-format.js'; const readPowers = makeReadNowPowers({ fs, url, path }); const { freeze, keys, assign } = Object; @@ -52,7 +55,7 @@ const defaultImportHook = async (specifier, packageLocation) => { return defaultImportNowHook(specifier, packageLocation); }; -test('intra-package dynamic require works without invoking the exitModuleImportNowHook', async t => { +test('dynamic require avoids exitModuleImportNowHook', async t => { t.plan(2); const fixture = new URL( 'fixtures-dynamic/node_modules/app/index.js', @@ -119,7 +122,7 @@ test('intra-package dynamic require works without invoking the exitModuleImportN // figures out what file to require within that directory. there is no // reciprocal dependency on wherever that directory lives (usually it's // somewhere in the dependent package) -test('intra-package dynamic require with inter-package absolute path works without invoking the exitModuleImportNowHook', async t => { +test('dynamic require using absolute path avoids exitModuleImportNowHook', async t => { t.plan(2); const fixture = new URL( 'fixtures-dynamic/node_modules/absolute-app/index.js', @@ -144,7 +147,12 @@ test('intra-package dynamic require with inter-package absolute path works witho }), ); }; - /** @type {Policy} */ + + /** + * This policy allows bidirectional access between `sprunt` and + * `sprunt>node-tammy-build` + * @type {Policy} + */ const policy = { entry: { packages: WILDCARD_POLICY_VALUE, @@ -152,9 +160,14 @@ test('intra-package dynamic require with inter-package absolute path works witho builtins: WILDCARD_POLICY_VALUE, }, resources: { + 'sprunt>node-tammy-build': { + packages: { + sprunt: true, + }, + }, sprunt: { packages: { - 'node-tammy-build': true, + 'sprunt>node-tammy-build': true, }, }, }, @@ -177,7 +190,7 @@ test('intra-package dynamic require with inter-package absolute path works witho t.is(importNowHookCallCount, 0); }); -test('intra-package dynamic require using known-but-restricted absolute path fails', async t => { +test('dynamic require of disallowed package', async t => { const fixture = new URL( 'fixtures-dynamic/node_modules/broken-app/index.js', import.meta.url, @@ -209,11 +222,11 @@ test('intra-package dynamic require using known-but-restricted absolute path fai resources: { badsprunt: { packages: { - 'node-tammy-build': true, + 'badsprunt>node-tammy-build': true, }, }, 'badsprunt>node-tammy-build': { - packages: { sprunt: false }, + packages: { 'badsprunt>sprunt': false }, }, }, }; @@ -224,7 +237,7 @@ test('intra-package dynamic require using known-but-restricted absolute path fai importNowHook, }), { - message: /Blocked in importNow hook by relationship/, + message: /Blocked in importNow hook by package policy/, }, ); }); @@ -249,7 +262,7 @@ test('dynamic require fails without maybeReadNow in read powers', async t => { resources: { dynamic: { packages: { - 'is-ok': true, + 'dynamic>is-ok': true, }, }, }, @@ -281,7 +294,7 @@ test('dynamic require fails without isAbsolute & fileURLToPath in read powers', resources: { dynamic: { packages: { - 'is-ok': true, + 'dynamic>is-ok': true, }, }, }, @@ -338,7 +351,7 @@ test('inter-package and exit module dynamic require works', async t => { resources: { hooked: { packages: { - dynamic: true, + 'hooked>dynamic': true, }, }, 'hooked>dynamic': { @@ -412,7 +425,7 @@ test('inter-package and exit module dynamic require policy is enforced', async t resources: { hooked: { packages: { - dynamic: true, + 'hooked>dynamic': true, }, }, 'hooked>dynamic': { @@ -479,7 +492,7 @@ test('inter-package and exit module dynamic require works ("node:"-namespaced)', resources: { hooked: { packages: { - dynamic: true, + 'hooked>dynamic': true, }, }, 'hooked>dynamic': { @@ -612,7 +625,7 @@ test('dynamic require of missing module falls through to importNowHook', async t await t.throwsAsync( importLocation(readPowers, fixture, { - policy: { entry: { builtins: 'any' }, resources: {} }, + policy: { entry: { builtins: WILDCARD_POLICY_VALUE }, resources: {} }, importNowHook: (specifier, _packageLocation) => { throw new Error(`Blocked exit module: ${specifier}`); }, @@ -635,7 +648,7 @@ test('dynamic require of package missing an optional module', async t => { t.like(namespace, { isOk: true, default: { isOk: true } }); }); -test('dynamic require of ancestor relative path within known compartment should succeed', async t => { +test('dynamic require of ancestor relative path', async t => { const fixture = new URL( 'fixtures-dynamic/node_modules/grabby-app/index.js', import.meta.url, @@ -648,6 +661,20 @@ test('dynamic require of ancestor relative path within known compartment should const { namespace } = await importLocation(readPowers, fixture, { importNowHook, + policy: { + entry: { + packages: WILDCARD_POLICY_VALUE, + globals: WILDCARD_POLICY_VALUE, + builtins: WILDCARD_POLICY_VALUE, + }, + resources: { + grabby: { + packages: { + [ENTRY_COMPARTMENT]: true, + }, + }, + }, + }, }); t.like(namespace, { value: 'buried treasure' }); }); @@ -689,7 +716,10 @@ test('dynamic require of ancestor', async t => { }, packages: { 'pantspack>pantspack-folder-runner': true, - 'webpackish-app': true, + [ENTRY_COMPARTMENT]: true, + }, + globals: { + 'process.exitCode': true, }, }, 'pantspack>pantspack-folder-runner': { @@ -697,6 +727,11 @@ test('dynamic require of ancestor', async t => { 'jorts-folder': true, }, }, + 'jorts-folder': { + packages: { + [ENTRY_COMPARTMENT]: true, + }, + }, }, }, }); @@ -716,6 +751,7 @@ test('dynamic require of ancestor disallowed by policy fails at require time', a import.meta.url, ).href; + /** @type {Policy} */ const policy = { entry: { packages: WILDCARD_POLICY_VALUE, @@ -731,7 +767,10 @@ test('dynamic require of ancestor disallowed by policy fails at require time', a }, packages: { 'pantspack>pantspack-folder-runner': true, - 'webpackish-app': true, + [ENTRY_COMPARTMENT]: true, + }, + globals: { + 'process.exitCode': true, }, }, 'pantspack>pantspack-folder-runner': { @@ -756,7 +795,7 @@ test('dynamic require of ancestor disallowed by policy fails at require time', a t.regex( err.cause.message, new RegExp( - `Importing "jorts-folder" in resource "jorts-folder" in "pantspack-folder-runner-v1\\.0\\.0" was not allowed by "packages" policy: ${JSON.stringify(policy.resources['pantspack>pantspack-folder-runner'].packages)}`, + `Importing "jorts-folder" in resource "jorts-folder" in "pantspack>pantspack-folder-runner" was not allowed by "packages" policy: ${JSON.stringify(policy.resources['pantspack>pantspack-folder-runner'].packages)}`, ), ); } @@ -769,16 +808,14 @@ test('dynamic require of ancestor disallowed if policy omitted', async t => { import.meta.url, ).href; - try { - // eslint-disable-next-line @jessie.js/safe-await-separator - await importLocation(readPowers, fixture, { + await t.throwsAsync( + importLocation(readPowers, fixture, { dev: true, importNowHook: defaultImportNowHook, importHook: defaultImportHook, - }); - t.fail('importLocation should have rejected'); - } catch (err) { - t.regex(err.message, /Could not require pantsFolder "jorts-folder"/); - t.regex(err.cause.message, /Could not import module/); - } + }), + { + message: /Could not import module.+webpackish-app\/pantspack\.config\.js/, + }, + ); }); diff --git a/packages/compartment-mapper/test/exit-hook.test.js b/packages/compartment-mapper/test/exit-hook.test.js index d8b26f7e4b..d3a505c1d9 100644 --- a/packages/compartment-mapper/test/exit-hook.test.js +++ b/packages/compartment-mapper/test/exit-hook.test.js @@ -3,6 +3,9 @@ import 'ses'; import test from 'ava'; import { scaffold } from './scaffold.js'; +/** + * @import {AssertionLocationFixtureNamespace} from './test.types.js' + */ const fixture = new URL( 'fixtures-policy/node_modules/app/importActualBuiltin.js', import.meta.url, diff --git a/packages/compartment-mapper/test/fixtures-dynamic-ancestor/README.md b/packages/compartment-mapper/test/fixtures-dynamic-ancestor/README.md index 3a47b7b554..14e7c15d4e 100644 --- a/packages/compartment-mapper/test/fixtures-dynamic-ancestor/README.md +++ b/packages/compartment-mapper/test/fixtures-dynamic-ancestor/README.md @@ -10,7 +10,7 @@ sequenceDiagram participant pantspack participant pantspack-folder-runner participant jorts-folder - + activate app app->>+pantspack: run pantspack deactivate app @@ -28,9 +28,9 @@ sequenceDiagram ``` -The main "app" is `webpackish-app`, which contains a [`pantspack.config.js`](./node_modules/webpackish-app/pantspack.config.js) file. This file specifies the _name_ of a "folder", which is `jorts-folder`. `jorts-folder` _is not_ loaded directly by `pantspack.config.js`. +The main "app" is `webpackish-app`, which contains a [`pantspack.config.js`](./node_modules/webpackish-app/pantspack.config.js) file. This file specifies the _name_ of a "folder", which is `jorts-folder`. `jorts-folder` _is not_ loaded directly by `pantspack.config.js`. -`webpackish-app` uses `pantspack` to "build" itself. Its `build` script: +`webpackish-app` uses `pantspack` to "build" itself. Its `build` script: ```sh node ../pantspack/pantspack.js --config pantspack.config.js diff --git a/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack-folder-runner/index.js b/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack-folder-runner/index.js index ef013d703c..1c64eba142 100644 --- a/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack-folder-runner/index.js +++ b/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack-folder-runner/index.js @@ -78,7 +78,7 @@ const foldPantsFolders = (pantsHeap, pantsFolders = []) => return fold(source, packageDescriptor); } catch (err) { throw Error( - `Error folding source "${source}" for package "${packageDescriptor.name}"`, + `Error folding source "${source}" for package "${packageDescriptor.name}": ${err.message}`, { cause: err }, ); } diff --git a/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack/pantspack.js b/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack/pantspack.js index 3d7c2a1cd5..751961d13f 100644 --- a/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack/pantspack.js +++ b/packages/compartment-mapper/test/fixtures-dynamic-ancestor/node_modules/pantspack/pantspack.js @@ -60,10 +60,8 @@ const main = () => { try { configResolved = require.resolve(configSpecifier); } catch (err) { - console.error(err); console.error(`Could not resolve config file: ${configSpecifier}`); - process.exitCode = 1; - return; + throw err; } /** @type {{entry: string, folders: string[]}} */ @@ -71,20 +69,14 @@ const main = () => { try { config = require(configResolved); } catch (err) { - console.error(err); console.error(`Error loading config file: ${configResolved}`); - process.exitCode = 1; - return; + throw err; } const { folders } = config; if (!folders.length) { - console.error( - `No folders specified in config file: ${configResolved}. Please specify at least one folder.`, - ); - process.exitCode = 1; - return; + throw new Error(`No folders specified in config file: ${configResolved}. Please specify at least one folder.`); } // find package descriptor for config diff --git a/packages/compartment-mapper/test/fixtures-implicit-reexport/package.json b/packages/compartment-mapper/test/fixtures-implicit-reexport/package.json index e145afcc81..4a9d6fb8c2 100644 --- a/packages/compartment-mapper/test/fixtures-implicit-reexport/package.json +++ b/packages/compartment-mapper/test/fixtures-implicit-reexport/package.json @@ -6,4 +6,3 @@ "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" } } - diff --git a/packages/compartment-mapper/test/fixtures-optional-peer-dependencies/README.md b/packages/compartment-mapper/test/fixtures-optional-peer-dependencies/README.md index 6eab58e82a..b44575ab5c 100644 --- a/packages/compartment-mapper/test/fixtures-optional-peer-dependencies/README.md +++ b/packages/compartment-mapper/test/fixtures-optional-peer-dependencies/README.md @@ -1,9 +1,9 @@ This fixture is for testing the behavior of `mapNodeModules()` when a package has no declared `peerDependencies` _but_ has a `peerDependenciesMeta` field listing an _optional_ dependency. -The _intent_ of this configuration is to declare _optional_ peer dependencies. +The _intent_ of this configuration is to declare _optional_ peer dependencies. `npm@7+` requires all dependencies mentioned in `peerDependenciesMeta` must to be declared in `peerDependencies`. This enables automatic installation of the peer dependencies (which is then allowed to fail). -Prior to `npm@7`, there was no way to declare a peer dependency as optional, leading packages to _omit_ optional `peerDependencies` from `package.json`. +Prior to `npm@7`, there was no way to declare a peer dependency as optional, leading packages to _omit_ optional `peerDependencies` from `package.json`. I do not know if `yarn` or `pnpm` (any version) do anything with a lone `peerDependenciesMeta` field. Assuming they don't, `peerDependenciesMeta` w/o a `peerDependencies` is little more than a _hint_ to a human reader. diff --git a/packages/compartment-mapper/test/guards.test.js b/packages/compartment-mapper/test/guards.test.js new file mode 100644 index 0000000000..d7419cfe4d --- /dev/null +++ b/packages/compartment-mapper/test/guards.test.js @@ -0,0 +1,349 @@ +import test from 'ava'; +import { + isErrorModuleConfiguration, + isFileModuleConfiguration, + isExitModuleConfiguration, + isCompartmentModuleConfiguration, + isErrorModuleSource, + isExitModuleSource, + isLocalModuleSource, + isNonNullableObject, +} from '../src/guards.js'; + +test('guard - isErrorModuleConfiguration() - returns true for object with deferredError', t => { + t.true(isErrorModuleConfiguration({ deferredError: 'some error' })); +}); + +test('guard - isErrorModuleConfiguration() - returns false for object without deferredError', t => { + t.false(isErrorModuleConfiguration({ parser: 'mjs' })); +}); + +test('guard - isErrorModuleConfiguration() - returns false when deferredError is undefined', t => { + // @ts-expect-error intentionally invalid input + t.false(isErrorModuleConfiguration({ deferredError: undefined })); +}); + +test('guard - isErrorModuleConfiguration() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isErrorModuleConfiguration({})); +}); + +test('guard - isFileModuleConfiguration() - returns true for object with parser', t => { + t.true(isFileModuleConfiguration({ parser: 'mjs' })); +}); + +test('guard - isFileModuleConfiguration() - returns true for object with parser and location', t => { + t.true( + isFileModuleConfiguration({ + parser: 'cjs', + location: './module.js', + }), + ); +}); + +test('guard - isFileModuleConfiguration() - returns false for object without parser', t => { + t.false(isFileModuleConfiguration({ exit: 'node:fs' })); +}); + +test('guard - isFileModuleConfiguration() - returns false when parser is undefined', t => { + // @ts-expect-error intentionally invalid input + t.false(isFileModuleConfiguration({ parser: undefined })); +}); + +test('guard - isFileModuleConfiguration() - returns false when deferredError is present', t => { + t.false( + isFileModuleConfiguration({ + parser: 'mjs', + deferredError: 'some error', + }), + ); +}); + +test('guard - isFileModuleConfiguration() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isFileModuleConfiguration({})); +}); + +test('guard - isExitModuleConfiguration() - returns true for object with exit', t => { + t.true(isExitModuleConfiguration({ exit: 'node:fs' })); +}); + +test('guard - isExitModuleConfiguration() - returns false for object without exit', t => { + t.false(isExitModuleConfiguration({ parser: 'mjs' })); +}); + +test('guard - isExitModuleConfiguration() - returns false when exit is undefined', t => { + // @ts-expect-error intentionally invalid input + t.false(isExitModuleConfiguration({ exit: undefined })); +}); + +test('guard - isExitModuleConfiguration() - returns false when deferredError is present', t => { + t.false( + isExitModuleConfiguration({ + exit: 'node:fs', + deferredError: 'some error', + }), + ); +}); + +test('guard - isExitModuleConfiguration() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isExitModuleConfiguration({})); +}); + +test('guard - isCompartmentModuleConfiguration() - returns true for object with compartment and module', t => { + t.true( + isCompartmentModuleConfiguration({ + compartment: 'file:///some/compartment', + module: 'index.js', + }), + ); +}); + +test('guard - isCompartmentModuleConfiguration() - returns false for object with only compartment', t => { + t.false( + // @ts-expect-error intentionally invalid input + isCompartmentModuleConfiguration({ + compartment: 'file:///some/compartment', + }), + ); +}); + +test('guard - isCompartmentModuleConfiguration() - returns false for object with only module', t => { + // @ts-expect-error intentionally invalid input + t.false(isCompartmentModuleConfiguration({ module: 'index.js' })); +}); + +test('guard - isCompartmentModuleConfiguration() - returns false when compartment is undefined', t => { + t.false( + isCompartmentModuleConfiguration({ + // @ts-expect-error intentionally invalid input + compartment: undefined, + module: 'index.js', + }), + ); +}); + +test('guard - isCompartmentModuleConfiguration() - returns false when module is undefined', t => { + t.false( + isCompartmentModuleConfiguration({ + compartment: 'file:///some/compartment', + // @ts-expect-error intentionally invalid input + module: undefined, + }), + ); +}); + +test('guard - isCompartmentModuleConfiguration() - returns false when deferredError is present', t => { + t.false( + isCompartmentModuleConfiguration({ + compartment: 'file:///some/compartment', + module: 'index.js', + deferredError: 'some error', + }), + ); +}); + +test('guard - isCompartmentModuleConfiguration() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isCompartmentModuleConfiguration({})); +}); + +test('guard - isErrorModuleSource() - returns true for object with deferredError', t => { + t.true(isErrorModuleSource({ deferredError: 'some error' })); +}); + +test('guard - isErrorModuleSource() - returns false for object without deferredError', t => { + t.false(isErrorModuleSource({ exit: 'node:fs' })); +}); + +test('guard - isErrorModuleSource() - returns false when deferredError is undefined', t => { + // @ts-expect-error intentionally invalid input + t.false(isErrorModuleSource({ deferredError: undefined })); +}); + +test('guard - isErrorModuleSource() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isErrorModuleSource({})); +}); + +test('guard - isExitModuleSource() - returns true for object with exit', t => { + t.true(isExitModuleSource({ exit: 'node:fs' })); +}); + +test('guard - isExitModuleSource() - returns false for object without exit', t => { + t.false( + // @ts-expect-error intentionally invalid input + isExitModuleSource({ + bytes: new Uint8Array(), + parser: 'mjs', + sourceDirname: '/src', + location: './module.js', + }), + ); +}); + +test('guard - isExitModuleSource() - returns false when exit is undefined', t => { + // @ts-expect-error intentionally invalid input + t.false(isExitModuleSource({ exit: undefined })); +}); + +test('guard - isExitModuleSource() - returns false when deferredError is present', t => { + t.false( + isExitModuleSource({ + exit: 'node:fs', + deferredError: 'some error', + }), + ); +}); + +test('guard - isExitModuleSource() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isExitModuleSource({})); +}); + +test('guard - isLocalModuleSource() - returns true for object with all required properties', t => { + t.true( + // @ts-expect-error intentionally incomplete input (missing sourceLocation, record) + isLocalModuleSource({ + bytes: new Uint8Array([0x01, 0x02]), + parser: 'mjs', + sourceDirname: '/src', + location: './module.js', + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false when bytes is missing', t => { + t.false( + // @ts-expect-error intentionally invalid input + isLocalModuleSource({ + parser: 'mjs', + sourceDirname: '/src', + location: './module.js', + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false when parser is missing', t => { + t.false( + // @ts-expect-error intentionally invalid input + isLocalModuleSource({ + bytes: new Uint8Array(), + sourceDirname: '/src', + location: './module.js', + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false when sourceDirname is missing', t => { + t.false( + // @ts-expect-error intentionally invalid input + isLocalModuleSource({ + bytes: new Uint8Array(), + parser: 'mjs', + location: './module.js', + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false when location is missing', t => { + t.false( + // @ts-expect-error intentionally invalid input + isLocalModuleSource({ + bytes: new Uint8Array(), + parser: 'mjs', + sourceDirname: '/src', + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false when any required property is undefined', t => { + t.plan(4); + t.false( + isLocalModuleSource({ + // @ts-expect-error intentionally invalid input + bytes: undefined, + parser: 'mjs', + sourceDirname: '/src', + location: './module.js', + }), + ); + t.false( + isLocalModuleSource({ + bytes: new Uint8Array(), + // @ts-expect-error intentionally invalid input + parser: undefined, + sourceDirname: '/src', + location: './module.js', + }), + ); + t.false( + isLocalModuleSource({ + bytes: new Uint8Array(), + parser: 'mjs', + // @ts-expect-error intentionally invalid input + sourceDirname: undefined, + location: './module.js', + }), + ); + t.false( + isLocalModuleSource({ + bytes: new Uint8Array(), + parser: 'mjs', + sourceDirname: '/src', + // @ts-expect-error intentionally invalid input + location: undefined, + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false when deferredError is present', t => { + t.false( + isLocalModuleSource({ + bytes: new Uint8Array(), + parser: 'mjs', + sourceDirname: '/src', + location: './module.js', + deferredError: 'some error', + }), + ); +}); + +test('guard - isLocalModuleSource() - returns false for empty object', t => { + // @ts-expect-error intentionally invalid input + t.false(isLocalModuleSource({})); +}); + +test('guard - isNonNullableObject() - returns true for plain object', t => { + t.true(isNonNullableObject({})); +}); + +test('guard - isNonNullableObject() - returns true for object with properties', t => { + t.true(isNonNullableObject({ foo: 'bar' })); +}); + +test('guard - isNonNullableObject() - returns true for array', t => { + t.true(isNonNullableObject([])); +}); + +test('guard - isNonNullableObject() - returns true for Date', t => { + t.true(isNonNullableObject(new Date())); +}); + +test('guard - isNonNullableObject() - returns false for null', t => { + t.false(isNonNullableObject(null)); +}); + +test('guard - isNonNullableObject() - returns false for undefined', t => { + t.false(isNonNullableObject(undefined)); +}); + +test('guard - isNonNullableObject() - returns false for primitive values', t => { + t.plan(5); + t.false(isNonNullableObject('string')); + t.false(isNonNullableObject(42)); + t.false(isNonNullableObject(true)); + t.false(isNonNullableObject(Symbol('test'))); + t.false(isNonNullableObject(BigInt(123))); +}); diff --git a/packages/compartment-mapper/test/integrity.test.js b/packages/compartment-mapper/test/integrity.test.js index a0b6c69226..15c4013337 100644 --- a/packages/compartment-mapper/test/integrity.test.js +++ b/packages/compartment-mapper/test/integrity.test.js @@ -100,7 +100,8 @@ test('extracting an archive with an inconsistent hash', async t => { }, }), { - message: `Failed to load module "./main.js" in package "app-v1.0.0" (1 underlying failures: Module "main.js" of package "app-v1.0.0" in archive "corrupt.zip" failed a SHA-512 integrity check`, + message: + 'Failed to load module "./main.js" in package "app-v1.0.0" (1 underlying failures: Module "main.js" of package "app-v1.0.0" in archive "corrupt.zip" failed a SHA-512 integrity check', }, ); diff --git a/packages/compartment-mapper/test/make-import-hook-maker.test.js b/packages/compartment-mapper/test/make-import-hook-maker.test.js new file mode 100644 index 0000000000..8503f9e435 --- /dev/null +++ b/packages/compartment-mapper/test/make-import-hook-maker.test.js @@ -0,0 +1,156 @@ +/* eslint-disable no-shadow */ +import './ses-lockdown.js'; +import test from 'ava'; +import { mapNodeModules } from '../src/node-modules.js'; +import { makeProjectFixtureReadPowers } from './project-fixture.js'; +import { defaultParserForLanguage } from '../src/import-parsers.js'; +import { captureFromMap } from '../capture-lite.js'; +import { loadFromMap } from '../import-lite.js'; +import { loadLocation } from '../import.js'; + +/** + * @import { + * CanonicalName, + * MaybeReadPowers, + * ModuleSourceHook, + * } from '../src/types.js'; + * @import {ExecutionContext} from 'ava'; + * @import {ProjectFixture} from './test.types.js'; + */ + +/** + * Test fixture with a simple entry module + * @type {ProjectFixture} + */ +const testFixture = { + root: 'test-app', + graph: { + 'test-app': [], + }, + entrypoint: 'file:///node_modules/test-app/index.js', +}; + +/** + * AVA macro for testing moduleSource hook with different loader functions + * + * This could be more narrowly-typed, but it's a test. + * + * @param {ExecutionContext} t + * @param {object} options + * @param {Function} options.loaderFn - The function to call (captureFromMap, loadFromMap, or loadLocation) + * @param {(readPowers: MaybeReadPowers, entrypoint: string, options: object) => any} options.argsFn - Function that returns arguments for the loader function + * @param {boolean} options.callImport - Whether to call application.import() after loading + */ +const moduleSourceHookTest = async ( + t, + { loaderFn, argsFn, callImport = false }, +) => { + t.plan(7); // log, moduleSource, canonicalName type, hook called, canonicalName content, moduleSource type, moduleSource truthy + + const readPowers = makeProjectFixtureReadPowers(testFixture); + + let hookCalled = false; + /** @type {object | undefined} */ + let receivedModuleSource; + /** @type {CanonicalName | undefined} */ + let receivedCanonicalName; + + /** @type {ModuleSourceHook} */ + const moduleSourceHook = ({ moduleSource, canonicalName, log }) => { + hookCalled = true; + receivedModuleSource = moduleSource; + receivedCanonicalName = canonicalName; + + t.truthy(log, 'log function should be provided'); + t.truthy(moduleSource, 'moduleSource should be provided'); + t.is(typeof canonicalName, 'string', 'canonicalName should be a string'); + }; + + const options = { + moduleSourceHook, + parserForLanguage: defaultParserForLanguage, + }; + + const args = await argsFn( + readPowers, + /** @type {string} */ (testFixture.entrypoint), + options, + ); + + const result = await loaderFn(...args); + + if ( + callImport && + typeof result === 'object' && + !!result && + 'import' in result && + result.import && + typeof result.import === 'function' + ) { + await result.import(); + } + + // The hook should have been called during capture/loading + t.true(hookCalled, 'moduleSource hook should have been called'); + + t.is( + receivedCanonicalName, + '$root$', + 'canonicalName should be the root compartment identifier', + ); + + t.truthy(receivedModuleSource, 'moduleSource should be truthy'); + t.snapshot(receivedModuleSource); +}; + +/** + * Computes title for macro + * + * @param {string} providedTitle + * @param {{loaderFn: Function}} options + * @returns {string} + */ +moduleSourceHookTest.title = (providedTitle, { loaderFn }) => + `${providedTitle || ''} ${loaderFn.name}`.trim(); + +// #region tests +test( + 'captureFromMap - moduleSourceHook - receives correct parameters (snapshot)', + moduleSourceHookTest, + { + loaderFn: captureFromMap, + argsFn: async (readPowers, entrypoint, options) => { + const compartmentMap = await mapNodeModules(readPowers, entrypoint); + return [readPowers, compartmentMap, options]; + }, + callImport: false, + }, +); + +test( + 'loadFromMap - moduleSourceHook - receives correct parameters (snapshot)', + moduleSourceHookTest, + { + loaderFn: loadFromMap, + argsFn: async (readPowers, entrypoint, options) => { + const compartmentMap = await mapNodeModules(readPowers, entrypoint); + return [readPowers, compartmentMap, options]; + }, + callImport: true, + }, +); + +test( + 'loadLocation - moduleSourceHook - receives correct parameters (snapshot)', + moduleSourceHookTest, + { + loaderFn: loadLocation, + argsFn: (readPowers, entrypoint, options) => [ + readPowers, + entrypoint, + options, + ], + callImport: true, + }, +); +// #endregion diff --git a/packages/compartment-mapper/test/map-node-modules.test.js b/packages/compartment-mapper/test/map-node-modules.test.js index 4266f0e76b..a0faa13685 100644 --- a/packages/compartment-mapper/test/map-node-modules.test.js +++ b/packages/compartment-mapper/test/map-node-modules.test.js @@ -1,8 +1,10 @@ +/* eslint-disable no-shadow */ import 'ses'; import fs from 'node:fs'; import url from 'node:url'; import test from 'ava'; +import path from 'node:path'; import { mapNodeModules } from '../src/node-modules.js'; import { makeReadPowers } from '../src/node-powers.js'; import { @@ -10,41 +12,71 @@ import { dumpProjectFixture, makeProjectFixtureReadPowers, } from './project-fixture.js'; +import { relativizeCompartmentMap } from './snapshot-utilities.js'; +import { + ATTENUATORS_COMPARTMENT, + WILDCARD_POLICY_VALUE, +} from '../src/policy-format.js'; + +const dirname = url.fileURLToPath(new URL('.', import.meta.url)); /** - * @import {ProjectFixture, ProjectFixtureGraph} from './test.types.js' + * @import {ProjectFixture} from './test.types.js' + * @import {FileUrlString, MapNodeModulesOptions, MaybeReadPowers, PackageCompartmentMapDescriptor, CanonicalName, SomePolicy, PackageDescriptor, UnknownCanonicalNameHook, PackageDependenciesHook, LogFn} from '../src/types.js' */ const { keys, values } = Object; -const CORRECT_SHORTEST_PATH = ['paperino', 'topolino', 'goofy']; +/** + * The expected canonical name of `goofy` in the tests using {@link phonyFixture} + */ +const CORRECT_CANONICAL_NAME = 'paperino>topolino>goofy'; + +/** + * `ReadPowers` for on-disk fixtures + */ +const readPowers = makeReadPowers({ fs, url }); + +/** + * In-memory project fixture + * @see {@link makeProjectFixtureReadPowers} + * @satisfies {ProjectFixture} + */ +const phonyFixture = /** @type {const} */ ({ + root: 'app', + graph: { + app: ['pippo', 'paperino'], + paperino: ['topolino'], + pippo: ['gambadilegno'], + gambadilegno: ['topolino'], + topolino: ['goofy'], + }, + entrypoint: 'file:///node_modules/app/index.js', +}); test(`mapNodeModules() should return compartment descriptors containing shortest path`, async t => { - const readPowers = makeReadPowers({ fs, url }); const shortestPathFixture = new URL( 'fixtures-shortest-path/node_modules/app/index.js', import.meta.url, ).href; - const targetLabel = 'goofy-v1.0.0'; - const compartmentMap = await mapNodeModules(readPowers, shortestPathFixture); const compartmentDescriptor = values(compartmentMap.compartments).find( - compartment => compartment.label === targetLabel, + compartment => compartment.label === CORRECT_CANONICAL_NAME, ); // not using AVA's assertions here because assertion types are not assert-style type guards (they just return `void`) which prevents the need for type assertions on `compartmentDescriptor` after if (!compartmentDescriptor) { t.fail( - `compartment descriptor for '${targetLabel}' should exist, but it does not`, + `compartment descriptor for '${CORRECT_CANONICAL_NAME}' should exist, but it does not`, ); return; } - t.deepEqual( - compartmentDescriptor.path, - CORRECT_SHORTEST_PATH, - `compartment descriptor should have shortest path: ${CORRECT_SHORTEST_PATH.join('>')}`, + t.is( + compartmentDescriptor.label, + CORRECT_CANONICAL_NAME, + `compartment descriptor should have canonical name: ${CORRECT_CANONICAL_NAME}`, ); }); @@ -52,7 +84,6 @@ test.serial( 'mapNodeModules() should consider peerDependenciesMeta without corresponding peerDependencies when the dependency is present', async t => { t.plan(2); - const readPowers = makeReadPowers({ fs, url }); const moduleLocation = new URL( 'fixtures-optional-peer-dependencies/node_modules/app/index.js', import.meta.url, @@ -74,7 +105,6 @@ test('mapNodeModules() should not consider peerDependenciesMeta without correspo 'fixtures-missing-optional-peer-dependencies/node_modules/app/index.js', import.meta.url, ).href; - const readPowers = makeReadPowers({ fs, url }); const compartmentMap = await mapNodeModules(readPowers, moduleLocation); t.is(keys(compartmentMap.compartments).length, 1); @@ -86,20 +116,6 @@ test('mapNodeModules() should not consider peerDependenciesMeta without correspo */ const shortestPathTestCount = 20; - /** @type {ProjectFixture} */ - const fixture = { - root: 'app', - graph: { - app: ['pippo', 'paperino'], - paperino: ['topolino'], - pippo: ['gambadilegno'], - gambadilegno: ['topolino'], - topolino: ['goofy'], - }, - }; - - const entrypoint = 'file:///node_modules/app/index.js'; - test(`mapNodeModules() should be path stable`, async t => { await null; @@ -107,15 +123,18 @@ test('mapNodeModules() should not consider peerDependenciesMeta without correspo /** @type {string|undefined} */ let expectedCanonicalName; - const targetLabel = 'goofy-v1.0.0'; + const targetLabel = 'paperino>topolino>goofy'; - const readPowers = makeProjectFixtureReadPowers(fixture, { + const readPowers = makeProjectFixtureReadPowers(phonyFixture, { randomDelay: true, }); for (let i = 0; i < shortestPathTestCount; i += 1) { // eslint-disable-next-line no-await-in-loop - const compartmentMap = await mapNodeModules(readPowers, entrypoint); + const compartmentMap = await mapNodeModules( + readPowers, + phonyFixture.entrypoint, + ); const compartmentDescriptor = Object.values( compartmentMap.compartments, @@ -125,36 +144,816 @@ test('mapNodeModules() should not consider peerDependenciesMeta without correspo `compartment descriptor for '${targetLabel}' should exist, but it does not`, ); - dumpProjectFixture(t, fixture); + dumpProjectFixture(t, phonyFixture); dumpCompartmentMap(t, compartmentMap); return; } - const { path } = compartmentDescriptor; - if (!path) { + const { label } = compartmentDescriptor; + if (!label) { t.fail( - `path for '${compartmentDescriptor.name}' should exist, but it does not`, + `label for '${compartmentDescriptor.name}' should exist, but it does not`, ); - dumpProjectFixture(t, fixture); + dumpProjectFixture(t, phonyFixture); dumpCompartmentMap(t, compartmentMap); return; } if (i === 0) { - expectedCanonicalName = path.join('>'); + expectedCanonicalName = label; t.log( `Canonical name of compartment '${targetLabel}': ${expectedCanonicalName}`, ); } try { - t.deepEqual(path.join('>'), expectedCanonicalName); + t.is(label, /** @type {any} */ (expectedCanonicalName)); } catch (err) { - dumpProjectFixture(t, fixture); + dumpProjectFixture(t, phonyFixture); dumpCompartmentMap(t, compartmentMap); throw err; } } }); } + +/** + * @typedef StabilityFixtureConfig + * @property {string} entrypoint + * @property {MaybeReadPowers} readPowers + * @property {MapNodeModulesOptions} [options] + */ + +/** + * Configuration of all fixtures to test for stable generation of a {@link PackageCompartmentMapDescriptor} + * @type {Array} + */ +const fixtureConfigs = [ + { + entrypoint: phonyFixture.entrypoint, + readPowers: makeProjectFixtureReadPowers(phonyFixture, { + randomDelay: true, + }), + }, + { + entrypoint: new URL( + 'fixtures-shortest-path/node_modules/app/index.js', + import.meta.url, + ).href, + readPowers, + }, + { + entrypoint: new URL( + 'fixtures-optional-peer-dependencies/node_modules/app/index.js', + import.meta.url, + ).href, + readPowers, + }, + { + entrypoint: new URL( + 'fixtures-missing-optional-peer-dependencies/node_modules/app/index.js', + import.meta.url, + ).href, + readPowers, + }, + { + entrypoint: new URL( + 'fixtures-policy/node_modules/app/index.js', + import.meta.url, + ).href, + readPowers, + options: { + policy: { + entry: { + globals: { + bluePill: true, + }, + packages: { + alice: true, + '@ohmyscope/bob': true, + }, + builtins: { + // that's the one builtin name that scaffold is providing by default + builtin: { + attenuate: 'myattenuator', + params: ['a', 'b'], + }, + }, + }, + resources: { + alice: { + globals: { + redPill: true, + }, + packages: { + 'alice>carol': true, + }, + builtins: { + // that's the one builtin name that scaffold is providing by default + builtin: { + attenuate: 'myattenuator', + params: ['c'], + }, + }, + }, + '@ohmyscope/bob': { + packages: { + alice: true, + }, + }, + 'alice>carol': { + globals: { + purplePill: true, + }, + }, + myattenuator: {}, + }, + }, + }, + }, +]; + +for (const { entrypoint, readPowers, options } of fixtureConfigs) { + const relativeEntrypoint = entrypoint.startsWith(`file://${dirname}`) + ? path.relative(dirname, url.fileURLToPath(entrypoint)) + : entrypoint; + test(`mapNodeModules() should be idempotent for ${relativeEntrypoint}`, async t => { + const compartmentMap = await mapNodeModules( + readPowers, + entrypoint, + options, + ); + + t.snapshot(relativizeCompartmentMap(compartmentMap)); + }); +} + +/** + * Test fixture with dependencies for hook testing + * @satisfies {ProjectFixture} + */ +const hookTestFixture = /** @type {const} */ ({ + root: 'app', + graph: { + app: ['dep-a', 'dep-b'], + 'dep-a': ['dep-c'], + 'dep-b': ['dep-c'], + 'dep-c': [], + }, + entrypoint: 'file:///node_modules/app/index.js', +}); + +/** + * Policy for testing packageDependencies hook + * @type {SomePolicy} + */ +const hookTestPolicy = { + entry: { + packages: WILDCARD_POLICY_VALUE, + }, + resources: { + 'dep-a': { + packages: { + 'dep-a>dep-c': true, + }, + }, + 'dep-b': { + packages: { + 'dep-a>dep-c': true, + }, + }, + }, +}; + +/** + * Test fixture with extra dependencies for snapshot testing + * @satisfies {ProjectFixture} + */ +const packageDependenciesHookFixture = /** @type {const} */ ({ + root: 'app', + graph: { + app: ['dep-a', 'dep-b'], + 'dep-a': ['dep-c'], + 'dep-b': ['dep-c'], + 'dep-c': [], + 'extra-dep': ['dep-c'], // Extra dep that exists in the fixture + }, + entrypoint: 'file:///node_modules/app/index.js', +}); + +/** + * Policy for snapshot testing with packageDependencies hook + * @type {SomePolicy} + */ +const packageDependenciesHookPolicy = { + entry: { + packages: WILDCARD_POLICY_VALUE, + }, + resources: { + 'dep-a': { + packages: { + 'dep-a>dep-c': true, + }, + }, + 'dep-b': { + packages: { + 'dep-a>dep-c': true, + }, + }, + // extra-dep omitted from policy to test dynamic addition via hooks + }, +}; + +test('mapNodeModules - packageDataHook - called with correct parameters', async t => { + /** @type {Map | undefined} */ + let receivedPackageData; + + const packageDataHook = ({ packageData, log }) => { + receivedPackageData = packageData; + t.is(typeof log, 'function', 'log should be a function'); + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDataHook, + }); + + t.truthy(receivedPackageData, 'packageData should be provided'); + if (!receivedPackageData) { + return; + } + + t.true( + typeof receivedPackageData.size === 'number' && + typeof receivedPackageData.has === 'function', + 'packageData should be a Map', + ); + + // Expected canonical names: $root$, dep-a, dep-b, dep-a>dep-c + t.is( + receivedPackageData.size, + 4, + 'should include entry package and all unique dependencies', + ); + + // Verify each package data has the correct structure + for (const [canonicalName, packageData] of receivedPackageData) { + t.is(typeof packageData.name, 'string', 'name should be a string'); + t.is( + typeof packageData.packageDescriptor, + 'object', + 'packageDescriptor should be an object', + ); + t.is(typeof packageData.location, 'string', 'location should be a string'); + t.true( + packageData.location.startsWith('file:///'), + 'location should be a file URL', + ); + t.is( + packageData.canonicalName, + canonicalName, + 'canonicalName should match map key', + ); + } +}); + +test('mapNodeModules - packageDependenciesHook modifies dependencies', async t => { + t.plan(4); + + /** @type {Array<{canonicalName: CanonicalName, dependencies: Set}>} */ + const hookCalls = []; + + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = ({ canonicalName, dependencies }) => { + hookCalls.push({ canonicalName, dependencies: new Set(dependencies) }); + + // Only modify attenuators compartment which holds actual dependency relationships + if (canonicalName === ATTENUATORS_COMPARTMENT) { + const newDeps = new Set([ + ...dependencies, + /** @type {CanonicalName} */ ('added-dep'), + ]); + return { dependencies: newDeps }; + } + + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDependenciesHook, + policy: hookTestPolicy, + }); + + t.true(hookCalls.length > 0, 'hook should have been called'); + + const attenuatorsCall = hookCalls.find( + call => call.canonicalName === ATTENUATORS_COMPARTMENT, + ); + t.truthy( + attenuatorsCall, + 'hook should have been called for attenuators compartment', + ); + if (attenuatorsCall) { + t.true( + attenuatorsCall.dependencies.has('dep-a'), + 'should include original dependency dep-a', + ); + t.true( + attenuatorsCall.dependencies.has('dep-b'), + 'should include original dependency dep-b', + ); + } +}); + +test('mapNodeModules - packageDependenciesHook called even without policy', async t => { + let hookCalled = false; + + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = () => { + hookCalled = true; + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDependenciesHook, + }); + + t.true( + hookCalled, + 'user packageDependencies hook should be called even without policy', + ); +}); + +test('mapNodeModules - packageDependenciesHook removes dependencies', async t => { + t.plan(3); + + /** @type {Array<{canonicalName: CanonicalName, dependencies: Set}>} */ + const hookCalls = []; + + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = ({ canonicalName, dependencies }) => { + hookCalls.push({ canonicalName, dependencies: new Set(dependencies) }); + + if (canonicalName === ATTENUATORS_COMPARTMENT) { + const filteredDeps = new Set( + [...dependencies].filter(dep => dep !== 'dep-b'), + ); + return { dependencies: filteredDeps }; + } + + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDependenciesHook, + policy: hookTestPolicy, + }); + + const attenuatorsCall = hookCalls.find( + call => call.canonicalName === ATTENUATORS_COMPARTMENT, + ); + t.truthy( + attenuatorsCall, + 'hook should have been called for attenuators compartment', + ); + if (attenuatorsCall) { + t.true( + attenuatorsCall.dependencies.has('dep-a'), + 'should include original dependency dep-a', + ); + // Test shows dep-b present before hook filtering removes it + t.true( + attenuatorsCall.dependencies.has('dep-b'), + 'should include original dependency dep-b before filtering', + ); + } +}); + +test('mapNodeModules - multiple hooks work together', async t => { + t.plan(2); + + let packageDataCalled = false; + let packageDependenciesCalls = 0; + + const packageDataHook = () => { + packageDataCalled = true; + }; + + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = () => { + packageDependenciesCalls += 1; + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDataHook, + packageDependenciesHook, + policy: hookTestPolicy, + }); + + t.true(packageDataCalled, 'packageData hook should be called'); + t.true( + packageDependenciesCalls > 0, + 'packageDependencies hook should be called', + ); +}); + +test('mapNodeModules - packageDataHook - hook error handling', async t => { + t.plan(3); + + const packageDataHook = ({ packageData }) => { + // Check if dep-a is in the packageData + if ([...packageData.keys()].some(key => key.includes('dep-a'))) { + throw new Error('Test hook error'); + } + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + + const error = await t.throwsAsync( + mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDataHook, + }), + ); + + t.truthy(error, 'should throw error when hook fails'); + t.true( + error.message.includes('Test hook error'), + 'should propagate hook errors', + ); +}); + +test('mapNodeModules - packageDependenciesHook receives expected canonical names', async t => { + t.plan(8); + + /** @type {Set} */ + const receivedCanonicalNames = new Set(); + + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = ({ canonicalName, dependencies }) => { + receivedCanonicalNames.add(canonicalName); + + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDependenciesHook, + policy: hookTestPolicy, + }); + + t.true( + receivedCanonicalNames.size > 0, + 'hook should be called for some canonical names', + ); + + t.true( + receivedCanonicalNames.has(ATTENUATORS_COMPARTMENT), + 'hook should be called for attenuators compartment', + ); + + const canonicalNamesArray = [...receivedCanonicalNames].sort(); + t.true(canonicalNamesArray.length > 0, 'should receive some canonical names'); + + for (const name of canonicalNamesArray) { + t.is(typeof name, 'string', `canonical name ${name} should be a string`); + } +}); + +test('mapNodeModules - packageDependenciesHook removes dependency (snapshot)', async t => { + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = ({ canonicalName, dependencies }) => { + if (canonicalName === ATTENUATORS_COMPARTMENT) { + const filteredDeps = new Set( + [...dependencies].filter(dep => dep !== 'dep-b'), + ); + return { dependencies: filteredDeps }; + } + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers( + packageDependenciesHookFixture, + ); + const compartmentMap = await mapNodeModules( + hookReadPowers, + packageDependenciesHookFixture.entrypoint, + { + packageDependenciesHook, + policy: packageDependenciesHookPolicy, + }, + ); + + t.snapshot(relativizeCompartmentMap(compartmentMap), 'remove dependency'); +}); + +test('mapNodeModules - packageDependenciesHook adds existing dependency (snapshot)', async t => { + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = ({ canonicalName, dependencies }) => { + // extra-dep exists in snapshotTestFixture but isn't part of normal dependency graph + if (canonicalName === ATTENUATORS_COMPARTMENT) { + const newDeps = new Set([ + ...dependencies, + /** @type {CanonicalName} */ ('extra-dep'), + ]); + return { dependencies: newDeps }; + } + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers( + packageDependenciesHookFixture, + ); + const compartmentMap = await mapNodeModules( + hookReadPowers, + packageDependenciesHookFixture.entrypoint, + { + packageDependenciesHook, + policy: packageDependenciesHookPolicy, + }, + ); + + t.snapshot( + relativizeCompartmentMap(compartmentMap), + 'add existing dependency', + ); +}); + +test('mapNodeModules - packageDependenciesHook adds non-existing dependency logs warning', async t => { + t.plan(1); // single assertion for warning message + + /** @type {Array} */ + const logCalls = []; + + /** @type {LogFn} */ + const mockLog = message => { + logCalls.push(message); + }; + + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = ({ canonicalName, dependencies }) => { + // non-existent-dep doesn't exist in fixture - tests how system handles invalid deps + if (canonicalName === ATTENUATORS_COMPARTMENT) { + const newDeps = new Set([ + ...dependencies, + /** @type {CanonicalName} */ ('non-existent-dep'), + ]); + return { dependencies: newDeps }; + } + return undefined; + }; + + const hookReadPowers = makeProjectFixtureReadPowers( + packageDependenciesHookFixture, + ); + + await mapNodeModules( + hookReadPowers, + packageDependenciesHookFixture.entrypoint, + { + packageDependenciesHook, + policy: packageDependenciesHookPolicy, + log: mockLog, + }, + ); + + const warningMessage = logCalls.find(message => + message.includes( + 'WARNING: packageDependencies hook returned unknown package with label "non-existent-dep"', + ), + ); + + t.truthy(warningMessage, 'should log warning for unknown package dependency'); +}); + +test('mapNodeModules - packageDependenciesHook - no modification (snapshot)', async t => { + // Return undefined to make no modifications (control case) + /** @type {PackageDependenciesHook} */ + const packageDependenciesHook = () => {}; + + const hookReadPowers = makeProjectFixtureReadPowers( + packageDependenciesHookFixture, + ); + const compartmentMap = await mapNodeModules( + hookReadPowers, + packageDependenciesHookFixture.entrypoint, + { + packageDependenciesHook, + policy: packageDependenciesHookPolicy, + }, + ); + + t.snapshot( + relativizeCompartmentMap(compartmentMap), + 'no dependency modification', + ); +}); + +test('mapNodeModules - unknownCanonicalNameHook called for missing policy resources', async t => { + t.plan(6); + + /** @type {Array<{canonicalName: CanonicalName, path: string[], message: string}>} */ + const hookCalls = []; + + const unknownCanonicalNameHook = ({ canonicalName, path, message }) => { + hookCalls.push({ canonicalName, path, message }); + }; + + // Policy with unknown resources to trigger the hook + /** @type {SomePolicy} */ + const policyWithUnknownResources = { + entry: { + packages: WILDCARD_POLICY_VALUE, + }, + resources: { + 'unknown-package': { + // This package doesn't exist in hookTestFixture + packages: { + 'dep-a': true, + }, + }, + 'dep-a': { + // This exists + packages: { + 'dep-a>dep-c': true, // This exists + 'unknown-nested-package': true, // This doesn't exist + }, + }, + }, + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + unknownCanonicalNameHook, + policy: policyWithUnknownResources, + }); + + t.is(hookCalls.length, 2, 'should call hook for each unknown canonical name'); + + // Check hook call for unknown top-level resource + const unknownResourceCall = hookCalls.find( + call => call.canonicalName === 'unknown-package', + ); + t.truthy(unknownResourceCall, 'should call hook for unknown resource'); + t.deepEqual( + unknownResourceCall?.path, + ['resources', 'unknown-package'], + 'should provide correct path for unknown resource', + ); + t.true( + unknownResourceCall?.message.includes( + 'Resource "unknown-package" was not found', + ), + 'should provide descriptive message for unknown resource', + ); + + // Check hook call for unknown nested package + const unknownPackageCall = hookCalls.find( + call => call.canonicalName === 'unknown-nested-package', + ); + t.truthy(unknownPackageCall, 'should call hook for unknown nested package'); + t.deepEqual( + unknownPackageCall?.path, + ['resources', 'dep-a', 'packages', 'unknown-nested-package'], + 'should provide correct path for unknown nested package', + ); +}); + +test('mapNodeModules - unknownCanonicalNameHook not called when all resources exist', async t => { + let hookCalled = false; + + /** @type {UnknownCanonicalNameHook} */ + const unknownCanonicalNameHook = () => { + hookCalled = true; + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + unknownCanonicalNameHook, + policy: hookTestPolicy, // Uses only known resources + }); + + t.false(hookCalled, 'should not call hook when all policy resources exist'); +}); + +test('mapNodeModules - unknownCanonicalNameHook includes suggestions when available', async t => { + /** @type {Array<{canonicalName: CanonicalName, path: string[], message: string, suggestion?: CanonicalName}>} */ + const hookCalls = []; + + /** @type {UnknownCanonicalNameHook} */ + const unknownCanonicalNameHook = ({ + canonicalName, + path, + message, + suggestion, + }) => { + hookCalls.push({ canonicalName, path, message, suggestion }); + }; + + // Policy with typos that should trigger suggestions + /** @type {SomePolicy} */ + const policyWithTypo = { + entry: { + packages: WILDCARD_POLICY_VALUE, + }, + resources: { + 'dep-aa': { + // Not close enough to 'dep-a' to suggest, but contains 'dep-c' + // which should suggest 'dep-a>dep-c' + packages: { + 'dep-c': true, + }, + }, + }, + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + unknownCanonicalNameHook, + policy: policyWithTypo, + }); + + t.is( + hookCalls.length, + 2, + 'should call hook twice for both unknown resources', + ); + + // Check the call for the unknown top-level resource (no close suggestion) + const unknownResourceCall = hookCalls.find( + call => call.canonicalName === 'dep-aa', + ); + t.truthy(unknownResourceCall, 'should call hook for unknown resource'); + t.deepEqual( + unknownResourceCall?.path, + ['resources', 'dep-aa'], + 'should provide correct path for unknown resource', + ); + t.true( + unknownResourceCall?.message.includes('Resource "dep-aa" was not found'), + 'should provide descriptive message for unknown resource', + ); + t.is( + unknownResourceCall?.suggestion, + undefined, + 'should not suggest when no close match exists', + ); + + // Check the call for the nested package (should have suggestion) + const nestedPackageCall = hookCalls.find( + call => call.canonicalName === 'dep-c', + ); + t.truthy(nestedPackageCall, 'should call hook for nested unknown package'); + t.deepEqual( + nestedPackageCall?.path, + ['resources', 'dep-aa', 'packages', 'dep-c'], + 'should provide correct path for nested unknown package', + ); + t.true( + nestedPackageCall?.message.includes( + 'Resource "dep-c" from resource "dep-aa" was not found', + ), + 'should provide descriptive message for nested unknown package', + ); + t.is( + nestedPackageCall?.suggestion, + 'dep-a>dep-c', + 'should suggest the closest matching canonical name', + ); +}); + +test('mapNodeModules - packageDataHook provides all package data', async t => { + t.plan(1); + + /** @type {Set} */ + let receivedCanonicalNames = new Set(); + + const packageDataHook = ({ packageData }) => { + receivedCanonicalNames = new Set([...packageData.keys()].sort()); + }; + + const hookReadPowers = makeProjectFixtureReadPowers(hookTestFixture); + await mapNodeModules(hookReadPowers, hookTestFixture.entrypoint, { + packageDataHook, + }); + + // Expected canonical names based on the test fixture: + // - $root$ (the entry package 'app' becomes '$root$') + // - dep-a (direct dependency) + // - dep-b (direct dependency) + // - dep-a>dep-c (transitive dependency through dep-a) + const expectedCanonicalNames = new Set( + ['$root$', 'dep-a', 'dep-b', 'dep-a>dep-c'].sort(), + ); + + t.deepEqual( + receivedCanonicalNames, + expectedCanonicalNames, + 'should receive exactly the expected canonical names from the project fixture', + ); +}); diff --git a/packages/compartment-mapper/test/node-modules-basename.test.js b/packages/compartment-mapper/test/node-modules-basename.test.js index a5d19d5893..746da74dfa 100644 --- a/packages/compartment-mapper/test/node-modules-basename.test.js +++ b/packages/compartment-mapper/test/node-modules-basename.test.js @@ -1,3 +1,5 @@ +import 'ses'; + import test from 'ava'; import { basename } from '../src/node-modules.js'; diff --git a/packages/compartment-mapper/test/policy-format.test.js b/packages/compartment-mapper/test/policy-format.test.js index 0b419d17ab..4ea1252d79 100644 --- a/packages/compartment-mapper/test/policy-format.test.js +++ b/packages/compartment-mapper/test/policy-format.test.js @@ -1,7 +1,11 @@ import 'ses'; import test from 'ava'; -import { assertPackagePolicy, assertPolicy } from '../src/policy-format.js'; +import { + assertPackagePolicy, + assertPolicy, + WILDCARD_POLICY_VALUE, +} from '../src/policy-format.js'; const q = JSON.stringify; @@ -9,7 +13,11 @@ const q = JSON.stringify; {}, { packages: {}, globals: {}, builtins: {} }, { packages: {}, globals: {}, builtins: {}, noGlobalFreeze: true }, - { packages: 'any', globals: 'any', builtins: 'any' }, + { + packages: WILDCARD_POLICY_VALUE, + globals: WILDCARD_POLICY_VALUE, + builtins: WILDCARD_POLICY_VALUE, + }, { packages: { foo: true, diff --git a/packages/compartment-mapper/test/policy.test.js b/packages/compartment-mapper/test/policy.test.js index 2dba185daf..1f97059566 100644 --- a/packages/compartment-mapper/test/policy.test.js +++ b/packages/compartment-mapper/test/policy.test.js @@ -3,6 +3,12 @@ import 'ses'; import test from 'ava'; import { moduleify, scaffold, sanitizePaths } from './scaffold.js'; +import { + WILDCARD_POLICY_VALUE, + ATTENUATORS_COMPARTMENT, + ENTRY_COMPARTMENT, +} from '../src/policy-format.js'; +import { makePackagePolicy } from '../src/policy.js'; function combineAssertions(...assertionFunctions) { return async (...args) => { @@ -75,9 +81,9 @@ const policy = { }, }; const ANY = { - globals: 'any', - packages: 'any', - builtins: 'any', + globals: WILDCARD_POLICY_VALUE, + packages: WILDCARD_POLICY_VALUE, + builtins: WILDCARD_POLICY_VALUE, }; const anyPolicy = { entry: policy.entry, @@ -112,6 +118,7 @@ const anyExpectations = { carol: { bluePill: 'number', redPill: 'number', purplePill: 'number' }, }), }; + const powerlessCarolExpectations = { namespace: moduleify({ ...defaultExpectations.namespace, @@ -129,12 +136,29 @@ const makeResultAssertions = t.deepEqual(namespace, expectations.namespace); }; -const assertNoPolicyBypassImport = async (t, { compartments }) => { - await t.throwsAsync( - () => compartments.find(c => c.name.includes('alice')).import('hackity'), - { message: /Failed to load module "hackity" in package .*alice/ }, - 'Attempting to import a package into a compartment despite policy should fail.', - ); +const assertExternalModuleNotFound = async ( + t, + { compartments, testCategoryHint }, +) => { + await null; + if (testCategoryHint === 'Archive') { + await t.throwsAsync( + () => compartments.find(c => c.name.includes('alice')).import('hackity'), + { + message: + /importing "hackity" in "alice" was not allowed by "builtins" policy/i, + }, + 'Attempting to import a missing package into a compartment should fail.', + ); + } else { + await t.throwsAsync( + () => compartments.find(c => c.name.includes('alice')).import('hackity'), + { + message: /cannot find external module "hackity"/i, + }, + 'Attempting to import a missing package into a compartment should fail.', + ); + } }; const assertTestAlwaysThrows = t => { @@ -147,7 +171,7 @@ scaffold( fixture, combineAssertions( makeResultAssertions(defaultExpectations), - assertNoPolicyBypassImport, + assertExternalModuleNotFound, ), 2, // expected number of assertions { @@ -162,7 +186,7 @@ scaffold( fixture, combineAssertions( makeResultAssertions(anyExpectations), - assertNoPolicyBypassImport, + assertExternalModuleNotFound, ), 2, // expected number of assertions { @@ -179,10 +203,15 @@ scaffold( 2, // expected number of assertions { shouldFailBeforeArchiveOperations: true, - onError: (t, { error }) => { - t.regex(error.message, /dan.*resolves.*hackity/); + onError: (t, { error, testCategoryHint }) => { + if (testCategoryHint === 'Archive') { + t.regex(error.message, /unknown resources found in policy/i); + t.snapshot(sanitizePaths(error.message), 'archive case error message'); + } else { + t.regex(error.message, /cannot find external module/i); + t.snapshot(sanitizePaths(error.message), 'location case error message'); + } // see the snapshot for the error hint in the message - t.snapshot(sanitizePaths(error.message)); }, addGlobals: globals, policy: { @@ -311,7 +340,7 @@ scaffold( { shouldFailBeforeArchiveOperations: true, onError: (t, { error }) => { - t.regex(error.message, /Importing.*carol.*in.*alice.*not allowed/i); + t.regex(error.message, /cannot find external module "carol"/i); t.snapshot(sanitizePaths(error.message)); }, addGlobals: globals, @@ -423,7 +452,7 @@ scaffold( fixture, combineAssertions( makeResultAssertions(defaultExpectations), - assertNoPolicyBypassImport, + assertExternalModuleNotFound, ), 2, // expected number of assertions { @@ -431,3 +460,176 @@ scaffold( policy: nestedAttenuator(policy), }, ); + +// Unit tests for makePackagePolicy +test('makePackagePolicy() - no policy provided', t => { + t.is(makePackagePolicy('alice'), undefined); + t.is(makePackagePolicy(ATTENUATORS_COMPARTMENT), undefined); + t.is(makePackagePolicy(ENTRY_COMPARTMENT), undefined); + t.is(makePackagePolicy('alice', {}), undefined); +}); + +test('makePackagePolicy() - ATTENUATORS_COMPARTMENT label', t => { + const testPolicy = { + defaultAttenuator: 'myattenuator', + entry: { packages: { alice: true } }, + resources: {}, + }; + + const result = makePackagePolicy(ATTENUATORS_COMPARTMENT, { + policy: testPolicy, + }); + + t.deepEqual(result, { + defaultAttenuator: 'myattenuator', + packages: WILDCARD_POLICY_VALUE, + }); +}); + +test('makePackagePolicy() - ATTENUATORS_COMPARTMENT label without defaultAttenuator', t => { + const testPolicy = { + entry: { packages: { alice: true } }, + resources: {}, + }; + + const result = makePackagePolicy(ATTENUATORS_COMPARTMENT, { + policy: testPolicy, + }); + + t.deepEqual(result, { + defaultAttenuator: undefined, + packages: WILDCARD_POLICY_VALUE, + }); +}); + +test('makePackagePolicy() - ENTRY_COMPARTMENT label', t => { + const entryPolicy = { + globals: { bluePill: true }, + packages: { alice: true }, + builtins: { builtin: { attenuate: 'myattenuator', params: ['a', 'b'] } }, + }; + const testPolicy = { + defaultAttenuator: 'myattenuator', + entry: entryPolicy, + resources: {}, + }; + + const result = makePackagePolicy(ENTRY_COMPARTMENT, { policy: testPolicy }); + + t.is(result, entryPolicy); + t.deepEqual(result, entryPolicy); +}); + +test('makePackagePolicy() - ENTRY_COMPARTMENT label with undefined entry', t => { + const testPolicy = { + defaultAttenuator: 'myattenuator', + resources: {}, + }; + + const result = makePackagePolicy(ENTRY_COMPARTMENT, { policy: testPolicy }); + + t.is(result, undefined); +}); + +test('makePackagePolicy() - regular canonical name that exists in resources', t => { + const resourcePolicy = { + packages: { 'alice>carol': true }, + }; + const testPolicy = { + entry: { packages: { alice: true } }, + resources: { + alice: resourcePolicy, + }, + }; + + const result = makePackagePolicy('alice', { policy: testPolicy }); + + t.is(result, resourcePolicy); + t.deepEqual(result, resourcePolicy); +}); + +test('makePackagePolicy() - regular canonical name that does not exist in resources', t => { + const testPolicy = { + entry: { packages: { alice: true } }, + resources: { + alice: { globals: { santorum: true } }, + }, + }; + + const result = makePackagePolicy('nonexistent', { policy: testPolicy }); + + t.not(result, undefined); + t.is(Object.getPrototypeOf(result), null); + t.deepEqual(result, {}); + t.is(Object.keys(result).length, 0); +}); + +test('makePackagePolicy() - regular canonical name with resources undefined', t => { + const testPolicy = { + entry: { packages: { alice: true } }, + }; + + const result = makePackagePolicy('alice', { policy: testPolicy }); + + t.not(result, undefined); + t.is(Object.getPrototypeOf(result), null); + t.deepEqual(result, {}); + t.is(Object.keys(result).length, 0); +}); + +test('makePackagePolicy() - empty label throws', t => { + const testPolicy = { + entry: { packages: { alice: true } }, + resources: { + alice: { globals: { santorum: true } }, + }, + }; + + t.throws(() => makePackagePolicy(null, { policy: testPolicy }), { + message: /Invalid arguments: label must be a non-empty string; got null/i, + }); + t.throws(() => makePackagePolicy(undefined, { policy: testPolicy }), { + message: + /Invalid arguments: label must be a non-empty string; got undefined/i, + }); + t.throws(() => makePackagePolicy('', { policy: testPolicy }), { + message: /Invalid arguments: label must be a non-empty string; got ""/i, + }); +}); + +test('makePackagePolicy() - preserves object reference for entry', t => { + const entryPolicy = { packages: { alice: true } }; + const testPolicy = { + entry: entryPolicy, + resources: {}, + }; + + const result = makePackagePolicy(ENTRY_COMPARTMENT, { policy: testPolicy }); + + t.is(result, entryPolicy); +}); + +test('makePackagePolicy() - preserves object reference for resources', t => { + const resourcePolicy = { globals: { redPill: true } }; + const testPolicy = { + entry: { packages: { alice: true } }, + resources: { + alice: resourcePolicy, + }, + }; + + const result = makePackagePolicy('alice', { policy: testPolicy }); + + t.is(result, resourcePolicy); +}); + +test('makePackagePolicy() - empty resources object returns empty package policy', t => { + const testPolicy = { + entry: { packages: { alice: true } }, + resources: {}, + }; + + const result = makePackagePolicy('alice', { policy: testPolicy }); + + t.deepEqual(result, {}); +}); diff --git a/packages/compartment-mapper/test/project-fixture.js b/packages/compartment-mapper/test/project-fixture.js index 22b37481a0..c6113280a5 100644 --- a/packages/compartment-mapper/test/project-fixture.js +++ b/packages/compartment-mapper/test/project-fixture.js @@ -1,3 +1,4 @@ +/* eslint-disable no-shadow */ /** * Utilities for working with {@link ProjectFixture} objects * @@ -15,19 +16,32 @@ import nodeUrl from 'node:url'; import { inspect } from 'node:util'; import { makeReadPowers } from '../node-powers.js'; import { GenericGraph } from '../src/generic-graph.js'; +import { + ATTENUATORS_COMPARTMENT, + ENTRY_COMPARTMENT, + WILDCARD_POLICY_VALUE, +} from '../src/policy-format.js'; /** * @import {MakeMaybeReadProjectFixtureOptions, * MakeProjectFixtureReadPowersOptions, * MakeMaybeReadProjectFixtureOptionsWithRandomDelay, * ProjectFixture, - * RestParameters} from './test.types.js' - * @import {CompartmentMapDescriptor, LogFn, MaybeReadFn, PackageDescriptor} from '../src/types.js' + * FixedCustomInspectFunction, + * RestParameters, + * CustomInspectStyles} from './test.types.js' + * @import {CompartmentMapDescriptor, + * LogFn, + * MaybeReadFn, + * MaybeReadNowFn, + * MaybeReadPowers, + * PackageDescriptor, + * PackagePolicy, + * ReadFn} from '../src/types.js' * @import {ExecutionContext} from 'ava' - * @import {CustomInspectFunction} from 'node:util' */ -const { entries, fromEntries, assign, getPrototypeOf } = Object; +const { entries, fromEntries, getPrototypeOf, freeze, keys } = Object; /** * Pretty-prints a {@link ProjectFixture} as an ASCII tree. @@ -88,46 +102,85 @@ const MIN_DELAY = 10; */ const MAX_DELAY = 100; +const customStyles = freeze( + /** @type {CustomInspectStyles} */ ({ + ...inspect.styles, + name: 'white', + undefined: 'dim', + endoKind: 'magenta', + endoCanonical: 'cyanBright', + endoConstant: 'magentaBright', + }), +); + /** - * Creates a `maybeRead` function for use with a {@link ProjectFixture} having a - * random delay. + * Core logic for reading project fixture files. Handles different file types: + * - `package.json`: Generates a package descriptor with `type: 'module'` based + * on the fixture's dependency graph + * - JavaScript files (`.js`, `.mjs`): Returns deterministic ESM module content + * with named and default exports + * - Other files: Returns deterministic text content suitable for testing * - * @overload * @param {ProjectFixture} fixture - * @param {MakeMaybeReadProjectFixtureOptionsWithRandomDelay} options - * @returns {MaybeReadFn} + * @param {string} specifier + * @returns {Buffer|undefined} */ +const readProjectFixtureCore = ({ graph }, specifier) => { + const chunks = specifier.split('node_modules/'); -/** - * Creates a `maybeRead` function for use with a {@link ProjectFixture}, optionally with static delay (in ms) - * @overload - * @param {ProjectFixture} fixture - * @param {MakeMaybeReadProjectFixtureOptions} [options] - * @returns {MaybeReadFn} - */ + if (chunks.length > 2 || chunks.length === 0) { + return undefined; + } + + const filepath = chunks[1]; + const packageName = nodePath.dirname(filepath); + const filename = nodePath.basename(filepath); + + // Handle non-package.json files with deterministic dummy content + if (filename !== 'package.json') { + const extension = nodePath.extname(filename); + + if (extension === '.js' || extension === '.mjs') { + // Return deterministic dummy ESM content + return Buffer.from(`// Module: ${packageName}/${filename} +export const value = '${packageName}-${filename}'; +export default value; +`); + } + + // For other file types, return generic content + return Buffer.from(`// File: ${packageName}/${filename}\n`); + } + + // Handle package.json files + const dependencies = graph[packageName] || []; + const packageDescriptor = { + name: packageName, + version: '1.0.0', + type: 'module', + dependencies: fromEntries( + dependencies.map(dependencyName => [dependencyName, '1.0.0']), + ), + }; + + return Buffer.from(JSON.stringify(packageDescriptor, undefined, 2)); +}; /** + * Creates a `maybeRead` function for use with a {@link ProjectFixture}. + * + * This is the async version that supports delay options and returns `undefined` + * for missing files instead of throwing errors. + * * @param {ProjectFixture} fixture * @param {MakeMaybeReadProjectFixtureOptions|MakeMaybeReadProjectFixtureOptionsWithRandomDelay} [options] * @returns {MaybeReadFn} */ -export const makeMaybeReadProjectFixture = - ({ graph }, options = {}) => - async specifier => { +export const makeMaybeReadProjectFixture = (fixture, options = {}) => { + return async specifier => { await Promise.resolve(); - const chunks = specifier.split('node_modules/'); - if (chunks.length > 2) { - return undefined; - } - - assert( - chunks.length > 0, - `Invalid specifier "${specifier}" for makeMaybeReadProjectFixture`, - ); - - const filepath = chunks[1]; - + // Handle delay options /** @type {() => Promise} */ let wait; if ('randomDelay' in options && options.randomDelay === false) { @@ -143,26 +196,59 @@ export const makeMaybeReadProjectFixture = await wait(); - const packageName = nodePath.dirname(filepath); - const dependencies = graph[packageName] || []; + return readProjectFixtureCore(fixture, specifier); + }; +}; - /** @type {PackageDescriptor} */ - const packageDescriptor = { - name: packageName, - version: '1.0.0', - dependencies: fromEntries( - dependencies.map(dependencyName => [dependencyName, '1.0.0']), - ), - }; +/** + * Creates a `read` function for use with a {@link ProjectFixture}. + * + * This is the async version that throws errors for missing files instead of + * returning `undefined`. + * + * @param {ProjectFixture} fixture + * @param {MakeMaybeReadProjectFixtureOptions|MakeMaybeReadProjectFixtureOptionsWithRandomDelay} [options] + * @returns {ReadFn} + */ +const makeReadProjectFixture = (fixture, options = {}) => { + const maybeRead = makeMaybeReadProjectFixture(fixture, options); - return Buffer.from(JSON.stringify(packageDescriptor)); + return async specifier => { + const result = await maybeRead(specifier); + + if (result === undefined) { + const err = new Error(`File not found: ${specifier}`); + /** @type {any} */ (err).code = 'ENOENT'; + throw err; + } + + return result; }; +}; + +/** + * Creates a `maybeReadNow` function for use with a {@link ProjectFixture}. + * + * This is the synchronous version that returns `undefined` for missing files. + * + * @param {ProjectFixture} fixture + * @returns {MaybeReadNowFn} + */ +const makeMaybeReadNowProjectFixture = fixture => { + /** @type {MaybeReadNowFn} */ + const maybeReadNow = specifier => { + return readProjectFixtureCore(fixture, specifier); + }; + return maybeReadNow; +}; /** * Creates `ReadPowers` for use with a {@link ProjectFixture} * * @param {ProjectFixture} fixture * @param {MakeProjectFixtureReadPowersOptions} [options] + * @see {@link makeMaybeReadProjectFixture} for details + * @returns {MaybeReadPowers} */ export const makeProjectFixtureReadPowers = ( fixture, @@ -174,9 +260,15 @@ export const makeProjectFixtureReadPowers = ( ...otherOptions } = {}, ) => { + const basePowers = makeReadPowers({ fs, url, crypto, path }); + const maybeRead = makeMaybeReadProjectFixture(fixture, otherOptions); + const maybeReadNow = makeMaybeReadNowProjectFixture(fixture); + const read = makeReadProjectFixture(fixture, otherOptions); return { - ...makeReadPowers({ fs, url, crypto, path }), - maybeRead: makeMaybeReadProjectFixture(fixture, otherOptions), + ...basePowers, + maybeRead, + maybeReadNow, + read, }; }; @@ -188,35 +280,104 @@ export const makeProjectFixtureReadPowers = ( * * Otherwise this function is just the identity * - * @param {unknown} value - * @returns {unknown} + * @template T + * @param {T} value + * @returns {T} */ -const styleObject = value => { - if (value && getPrototypeOf(value) === null) { - return fromEntries(entries(value).map(([k, v]) => [k, styleObject(v)])); +const unnullify = value => { + if (value === undefined) { + return value; + } + if (value !== null && getPrototypeOf(value) === null) { + return entries(value).reduce( + (acc, [k, v]) => ({ + ...acc, + [k]: unnullify(v), + }), + /** @type {T} */ ({}), + ); } return value; }; /** - * Prepends a `CompartmentDescriptor.path` with the computed canonical name (or "[Root]" if entry compartment). - * - * Returns a shallow copy of `path` with a {@link CustomInspectFunction}. + * Prepares a {@link PackagePolicy} for inspection by {@link dumpCompartmentMap}. * - * @param {string[]} path + * @template {PackagePolicy|undefined} T + * @param {T} packagePolicy + * @returns {T} */ -const stylePath = path => { - const fancyPath = [...path]; - assign(fancyPath, { - /** @type {CustomInspectFunction} */ - [inspect.custom]: (_, { stylize }) => - path.length - ? `[${stylize('Canonical', 'date')}: ${stylize(path.join('>'), 'special')}] ${inspect(path)}` - : `[${stylize('Root', 'date')}]`, - }); - return fancyPath; +const stylePackagePolicy = packagePolicy => { + if (packagePolicy === undefined) { + return packagePolicy; + } + const policy = unnullify(packagePolicy); + return { + ...policy, + /** @type {FixedCustomInspectFunction} */ + [inspect.custom]: (_, options, inspect) => { + for (const [key, value] of entries(policy)) { + if (value === WILDCARD_POLICY_VALUE) { + // styles value as a constant. this could also be done by mutating styles, + // but since the value is just a string, we don't need to. we _do_ however + // need to turn it into an object so that it can have a custom inspect function. + policy[key] = { + /** @type {FixedCustomInspectFunction} */ + [inspect.custom]: (_, options) => + options.stylize(value, 'endoConstant'), + }; + } else if ( + key === 'packages' && + typeof value === 'object' && + keys(/** @type {any} */ (value)).length + ) { + // styles non-empty `packages` prop; all object keys are temporarily + // styled as canonical names. I was unable to find a nicer way to do + // this (e.g. with `stylize`), since the `inspect()` call will want to + // apply its own colors. + policy[key] = /** @type {any} */ ({ + ...value, + /** @type {FixedCustomInspectFunction} */ + [inspect.custom]: (_, options, inspect) => { + const { styles } = inspect; + try { + inspect.styles = /** @type {any} */ ({ + ...customStyles, + string: 'cyanBright', + name: 'cyanBright', + }); + return inspect(value, options); + } finally { + inspect.styles = styles; + } + }, + }); + } + } + return `${options.stylize('PackagePolicy', 'endoKind')}(${inspect(policy, { ...options })})`; + }, + }; }; +/** + * + * @param {string} label + */ +const styleLabel = label => ({ + /** @type {FixedCustomInspectFunction} */ + [inspect.custom]: (_, options) => { + const { stylize } = options; + const kind = stylize('Canonical', 'endoKind'); + if (label === ATTENUATORS_COMPARTMENT) { + return `${kind}(${stylize('Attenuators', 'endoConstant')})`; + } + if (label === ENTRY_COMPARTMENT) { + return `${kind}(${stylize('Entry', 'endoConstant')})`; + } + return `${kind}(${stylize(label, 'endoCanonical')})`; + }, +}); + /** * Dump a {@link CompartmentMapDescriptor}, omitting some fields. * @@ -233,43 +394,55 @@ const stylePath = path => { * @returns {void} */ export const dumpCompartmentMap = (logger, compartmentMap) => { - const compartmentMapForInspect = { - ...compartmentMap, - /** @type {CustomInspectFunction} */ - [inspect.custom]: () => ({ - compartments: fromEntries( - entries(compartmentMap.compartments).map( - ([ - compartmentName, - { - label, - name, - scopes, - location, - sourceDirname, - modules, - path = [], - }, - ]) => [ - compartmentName, - { - label, - name, - scopes: styleObject(scopes), - location: styleObject(location), - sourceDirname, - modules: styleObject(modules), - path: stylePath(path), - }, - ], - ), - ), + const originalStyles = inspect.styles; - entry: styleObject(compartmentMap.entry), - }), - }; + inspect.styles = customStyles; + + let inspected; + try { + const compartmentMapForInspect = { + ...compartmentMap, + /** @type {FixedCustomInspectFunction} */ + [inspect.custom]: () => { + return { + compartments: fromEntries( + entries(compartmentMap.compartments).map( + ([ + compartmentName, + { + label, + name, + scopes, + location, + sourceDirname, + modules, + policy, + ...rest + }, + ]) => [ + compartmentName, + { + label: styleLabel(label), + name, + location, // TODO: style if file URL + policy: stylePackagePolicy(policy), + sourceDirname, + ...unnullify(rest), + }, + ], + ), + ), + + entry: unnullify(compartmentMap.entry), + }; + }, + }; + inspected = inspect(compartmentMapForInspect, { depth: 4, colors: true }); + } finally { + inspect.styles = originalStyles; + } logger.log( - `Compartment map for entry compartment at "${compartmentMap.entry.compartment}":\n${inspect(compartmentMapForInspect, false, 4, true)}`, + `Compartment map for entry compartment at "${compartmentMap.entry.compartment}":\n${inspected}`, ); }; diff --git a/packages/compartment-mapper/test/scaffold.js b/packages/compartment-mapper/test/scaffold.js index 1532908197..d7f0e6c10e 100644 --- a/packages/compartment-mapper/test/scaffold.js +++ b/packages/compartment-mapper/test/scaffold.js @@ -4,6 +4,7 @@ import fs from 'fs'; import crypto from 'crypto'; import url from 'url'; import { ZipReader, ZipWriter } from '@endo/zip'; +import { getEnvironmentOption } from '@endo/env-options'; import { loadLocation, importLocation, @@ -21,10 +22,13 @@ import { defaultParserForLanguage } from '../src/import-parsers.js'; import { defaultParserForLanguage as defaultArchiveParserForLanguage } from '../src/archive-parsers.js'; import { makeReadPowers } from '../src/node-powers.js'; +const shouldEnableLogging = + getEnvironmentOption('SCAFFOLD_LOGGING', '0') === '1'; + /** - * @import {TestFn, FailingFn} from 'ava'; + * @import {TestFn, FailingFn, ExecutionContext} from 'ava'; * @import {ScaffoldOptions, FixtureAssertionFn, TestCategoryHint, WrappedTestFn} from './test.types.js'; - * @import {HashPowers} from '../src/types.js'; + * @import {HashPowers, LogFn} from '../src/types.js'; */ export const readPowers = makeReadPowers({ fs, crypto, url }); @@ -36,6 +40,17 @@ export const sanitizePaths = (text = '', tolerateLineChange = false) => { return text.replace(/file:\/\/[^'"\n]+\/packages\//g, 'file://.../'); }; +/** + * @param {ExecutionContext} [t] + * @returns {LogFn} + */ +const getLogger = t => { + if (t && shouldEnableLogging) { + return t.log.bind(t); + } + return () => {}; +}; + /** * @returns {{getCompartments: () => Array, Compartment: typeof Compartment}} */ @@ -77,10 +92,13 @@ const builtinLocation = new URL( // all subsequent tests to satisfy the "builtin" module dependency of the // application package. -export async function setup() { +/** + * @param {LogFn} log + */ +export async function setup(log) { await null; if (modules === undefined) { - const utility = await loadLocation(readPowers, builtinLocation); + const utility = await loadLocation(readPowers, builtinLocation, { log }); const { namespace } = await utility.import({ globals }); // We pass the builtin module into the module map. modules = { builtin: namespace }; @@ -122,6 +140,7 @@ export function scaffold( workspaceLanguageForExtension = undefined, workspaceCommonjsLanguageForExtension = undefined, workspaceModuleLanguageForExtension = undefined, + log, additionalOptions = {}, } = {}, ) { @@ -170,7 +189,8 @@ export function scaffold( wrap(test, 'Location')(`${name} / loadLocation`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); const application = await loadLocation(readPowers, fixture, { policy, @@ -184,6 +204,7 @@ export function scaffold( workspaceLanguageForExtension, workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, + log, strict, ...additionalOptions, }); @@ -200,7 +221,8 @@ export function scaffold( `${name} / mapNodeModules / importFromMap`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); const languages = Object.keys({ ...defaultParserForLanguage, @@ -219,6 +241,7 @@ export function scaffold( workspaceLanguageForExtension, workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, + log, ...additionalOptions, }); @@ -243,7 +266,8 @@ export function scaffold( `${name} / mapNodeModules / loadFromMap / import`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); const languages = Object.keys({ ...defaultParserForLanguage, @@ -289,9 +313,11 @@ export function scaffold( wrap(test, 'Location')(`${name} / importLocation`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); const { namespace } = await importLocation(readPowers, fixture, { + log, globals: { ...globals, ...addGlobals }, policy, modules, @@ -316,7 +342,8 @@ export function scaffold( `${name} / makeArchive / parseArchive`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); const archive = await makeArchive(readPowers, fixture, { modules, @@ -361,7 +388,8 @@ export function scaffold( `${name} / makeArchive / parseArchive with a prefix`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); // Zip files support an arbitrary length prefix. const archive = await makeArchive(readPowers, fixture, { @@ -405,7 +433,8 @@ export function scaffold( t.plan( fixtureAssertionCount + (shouldFailBeforeArchiveOperations ? 0 : 2), ); - await setup(); + log = log ?? getLogger(t); + await setup(log); // Single file slot. let archive; @@ -456,7 +485,8 @@ export function scaffold( t.plan( fixtureAssertionCount + (shouldFailBeforeArchiveOperations ? 0 : 2), ); - await setup(); + log = log ?? getLogger(t); + await setup(log); // Single file slot. let archive; @@ -472,7 +502,7 @@ export function scaffold( const sourceMaps = new Set(); const sourceMapHook = (sourceMap, { sha512 }) => { sourceMaps.add(sha512); - t.log(sha512, sourceMap); + // t.log(sha512, sourceMap); }; const computeSourceMapLocation = ({ sha512 }) => { @@ -526,7 +556,8 @@ export function scaffold( `${name} / mapNodeModules / makeArchiveFromMap / importArchive`, async (t, Compartment) => { t.plan(fixtureAssertionCount); - await setup(); + log = log ?? getLogger(t); + await setup(log); const languages = Object.keys({ ...defaultArchiveParserForLanguage, @@ -585,13 +616,16 @@ export function scaffold( // @ts-expect-error XXX TS2345 test(`${name} / makeArchive / parseArchive / hashArchive consistency`, async (t, Compartment) => { t.plan(1); - await setup(); + log = log ?? getLogger(t); + await setup(log); const expectedSha512 = await hashLocation( /** @type {HashPowers} */ (readPowers), fixture, { + log, modules, + policy, conditions: new Set(['development', ...(conditions || [])]), strict, searchSuffixes, @@ -611,6 +645,7 @@ export function scaffold( modules, conditions: new Set(['development', ...(conditions || [])]), strict, + policy, searchSuffixes, commonDependencies, parserForLanguage, @@ -642,12 +677,14 @@ export function scaffold( // @ts-expect-error XXX TS2345 test(`${name} / makeArchive / parseArchive, but with sha512 corruption of a compartment map`, async (t, Compartment) => { t.plan(1); - await setup(); + log = log ?? getLogger(t); + await setup(log); const expectedSha512 = await hashLocation( /** @type {HashPowers} */ (readPowers), fixture, { + policy, modules, conditions: new Set(['development', ...(conditions || [])]), strict, @@ -660,11 +697,13 @@ export function scaffold( workspaceLanguageForExtension, workspaceCommonjsLanguageForExtension, workspaceModuleLanguageForExtension, + log, ...additionalOptions, }, ); const archive = await makeArchive(readPowers, fixture, { + policy, modules, conditions: new Set(['development', ...(conditions || [])]), strict, diff --git a/packages/compartment-mapper/test/snapshot-utilities.js b/packages/compartment-mapper/test/snapshot-utilities.js new file mode 100644 index 0000000000..a5e0a3b000 --- /dev/null +++ b/packages/compartment-mapper/test/snapshot-utilities.js @@ -0,0 +1,209 @@ +/** + * Test utilities for working with snapshots of `CompartmentMapDescriptor`s and other dat structures. + * + * @module + */ + +import { + isCompartmentModuleConfiguration, + isLocalModuleSource, +} from '../src/guards.js'; +import { ATTENUATORS_COMPARTMENT } from '../src/policy-format.js'; + +/** + * @import { + * Sources, + * CompartmentSources, + * CaptureResult, + * PackageCompartmentMapDescriptor, + * FileUrlString, + * EntryDescriptor, + * PackageCompartmentDescriptor, + * } from '../src/types.js'; + */ + +const { entries, fromEntries, create } = Object; + +/** + * Strip absolute file URLs to relative test paths. The paths will be relative + * to the `compartment-mapper` workspace dir. + * @param {FileUrlString} url + * @returns {FileUrlString} + */ +export const relativizeFileUrlString = url => { + if (!url.startsWith('file://')) { + throw new TypeError(`Not a file URL: ${url}`); + } + const match = url.match(/file:\/\/.*?packages\/compartment-mapper\/(.*)/); + return match ? `file:///${match[1]}` : url; +}; + +/** + * Replaces all absolute {@link FileUrlString}s within a + * {@link PackageCompartmentMapDescriptor} (as produced by `mapNodeModules()`) and makes relative to the `compartment-mapper` workspace dir, as if it were the filesystem root. + * + * For snapshotting purposes (since we cannot use absolute paths in snapshots). + * + * TODO: Validate resulting CompartmentMapDescriptor + * @template {PackageCompartmentMapDescriptor} T + * @param {T} compartmentMap + * @returns {T} + */ +export const relativizeCompartmentMap = compartmentMap => { + // entry.compartment + /** @type {EntryDescriptor} */ + const relativeEntry = { + ...compartmentMap.entry, + compartment: relativizeFileUrlString(compartmentMap.entry.compartment), + }; + + const compartmentsEntries = + /** @type {Array<[compartmentName: keyof PackageCompartmentMapDescriptor['compartments'], compartmentDescriptor: PackageCompartmentDescriptor]>} */ ( + entries(compartmentMap.compartments) + ); + + // compartments[] + const relativeCompartments = Object.fromEntries( + compartmentsEntries.map(([compartmentName, compartmentDescriptor]) => { + const newKey = + compartmentName === ATTENUATORS_COMPARTMENT + ? compartmentName + : relativizeFileUrlString(compartmentName); + + // compartments[].modules[].compartment + const modules = compartmentDescriptor.modules + ? fromEntries( + entries(compartmentDescriptor.modules).map( + ([moduleName, moduleDescriptorConfiguration]) => { + if ( + isCompartmentModuleConfiguration( + moduleDescriptorConfiguration, + ) + ) { + if ( + moduleDescriptorConfiguration.compartment === + ATTENUATORS_COMPARTMENT + ) { + return [moduleName, moduleDescriptorConfiguration]; + } + return [ + moduleName, + { + ...moduleDescriptorConfiguration, + compartment: relativizeFileUrlString( + moduleDescriptorConfiguration.compartment, + ), + }, + ]; + } + return [moduleName, moduleDescriptorConfiguration]; + }, + ), + ) + : compartmentDescriptor.modules; + + // compartments[].scopes[].compartment + const scopes = compartmentDescriptor.scopes + ? fromEntries( + entries(compartmentDescriptor.scopes).map( + ([scopeName, scopeDescriptor]) => { + if (scopeDescriptor.compartment === ATTENUATORS_COMPARTMENT) { + return [scopeName, scopeDescriptor]; + } + return [ + scopeName, + { + ...scopeDescriptor, + compartment: relativizeFileUrlString( + scopeDescriptor.compartment, + ), + }, + ]; + }, + ), + ) + : compartmentDescriptor.scopes; + + // compartments[].location + if (compartmentDescriptor.location !== ATTENUATORS_COMPARTMENT) { + compartmentDescriptor.location = relativizeFileUrlString( + compartmentDescriptor.location, + ); + } + return [ + newKey, + { + ...compartmentDescriptor, + modules, + scopes, + }, + ]; + }), + ); + + return { + ...compartmentMap, + entry: relativeEntry, + compartments: relativeCompartments, + }; +}; + +/** + * Strips absolute `file://` prefixes from locations in "renames" Records of {@link CaptureResult}. + * + * @see {@link stripCaptureResult} + * @param {Record} renames + * @returns {Record} Stripped renames + */ +export const stripRenames = renames => { + /** @type {Record} */ + const result = create(null); + for (const [key, value] of entries(renames)) { + result[relativizeFileUrlString(/** @type {FileUrlString} */ (key))] = + relativizeFileUrlString(/** @type {FileUrlString} */ (value)); + } + return result; +}; + +/** + * Returns a deep copy of {@link Sources} with absolute `file://` prefixes + * stripped from `sourceLocation` properties. + * + * @see {@link stripCaptureResult} + * @param {Sources} sources + * @returns {Sources} + */ +export const stripSources = sources => { + /** @type {Sources} */ + const result = create(null); + for (const [compartmentKey, compartmentSources] of entries(sources)) { + /** @type {CompartmentSources} */ + const compartmentCopy = create(null); + for (const [moduleKey, moduleSource] of entries(compartmentSources)) { + if (isLocalModuleSource(moduleSource)) { + compartmentCopy[moduleKey] = { + ...moduleSource, + sourceLocation: relativizeFileUrlString( + /** @type {FileUrlString} */ (moduleSource.sourceLocation), + ), + }; + } else { + compartmentCopy[moduleKey] = moduleSource; + } + } + result[compartmentKey] = compartmentCopy; + } + return result; +}; + +/** + * Strips absolute `file://` prefixes from locations in a `CaptureResult`. + * @param {CaptureResult} result + */ +export const stripCaptureResult = result => ({ + ...result, + compartmentRenames: stripRenames(result.compartmentRenames), + oldToNewCompartmentNames: stripRenames(result.oldToNewCompartmentNames), + newToOldCompartmentNames: stripRenames(result.newToOldCompartmentNames), + captureSources: stripSources(result.captureSources), +}); diff --git a/packages/compartment-mapper/test/snapshots/make-import-hook-maker.hooks.test.js.snap b/packages/compartment-mapper/test/snapshots/make-import-hook-maker.hooks.test.js.snap new file mode 100644 index 0000000000..cc547ea809 Binary files /dev/null and b/packages/compartment-mapper/test/snapshots/make-import-hook-maker.hooks.test.js.snap differ diff --git a/packages/compartment-mapper/test/snapshots/make-import-hook-maker.test.js.md b/packages/compartment-mapper/test/snapshots/make-import-hook-maker.test.js.md new file mode 100644 index 0000000000..83e28dbfcc --- /dev/null +++ b/packages/compartment-mapper/test/snapshots/make-import-hook-maker.test.js.md @@ -0,0 +1,68 @@ +# Snapshot report for `test/make-import-hook-maker.test.js` + +The actual snapshot is saved in `make-import-hook-maker.test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## captureFromMap - moduleSourceHook - receives correct parameters (snapshot) captureFromMap + +> Snapshot 1 + + { + bytes: Buffer @Uint8Array [ + 2f2f204d 6f64756c 653a2074 6573742d 6170702f 696e6465 782e6a73 0a657870 + 6f727420 636f6e73 74207661 6c756520 3d202774 6573742d 6170702d 696e6465 + 782e6a73 273b0a65 78706f72 74206465 6661756c 74207661 6c75653b 0a + ], + exports: [ + 'default', + 'value', + ], + imports: [], + language: 'mjs', + location: 'file:///node_modules/test-app/index.js', + reexports: [], + sha512: 'da157d268dbcf109c011f52ccc835fa6a07c35889eb33a1ad7ad9a398068769ada811359b3e05256adf7101999a00a7d6acb1b5f97206692792fb813645905fe', + } + +## loadFromMap - moduleSourceHook - receives correct parameters (snapshot) loadFromMap + +> Snapshot 1 + + { + bytes: Buffer @Uint8Array [ + 2f2f204d 6f64756c 653a2074 6573742d 6170702f 696e6465 782e6a73 0a657870 + 6f727420 636f6e73 74207661 6c756520 3d202774 6573742d 6170702d 696e6465 + 782e6a73 273b0a65 78706f72 74206465 6661756c 74207661 6c75653b 0a + ], + exports: [ + 'default', + 'value', + ], + imports: [], + language: 'mjs', + location: 'file:///node_modules/test-app/index.js', + reexports: [], + sha512: undefined, + } + +## loadLocation - moduleSourceHook - receives correct parameters (snapshot) loadLocation + +> Snapshot 1 + + { + bytes: Buffer @Uint8Array [ + 2f2f204d 6f64756c 653a2074 6573742d 6170702f 696e6465 782e6a73 0a657870 + 6f727420 636f6e73 74207661 6c756520 3d202774 6573742d 6170702d 696e6465 + 782e6a73 273b0a65 78706f72 74206465 6661756c 74207661 6c75653b 0a + ], + exports: [ + 'default', + 'value', + ], + imports: [], + language: 'mjs', + location: 'file:///node_modules/test-app/index.js', + reexports: [], + sha512: undefined, + } diff --git a/packages/compartment-mapper/test/snapshots/make-import-hook-maker.test.js.snap b/packages/compartment-mapper/test/snapshots/make-import-hook-maker.test.js.snap new file mode 100644 index 0000000000..701260da0a Binary files /dev/null and b/packages/compartment-mapper/test/snapshots/make-import-hook-maker.test.js.snap differ diff --git a/packages/compartment-mapper/test/snapshots/map-node-modules.hooks.test.js.snap b/packages/compartment-mapper/test/snapshots/map-node-modules.hooks.test.js.snap new file mode 100644 index 0000000000..179a6dc983 Binary files /dev/null and b/packages/compartment-mapper/test/snapshots/map-node-modules.hooks.test.js.snap differ diff --git a/packages/compartment-mapper/test/snapshots/map-node-modules.test.js.md b/packages/compartment-mapper/test/snapshots/map-node-modules.test.js.md new file mode 100644 index 0000000000..a4a51595c1 --- /dev/null +++ b/packages/compartment-mapper/test/snapshots/map-node-modules.test.js.md @@ -0,0 +1,1611 @@ +# Snapshot report for `test/map-node-modules.test.js` + +The actual snapshot is saved in `map-node-modules.test.js.snap`. + +Generated by [AVA](https://avajs.dev). + +## mapNodeModules() should be idempotent for file:///node_modules/app/index.js + +> Snapshot 1 + + { + compartments: { + 'file:///node_modules/app/': { + label: '$root$', + location: 'file:///node_modules/app/', + modules: { + app: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + paperino: { + compartment: 'file:///node_modules/paperino/', + module: './index.js', + }, + pippo: { + compartment: 'file:///node_modules/pippo/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + app: { + compartment: 'file:///node_modules/app/', + }, + paperino: { + compartment: 'file:///node_modules/paperino/', + }, + pippo: { + compartment: 'file:///node_modules/pippo/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/gambadilegno/': { + label: 'pippo>gambadilegno', + location: 'file:///node_modules/gambadilegno/', + modules: { + gambadilegno: { + compartment: 'file:///node_modules/gambadilegno/', + module: './index.js', + }, + topolino: { + compartment: 'file:///node_modules/topolino/', + module: './index.js', + }, + }, + name: 'gambadilegno', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + gambadilegno: { + compartment: 'file:///node_modules/gambadilegno/', + }, + topolino: { + compartment: 'file:///node_modules/topolino/', + }, + }, + sourceDirname: 'gambadilegno', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/goofy/': { + label: 'paperino>topolino>goofy', + location: 'file:///node_modules/goofy/', + modules: { + goofy: { + compartment: 'file:///node_modules/goofy/', + module: './index.js', + }, + }, + name: 'goofy', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + goofy: { + compartment: 'file:///node_modules/goofy/', + }, + }, + sourceDirname: 'goofy', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/paperino/': { + label: 'paperino', + location: 'file:///node_modules/paperino/', + modules: { + paperino: { + compartment: 'file:///node_modules/paperino/', + module: './index.js', + }, + topolino: { + compartment: 'file:///node_modules/topolino/', + module: './index.js', + }, + }, + name: 'paperino', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + paperino: { + compartment: 'file:///node_modules/paperino/', + }, + topolino: { + compartment: 'file:///node_modules/topolino/', + }, + }, + sourceDirname: 'paperino', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/pippo/': { + label: 'pippo', + location: 'file:///node_modules/pippo/', + modules: { + gambadilegno: { + compartment: 'file:///node_modules/gambadilegno/', + module: './index.js', + }, + pippo: { + compartment: 'file:///node_modules/pippo/', + module: './index.js', + }, + }, + name: 'pippo', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + gambadilegno: { + compartment: 'file:///node_modules/gambadilegno/', + }, + pippo: { + compartment: 'file:///node_modules/pippo/', + }, + }, + sourceDirname: 'pippo', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/topolino/': { + label: 'paperino>topolino', + location: 'file:///node_modules/topolino/', + modules: { + goofy: { + compartment: 'file:///node_modules/goofy/', + module: './index.js', + }, + topolino: { + compartment: 'file:///node_modules/topolino/', + module: './index.js', + }, + }, + name: 'topolino', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + goofy: { + compartment: 'file:///node_modules/goofy/', + }, + topolino: { + compartment: 'file:///node_modules/topolino/', + }, + }, + sourceDirname: 'topolino', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules() should be idempotent for fixtures-shortest-path/node_modules/app/index.js + +> Snapshot 1 + + { + compartments: { + 'file:///test/fixtures-shortest-path/node_modules/app/': { + label: '$root$', + location: 'file:///test/fixtures-shortest-path/node_modules/app/', + modules: { + '.': { + compartment: 'file:///test/fixtures-shortest-path/node_modules/app/', + module: './index.js', + }, + app: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/app/', + module: './index.js', + }, + paperino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/paperino/', + module: './index.js', + }, + pippo: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/pippo/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + app: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/app/', + }, + paperino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/paperino/', + }, + pippo: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/pippo/', + }, + }, + sourceDirname: 'app', + types: {}, + version: '1.0.0', + }, + 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/': { + label: 'pippo>gambadilegno', + location: 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/', + modules: { + '.': { + compartment: 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/', + module: './index.js', + }, + gambadilegno: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/', + module: './index.js', + }, + topolino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + module: './index.js', + }, + }, + name: 'gambadilegno', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + gambadilegno: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/', + }, + topolino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + }, + }, + sourceDirname: 'gambadilegno', + types: {}, + version: '1.0.0', + }, + 'file:///test/fixtures-shortest-path/node_modules/goofy/': { + label: 'paperino>topolino>goofy', + location: 'file:///test/fixtures-shortest-path/node_modules/goofy/', + modules: { + '.': { + compartment: 'file:///test/fixtures-shortest-path/node_modules/goofy/', + module: './index.js', + }, + goofy: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/goofy/', + module: './index.js', + }, + }, + name: 'goofy', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + goofy: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/goofy/', + }, + }, + sourceDirname: 'goofy', + types: {}, + version: '1.0.0', + }, + 'file:///test/fixtures-shortest-path/node_modules/paperino/': { + label: 'paperino', + location: 'file:///test/fixtures-shortest-path/node_modules/paperino/', + modules: { + '.': { + compartment: 'file:///test/fixtures-shortest-path/node_modules/paperino/', + module: './index.js', + }, + paperino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/paperino/', + module: './index.js', + }, + topolino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + module: './index.js', + }, + }, + name: 'paperino', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + paperino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/paperino/', + }, + topolino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + }, + }, + sourceDirname: 'paperino', + types: {}, + version: '1.0.0', + }, + 'file:///test/fixtures-shortest-path/node_modules/pippo/': { + label: 'pippo', + location: 'file:///test/fixtures-shortest-path/node_modules/pippo/', + modules: { + '.': { + compartment: 'file:///test/fixtures-shortest-path/node_modules/pippo/', + module: './index.js', + }, + gambadilegno: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/', + module: './index.js', + }, + pippo: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/pippo/', + module: './index.js', + }, + }, + name: 'pippo', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + gambadilegno: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/gambadilegno/', + }, + pippo: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/pippo/', + }, + }, + sourceDirname: 'pippo', + types: {}, + version: '1.0.0', + }, + 'file:///test/fixtures-shortest-path/node_modules/topolino/': { + label: 'paperino>topolino', + location: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + modules: { + '.': { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + module: './index.js', + }, + goofy: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/goofy/', + module: './index.js', + }, + topolino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + module: './index.js', + }, + }, + name: 'topolino', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + goofy: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/goofy/', + }, + topolino: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/topolino/', + }, + }, + sourceDirname: 'topolino', + types: {}, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///test/fixtures-shortest-path/node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules() should be idempotent for fixtures-optional-peer-dependencies/node_modules/app/index.js + +> Snapshot 1 + + { + compartments: { + 'file:///test/fixtures-optional-peer-dependencies/node_modules/app/': { + label: '$root$', + location: 'file:///test/fixtures-optional-peer-dependencies/node_modules/app/', + modules: { + '.': { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/app/', + module: './index.js', + }, + app: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/app/', + module: './index.js', + }, + paperina: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + app: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/app/', + }, + paperina: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/', + }, + }, + sourceDirname: 'app', + types: {}, + version: '1.0.0', + }, + 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/': { + label: 'paperina', + location: 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/', + modules: { + '.': { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/', + module: './index.js', + }, + paperina: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/', + module: './index.js', + }, + }, + name: 'paperina', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + paperina: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/paperina/', + }, + }, + sourceDirname: 'paperina', + types: {}, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///test/fixtures-optional-peer-dependencies/node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules() should be idempotent for fixtures-missing-optional-peer-dependencies/node_modules/app/index.js + +> Snapshot 1 + + { + compartments: { + 'file:///test/fixtures-missing-optional-peer-dependencies/node_modules/app/': { + label: '$root$', + location: 'file:///test/fixtures-missing-optional-peer-dependencies/node_modules/app/', + modules: { + '.': { + compartment: 'file:///test/fixtures-missing-optional-peer-dependencies/node_modules/app/', + module: './index.js', + }, + app: { + compartment: 'file:///test/fixtures-missing-optional-peer-dependencies/node_modules/app/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'cjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: undefined, + scopes: { + app: { + compartment: 'file:///test/fixtures-missing-optional-peer-dependencies/node_modules/app/', + }, + }, + sourceDirname: 'app', + types: {}, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///test/fixtures-missing-optional-peer-dependencies/node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules() should be idempotent for fixtures-policy/node_modules/app/index.js + +> Snapshot 1 + + { + compartments: { + '': { + label: '', + location: '', + modules: { + '@ohmyscope/bob': { + compartment: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + module: './index.js', + }, + '@ohmyscope/bob/nested-export': { + compartment: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + module: './index.js', + }, + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + module: './index.js', + }, + eve: { + compartment: 'file:///test/fixtures-policy/node_modules/eve/', + module: './index.js', + }, + myattenuator: { + compartment: 'file:///test/fixtures-policy/node_modules/myattenuator/', + module: './index.js', + }, + 'myattenuator/attenuate': { + compartment: 'file:///test/fixtures-policy/node_modules/myattenuator/', + module: './index.js', + }, + }, + name: '', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + defaultAttenuator: undefined, + packages: 'any', + }, + scopes: { + '': { + compartment: '', + }, + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + }, + eve: { + compartment: 'file:///test/fixtures-policy/node_modules/eve/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '', + }, + 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/': { + label: '@ohmyscope/bob', + location: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + modules: { + '@ohmyscope/bob': { + compartment: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + module: './index.js', + }, + '@ohmyscope/bob/nested-export': { + compartment: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + module: './index.js', + }, + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + module: './index.js', + }, + }, + name: '@ohmyscope/bob', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + alice: true, + }, + }, + scopes: { + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + }, + }, + sourceDirname: 'bob', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///test/fixtures-policy/node_modules/alice/': { + label: 'alice', + location: 'file:///test/fixtures-policy/node_modules/alice/', + modules: { + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + module: './index.js', + }, + carol: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/node_modules/carol/', + module: './index.js', + }, + }, + name: 'alice', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + builtins: { + builtin: { + attenuate: 'myattenuator', + params: [ + 'c', + ], + }, + }, + globals: { + redPill: true, + }, + packages: { + 'alice>carol': true, + }, + }, + scopes: { + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + }, + carol: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/node_modules/carol/', + }, + }, + sourceDirname: 'alice', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///test/fixtures-policy/node_modules/alice/node_modules/carol/': { + label: 'alice>carol', + location: 'file:///test/fixtures-policy/node_modules/alice/node_modules/carol/', + modules: { + carol: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/node_modules/carol/', + module: './index.js', + }, + }, + name: 'carol', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + globals: { + purplePill: true, + }, + }, + scopes: { + carol: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/node_modules/carol/', + }, + }, + sourceDirname: 'carol', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///test/fixtures-policy/node_modules/app/': { + label: '$root$', + location: 'file:///test/fixtures-policy/node_modules/app/', + modules: { + '@ohmyscope/bob': { + compartment: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + module: './index.js', + }, + '@ohmyscope/bob/nested-export': { + compartment: 'file:///test/fixtures-policy/node_modules/@ohmyscope/bob/', + module: './index.js', + }, + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + module: './index.js', + }, + app: { + compartment: 'file:///test/fixtures-policy/node_modules/app/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + builtins: { + builtin: { + attenuate: 'myattenuator', + params: [ + 'a', + 'b', + ], + }, + }, + globals: { + bluePill: true, + }, + packages: { + '@ohmyscope/bob': true, + alice: true, + }, + }, + scopes: { + alice: { + compartment: 'file:///test/fixtures-policy/node_modules/alice/', + }, + app: { + compartment: 'file:///test/fixtures-policy/node_modules/app/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///test/fixtures-policy/node_modules/eve/': { + label: 'eve', + location: 'file:///test/fixtures-policy/node_modules/eve/', + modules: { + eve: { + compartment: 'file:///test/fixtures-policy/node_modules/eve/', + module: './index.js', + }, + }, + name: 'eve', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: {}, + scopes: { + eve: { + compartment: 'file:///test/fixtures-policy/node_modules/eve/', + }, + }, + sourceDirname: 'eve', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///test/fixtures-policy/node_modules/hackity/': { + label: 'eve>hackity', + location: 'file:///test/fixtures-policy/node_modules/hackity/', + modules: { + hackity: { + compartment: 'file:///test/fixtures-policy/node_modules/hackity/', + module: './index.js', + }, + }, + name: 'hackity', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: {}, + scopes: { + hackity: { + compartment: 'file:///test/fixtures-policy/node_modules/hackity/', + }, + }, + sourceDirname: 'hackity', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///test/fixtures-policy/node_modules/myattenuator/': { + label: 'myattenuator', + location: 'file:///test/fixtures-policy/node_modules/myattenuator/', + modules: { + myattenuator: { + compartment: 'file:///test/fixtures-policy/node_modules/myattenuator/', + module: './index.js', + }, + 'myattenuator/attenuate': { + compartment: 'file:///test/fixtures-policy/node_modules/myattenuator/', + module: './index.js', + }, + }, + name: 'myattenuator', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: {}, + scopes: {}, + sourceDirname: 'myattenuator', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///test/fixtures-policy/node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules - packageDependenciesHook removes dependency (snapshot) + +> remove dependency + + { + compartments: { + '': { + label: '', + location: '', + modules: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + }, + name: '', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + defaultAttenuator: undefined, + packages: 'any', + }, + scopes: { + '': { + compartment: '', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '', + }, + 'file:///node_modules/app/': { + label: '$root$', + location: 'file:///node_modules/app/', + modules: { + app: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: 'any', + }, + scopes: { + app: { + compartment: 'file:///node_modules/app/', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-a/': { + label: 'dep-a', + location: 'file:///node_modules/dep-a/', + modules: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-a', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + 'dep-a>dep-c': true, + }, + }, + scopes: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-a', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-b/': { + label: 'dep-b', + location: 'file:///node_modules/dep-b/', + modules: { + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-b', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + 'dep-a>dep-c': true, + }, + }, + scopes: { + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-b', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-c/': { + label: 'dep-a>dep-c', + location: 'file:///node_modules/dep-c/', + modules: { + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-c', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: {}, + scopes: { + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-c', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules - packageDependenciesHook adds existing dependency (snapshot) + +> add existing dependency + + { + compartments: { + '': { + label: '', + location: '', + modules: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + }, + name: '', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + defaultAttenuator: undefined, + packages: 'any', + }, + scopes: { + '': { + compartment: '', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '', + }, + 'file:///node_modules/app/': { + label: '$root$', + location: 'file:///node_modules/app/', + modules: { + app: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: 'any', + }, + scopes: { + app: { + compartment: 'file:///node_modules/app/', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-a/': { + label: 'dep-a', + location: 'file:///node_modules/dep-a/', + modules: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-a', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + 'dep-a>dep-c': true, + }, + }, + scopes: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-a', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-b/': { + label: 'dep-b', + location: 'file:///node_modules/dep-b/', + modules: { + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-b', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + 'dep-a>dep-c': true, + }, + }, + scopes: { + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-b', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-c/': { + label: 'dep-a>dep-c', + location: 'file:///node_modules/dep-c/', + modules: { + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-c', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: {}, + scopes: { + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-c', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + tags: [], + } + +## mapNodeModules - packageDependenciesHook - no modification (snapshot) + +> no dependency modification + + { + compartments: { + '': { + label: '', + location: '', + modules: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + }, + name: '', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + defaultAttenuator: undefined, + packages: 'any', + }, + scopes: { + '': { + compartment: '', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '', + }, + 'file:///node_modules/app/': { + label: '$root$', + location: 'file:///node_modules/app/', + modules: { + app: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + }, + name: 'app', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: 'any', + }, + scopes: { + app: { + compartment: 'file:///node_modules/app/', + }, + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + }, + sourceDirname: 'app', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-a/': { + label: 'dep-a', + location: 'file:///node_modules/dep-a/', + modules: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + module: './index.js', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-a', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + 'dep-a>dep-c': true, + }, + }, + scopes: { + 'dep-a': { + compartment: 'file:///node_modules/dep-a/', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-a', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-b/': { + label: 'dep-b', + location: 'file:///node_modules/dep-b/', + modules: { + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + module: './index.js', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-b', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: { + packages: { + 'dep-a>dep-c': true, + }, + }, + scopes: { + 'dep-b': { + compartment: 'file:///node_modules/dep-b/', + }, + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-b', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + 'file:///node_modules/dep-c/': { + label: 'dep-a>dep-c', + location: 'file:///node_modules/dep-c/', + modules: { + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + module: './index.js', + }, + }, + name: 'dep-c', + parsers: { + bytes: 'bytes', + cjs: 'cjs', + js: 'mjs', + json: 'json', + mjs: 'mjs', + text: 'text', + }, + policy: {}, + scopes: { + 'dep-c': { + compartment: 'file:///node_modules/dep-c/', + }, + }, + sourceDirname: 'dep-c', + types: { + './index.js': 'mjs', + }, + version: '1.0.0', + }, + }, + entry: { + compartment: 'file:///node_modules/app/', + module: './index.js', + }, + tags: [], + } diff --git a/packages/compartment-mapper/test/snapshots/map-node-modules.test.js.snap b/packages/compartment-mapper/test/snapshots/map-node-modules.test.js.snap new file mode 100644 index 0000000000..26f0617123 Binary files /dev/null and b/packages/compartment-mapper/test/snapshots/map-node-modules.test.js.snap differ diff --git a/packages/compartment-mapper/test/snapshots/policy.test.js.md b/packages/compartment-mapper/test/snapshots/policy.test.js.md index 9dd489d6ea..458aa247ad 100644 --- a/packages/compartment-mapper/test/snapshots/policy.test.js.md +++ b/packages/compartment-mapper/test/snapshots/policy.test.js.md @@ -6,111 +6,111 @@ Generated by [AVA](https://avajs.dev). ## policy - attack - browser alias - with alias hint / loadLocation -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / mapNodeModules / importFromMap -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / mapNodeModules / loadFromMap / import -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / importLocation -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / makeArchive / parseArchive -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / makeArchive / parseArchive with a prefix -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / writeArchive / loadArchive -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / writeArchive / importArchive -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - attack - browser alias - with alias hint / mapNodeModules / makeArchiveFromMap / importArchive -> Snapshot 1 +> location case error message - 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Importing "dan" in "eve-v1.0.0" was not allowed by "packages" policy: {"dan":true} (info: Blocked in linking. "dan" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/hackity/".)' + 'Failed to load module "./attack.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (1 underlying failures: Cannot find external module "dan" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/eve/' ## policy - disallowed package with error hint / loadLocation > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / mapNodeModules / importFromMap > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / mapNodeModules / loadFromMap / import > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / importLocation > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / makeArchive / parseArchive > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / makeArchive / parseArchive with a prefix > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / writeArchive / loadArchive > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / writeArchive / importArchive > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - disallowed package with error hint / mapNodeModules / makeArchiveFromMap / importArchive > Snapshot 1 - 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".), Importing "carol" in "alice-v1.0.0" was not allowed by "packages" policy: {"alice>carol":false} (info: Blocked in linking. "carol" is part of the compartment map and resolves to "file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/node_modules/carol/".)' + 'Failed to load module "./index.js" in package "file://.../compartment-mapper/test/fixtures-policy/node_modules/app/" (2 underlying failures: Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/, Cannot find external module "carol" in package file://.../compartment-mapper/test/fixtures-policy/node_modules/alice/' ## policy - attenuator error aggregation / loadLocation diff --git a/packages/compartment-mapper/test/snapshots/policy.test.js.snap b/packages/compartment-mapper/test/snapshots/policy.test.js.snap index 1b763c9016..3dc64da2b7 100644 Binary files a/packages/compartment-mapper/test/snapshots/policy.test.js.snap and b/packages/compartment-mapper/test/snapshots/policy.test.js.snap differ diff --git a/packages/compartment-mapper/test/test.types.d.ts b/packages/compartment-mapper/test/test.types.d.ts index 808f02b334..d9c5b42961 100644 --- a/packages/compartment-mapper/test/test.types.d.ts +++ b/packages/compartment-mapper/test/test.types.d.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-shadow */ /** * Utility types for tests * @@ -5,15 +6,16 @@ */ import type { ExecutionContext } from 'ava'; +import type { inspect as nodeInspect, InspectOptionsStylized } from 'util'; import type { makeReadPowers } from '../src/node-powers.js'; -import type { LoadLocationOptions, SomePolicy } from '../archive-lite.js'; +import type { + FileUrlString, + LoadLocationOptions, + Simplify, + SomePolicy, +} from '../src/types.js'; // #region utility -/** - * Makes a nicer tooltip for `T` in IDEs (most of the time). - */ -export type Simplify = { [K in keyof T]: T[K] } & {}; - /** * Set all props of `T` to be optional */ @@ -44,6 +46,7 @@ export type ProjectFixtureGraph = Record; export interface ProjectFixture { root: Root; graph: ProjectFixtureGraph; + entrypoint?: FileUrlString; } /** @@ -81,6 +84,41 @@ export type MakeProjectFixtureReadPowersOptions = Simplify< MakeMaybeReadProjectFixtureOptions & SetOptional[0]> >; + +/** + * This is the same function as Node's `CustomInspectFunction`, but I added the + * third parameter which was missing from `@types/node`. + * TODO: Upstream & remove this. + */ +export type FixedCustomInspectFunction = ( + depth: number, + options: InspectOptionsStylized, + inspect: typeof nodeInspect, +) => any; + +/** + * Did you know you can define custom styles for `util.inspect`? It doesn't say + * anywhere that you can't!! + */ +export type CustomInspectStyles = Simplify< + (typeof nodeInspect)['styles'] & { + name: string; + endoKind: string; + endoCanonical: string; + endoConstant: string; + } +>; + +declare module 'node:util' { + /** + * Augments the `stylize` method of `InspectOptionsStylized` to allow + * {@link CustomInspectStyles} to be applied. + */ + interface InspectOptionsStylized { + stylize(text: string, styleType: keyof CustomInspectStyles): string; + } +} + // #endregion // #region scaffold.js diff --git a/packages/compartment-mapper/tsconfig.json b/packages/compartment-mapper/tsconfig.json index ce4c69b6a8..c14371d9c9 100644 --- a/packages/compartment-mapper/tsconfig.json +++ b/packages/compartment-mapper/tsconfig.json @@ -10,5 +10,8 @@ "src/**/*.js", "src/**/*.ts", "test", + ], + "exclude": [ + "**/*.d.ts" ] } diff --git a/packages/env-options/package.json b/packages/env-options/package.json index 566fb7aacb..b609ce1d07 100644 --- a/packages/env-options/package.json +++ b/packages/env-options/package.json @@ -18,7 +18,6 @@ "main": "./index.js", "module": "./index.js", "unpkg": null, - "types": null, "exports": { ".": "./index.js", "./package.json": "./package.json" diff --git a/yarn.lock b/yarn.lock index 2fe344d8ea..c5562a4c17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -309,6 +309,7 @@ __metadata: resolution: "@endo/compartment-mapper@workspace:packages/compartment-mapper" dependencies: "@endo/cjs-module-analyzer": "workspace:^" + "@endo/env-options": "workspace:^" "@endo/module-source": "workspace:^" "@endo/path-compare": "workspace:^" "@endo/trampoline": "workspace:^"