@endo/compartment-mapper
Version:
The compartment mapper assembles Node applications in a sandbox
427 lines (386 loc) • 13.1 kB
JavaScript
/**
* Provides the linking behavior shared by all Compartment Mapper
* workflows.
* This involves creating and configuring compartments according to the
* specifications in a compartment map, and is suitable for compartment maps
* that just outline the locations of compartments and their inter-linkage and
* also compartment maps that include every module descriptor in the transitive
* dependencies of their entry module.
*
* @module
*/
/**
* @import {ModuleMapHook} from 'ses'
* @import {
* CompartmentDescriptor,
* CompartmentMapDescriptor,
* ImportNowHookMaker,
* LinkOptions,
* LinkResult,
* ModuleDescriptor,
* ParseFn,
* AsyncParseFn,
* ParserForLanguage,
* ParserImplementation,
* ShouldDeferError,
* } from './types.js'
*/
import { makeMapParsers } from './map-parser.js';
import { resolve as resolveFallback } from './node-module-specifier.js';
import {
ATTENUATORS_COMPARTMENT,
attenuateGlobals,
enforceModulePolicy,
makeDeferredAttenuatorsProvider,
} from './policy.js';
const { assign, create, entries, freeze } = Object;
const { hasOwnProperty } = Object.prototype;
const { apply } = Reflect;
const { allSettled } = Promise;
/**
* @template T
* @type {(iterable: Iterable<Promise<T>>) => Promise<Array<PromiseSettledResult<T>>>}
*/
const promiseAllSettled = allSettled.bind(Promise);
const defaultCompartment = Compartment;
// q, as in quote, for strings in error messages.
const q = JSON.stringify;
/**
* @param {Record<string, unknown>} object
* @param {string} key
* @returns {boolean}
*/
const has = (object, key) => apply(hasOwnProperty, object, [key]);
/**
* For a full, absolute module specifier like "dependency",
* produce the module specifier in the dependency, like ".".
* For a deeper path like "@org/dep/aux" and a prefix like "@org/dep", produce
* "./aux".
*
* @param {string} moduleSpecifier
* @param {string} prefix
* @returns {string=}
*/
const trimModuleSpecifierPrefix = (moduleSpecifier, prefix) => {
if (moduleSpecifier === prefix) {
return '.';
}
if (moduleSpecifier.startsWith(`${prefix}/`)) {
return `./${moduleSpecifier.slice(prefix.length + 1)}`;
}
return undefined;
};
/**
* `makeModuleMapHook` generates a `moduleMapHook` for the `Compartment`
* constructor, suitable for Node.js style packages where any module in the
* package might be imported.
* Since searching for all of these modules up front is either needlessly
* costly (on a file system) or impossible (from a web service), we
* let the import graph guide our search.
* Any module specifier with an absolute prefix should be captured by
* the `moduleMap` or `moduleMapHook`.
*
* @param {CompartmentDescriptor} compartmentDescriptor
* @param {Record<string, Compartment>} compartments
* @param {string} compartmentName
* @param {Record<string, ModuleDescriptor>} moduleDescriptors
* @param {Record<string, ModuleDescriptor>} scopeDescriptors
* @returns {ModuleMapHook | undefined}
*/
const makeModuleMapHook = (
compartmentDescriptor,
compartments,
compartmentName,
moduleDescriptors,
scopeDescriptors,
) => {
/**
* @param {string} moduleSpecifier
* @returns {string | object | undefined}
*/
const moduleMapHook = moduleSpecifier => {
compartmentDescriptor.retained = true;
const moduleDescriptor = moduleDescriptors[moduleSpecifier];
if (moduleDescriptor !== undefined) {
moduleDescriptor.retained = true;
// "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,
)}}`,
);
}
return {
compartment: foreignCompartment,
namespace: foreignModuleSpecifier,
};
}
}
// Search for a scope that shares a prefix with the requested module
// specifier.
// This might be better with a trie, but only a benchmark on real-world
// data would tell us whether the additional complexity would translate to
// better performance, so this is left readable and presumed slow for now.
for (const [scopePrefix, scopeDescriptor] of entries(scopeDescriptors)) {
const foreignModuleSpecifier = trimModuleSpecifierPrefix(
moduleSpecifier,
scopePrefix,
);
if (foreignModuleSpecifier !== undefined) {
const { compartment: foreignCompartmentName } = scopeDescriptor;
if (foreignCompartmentName === undefined) {
throw Error(
`Cannot import from scope ${scopePrefix} due to missing "compartment" property`,
);
}
const foreignCompartment = compartments[foreignCompartmentName];
if (foreignCompartment === undefined) {
throw Error(
`Cannot import from missing compartment ${q(
foreignCompartmentName,
)}`,
);
}
enforceModulePolicy(scopePrefix, compartmentDescriptor, {
exit: false,
errorHint: `Blocked in linking. ${q(
moduleSpecifier,
)} is part of the compartment map and resolves to ${q(
foreignCompartmentName,
)}.`,
});
// The following line is weird.
// Information is flowing backward.
// This moduleMapHook writes back to the `modules` descriptor, from the
// original compartment map.
// So the compartment map that was used to create the compartment
// assembly, can then be captured in an archive, obviating the need for
// a moduleMapHook when we assemble compartments from the resulting
// archive.
moduleDescriptors[moduleSpecifier] = {
retained: true,
compartment: foreignCompartmentName,
module: foreignModuleSpecifier,
};
return {
compartment: foreignCompartment,
namespace: foreignModuleSpecifier,
};
}
}
// No entry in the module map.
// Compartments will fall through to their `importHook`.
return undefined;
};
return moduleMapHook;
};
/**
* @type {ImportNowHookMaker}
*/
const impossibleImportNowHookMaker = () => {
return function impossibleImportNowHook() {
throw new Error('Provided read powers do not support dynamic requires');
};
};
/**
* Assemble a DAG of compartments as declared in a compartment map starting at
* the named compartment and building all compartments that it depends upon,
* recursively threading the modules exported by one compartment into the
* compartment that imports them.
*
* - Returns the root of the compartment DAG.
* - Does not load or execute any modules.
* - Uses `makeImportHook` with the given "location" string of each compartment
* in the DAG.
* - Passes the given globals and external modules into the root compartment
* only.
*
* @param {CompartmentMapDescriptor} 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,
) => {
const {
resolve = resolveFallback,
makeImportHook,
makeImportNowHook = impossibleImportNowHookMaker,
parserForLanguage: parserForLanguageOption = {},
globals = {},
transforms = [],
moduleTransforms,
syncModuleTransforms,
__shimTransforms__ = [],
__native__ = false,
archiveOnly = false,
Compartment = defaultCompartment,
} = options;
const { compartment: entryCompartmentName } = entry;
/** @type {Record<string, Compartment>} */
const compartments = create(null);
/**
* @param {string} attenuatorSpecifier
*/
const attenuators = makeDeferredAttenuatorsProvider(
compartments,
compartmentDescriptors,
);
const pendingJobs = [];
/** @type {ParserForLanguage} */
const parserForLanguage = freeze(
assign(create(null), parserForLanguageOption),
);
const mapParsers = makeMapParsers({
parserForLanguage,
moduleTransforms,
syncModuleTransforms,
});
for (const [compartmentName, compartmentDescriptor] of entries(
compartmentDescriptors,
)) {
const {
location,
name,
parsers: languageForExtension = {},
types: languageForModuleSpecifier = {},
} = compartmentDescriptor;
// this is for retaining the correct type inference about these values
// without use of `let`
const { scopes: _scopes, modules: _modules } = compartmentDescriptor;
const modules = _modules || create(null);
const scopes = _scopes || create(null);
// Capture the default.
// The `moduleMapHook` writes back to the compartment map.
compartmentDescriptor.modules = modules;
// TS is kind of dumb about this, so we can use a type assertion to avoid a
// pointless ternary.
const parse = /** @type {ParseFn|AsyncParseFn} */ (
mapParsers(languageForExtension, languageForModuleSpecifier)
);
/** @type {ShouldDeferError} */
const shouldDeferError = language => {
if (language && has(parserForLanguage, language)) {
return /** @type {ParserImplementation} */ (parserForLanguage[language])
.heuristicImports;
} else {
// If language is undefined or there's no parser, the error we could consider deferring is surely related to
// that. Nothing to throw here.
return false;
}
};
// If we ever need an alternate resolution algorithm, it should be
// indicated in the compartment descriptor and a behavior selected here.
const resolveHook = resolve;
const importHook = makeImportHook({
packageLocation: location,
packageName: name,
attenuators,
parse,
shouldDeferError,
compartments,
});
const importNowHook = makeImportNowHook({
packageLocation: location,
packageName: name,
parse,
compartments,
shouldDeferError,
});
const moduleMapHook = makeModuleMapHook(
compartmentDescriptor,
compartments,
compartmentName,
modules,
scopes,
);
const compartment = new Compartment({
name: location,
resolveHook,
importHook,
importNowHook,
moduleMapHook,
transforms,
__shimTransforms__,
__options__: true,
__native__,
});
if (!archiveOnly) {
attenuateGlobals(
compartment.globalThis,
globals,
compartmentDescriptor.policy,
attenuators,
pendingJobs,
compartmentDescriptor.name,
);
}
compartments[compartmentName] = compartment;
}
const compartment = compartments[entryCompartmentName];
if (compartment === undefined) {
throw Error(
`Cannot assemble compartment graph because the root compartment named ${q(
entryCompartmentName,
)} is missing from the compartment map`,
);
}
const attenuatorsCompartment = compartments[ATTENUATORS_COMPARTMENT];
return {
compartment,
compartments,
attenuatorsCompartment,
pendingJobsPromise: promiseAllSettled(pendingJobs).then(
/** @param {PromiseSettledResult<unknown>[]} results */ results => {
const errors = results
.filter(result => result.status === 'rejected')
.map(
/** @param {PromiseRejectedResult} result */ result =>
result.reason,
);
if (errors.length > 0) {
throw Error(
`Globals attenuation errors: ${errors
.map(error => error.message)
.join(', ')}`,
);
}
},
),
};
};
/**
* @param {CompartmentMapDescriptor} compartmentMap
* @param {LinkOptions} options
* @deprecated Use {@link link}.
*/
export const assemble = (compartmentMap, options) =>
link(compartmentMap, options).compartment;