@endo/compartment-mapper
Version:
The compartment mapper assembles Node applications in a sandbox
539 lines (491 loc) • 18 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 {
* ImportNowHookMaker,
* LinkOptions,
* LinkResult,
* ParseFn,
* AsyncParseFn,
* ParserForLanguage,
* ParserImplementation,
* ShouldDeferError,
* ScopeDescriptor,
* CompartmentModuleConfiguration,
* PackageCompartmentMapDescriptor,
* FileUrlString,
* PackageCompartmentDescriptor,
* FileCompartmentMapDescriptor,
* FileCompartmentDescriptor,
* FileModuleConfiguration,
* } from './types.js'
* @import {SubpathReplacer} from './types/pattern-replacement.js'
*/
import { makeMapParsers } from './map-parser.js';
import { resolve as resolveFallback } from './node-module-specifier.js';
import {
attenuateGlobals,
enforcePolicyByModule,
makeDeferredAttenuatorsProvider,
} from './policy.js';
import { ATTENUATORS_COMPARTMENT } from './policy-format.js';
import {
isCompartmentModuleConfiguration,
isExitModuleConfiguration,
} from './guards.js';
import { makeMultiSubpathReplacer } from './pattern-replacement.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;
/**
* TODO: can we just use `Object.hasOwn` instead?
* @param {Record<string, unknown>} 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;
// See section 3.1 of RFC 3986 URI Scheme Generic Syntax
// https://www.rfc-editor.org/rfc/rfc3986#section-3.1
// Scheme names are case-insensitive per the cited section, so the regex
// accepts both cases on the prefix. Bundlers that produce specifiers like
// `HTTP:` or `File:` are unusual but valid per the spec.
const urlish = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/;
/**
* 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 {FileCompartmentDescriptor|PackageCompartmentDescriptor} compartmentDescriptor
* @param {Record<string, Compartment>} compartments
* @param {FileUrlString} compartmentName
* @param {Record<string, FileModuleConfiguration|CompartmentModuleConfiguration>} moduleDescriptors
* @param {Record<string, ScopeDescriptor<FileUrlString>>} scopeDescriptors
* @param {boolean} archiveOnly
* @returns {ModuleMapHook | undefined}
*/
const makeModuleMapHook = (
compartmentDescriptor,
compartments,
compartmentName,
moduleDescriptors,
scopeDescriptors,
archiveOnly,
) => {
// Build pattern matcher once per compartment if patterns exist.
const { patterns } = /** @type {Partial<PackageCompartmentDescriptor>} */ (
compartmentDescriptor
);
/** @type {SubpathReplacer | null} */
const matchPattern =
patterns && patterns.length > 0 ? makeMultiSubpathReplacer(patterns) : null;
/**
* @type {ModuleMapHook}
*/
const moduleMapHook = moduleSpecifier => {
compartmentDescriptor.retained = true;
if (archiveOnly && urlish.test(moduleSpecifier)) {
// When creating an archive of an application that imports a platform
// module like node:fs, we implicitly expect these to be provided by the
// host's importHook on the target platform. Freeze the synthesized
// descriptor so the cross-boundary return cannot be tampered with by a
// downstream caller.
return freeze({
source: freeze({
imports: freeze([]),
exports: freeze([]),
execute() {
throw new Error(
'Cannot import an application loaded strictly for analysis',
);
},
}),
});
}
const moduleDescriptor = moduleDescriptors[moduleSpecifier];
if (moduleDescriptor !== undefined) {
moduleDescriptor.retained = true;
if (isExitModuleConfiguration(moduleDescriptor)) {
return undefined;
}
// "foreignCompartmentName" refers to the compartment which
// may differ from the current compartment
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,
};
}
}
}
// Check patterns for wildcard matches (before scopes).
// Patterns may resolve within the same compartment (internal patterns)
// or to a foreign compartment (dependency export patterns).
if (matchPattern) {
const match = matchPattern(moduleSpecifier);
if (match !== null) {
const { result: resolvedPath, compartment: foreignCompartmentName } =
match;
// Null result means the specifier is explicitly excluded.
if (resolvedPath === null) {
throw Error(
`Cannot find module ${q(moduleSpecifier)} — excluded by null target pattern in ${q(compartmentName)}`,
);
}
const targetCompartmentName =
/** @type {FileUrlString} */
(foreignCompartmentName || compartmentName);
// A passthrough wildcard like "./*": "./*" produces a pattern where
// the resolved path equals the original specifier in the same
// compartment — a self-referential no-op. Returning it as a redirect
// would cause SES's memoized loader to await the in-flight Promise for
// this specifier from within that same Promise's resolution chain,
// deadlocking permanently. Skip it and let importHook load the module
// directly from the filesystem instead.
if (
targetCompartmentName === compartmentName &&
resolvedPath === moduleSpecifier
) {
return undefined;
}
// Write back to moduleDescriptors for caching, archival, and
// policy enforcement. The write-back must precede the policy
// check because enforcePolicyByModule verifies the specifier
// exists in compartmentDescriptor.modules (the same object).
moduleDescriptors[moduleSpecifier] = {
retained: true,
compartment: targetCompartmentName,
module: resolvedPath,
__createdBy: 'link-pattern',
};
// Policy enforcement for pattern-matched modules
enforcePolicyByModule(moduleSpecifier, compartmentDescriptor, {
exit: false,
errorHint: `Pattern matched in compartment ${q(compartmentName)}: module specifier ${q(moduleSpecifier)} mapped to ${q(resolvedPath)}`,
});
const targetCompartment = compartments[targetCompartmentName];
if (targetCompartment === undefined) {
throw Error(
`Cannot import module specifier ${q(moduleSpecifier)} from missing compartment ${q(targetCompartmentName)}`,
);
}
return {
compartment: targetCompartment,
namespace: resolvedPath,
};
}
}
// 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,
)}`,
);
}
enforcePolicyByModule(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,
__createdBy: 'link',
};
// actual module descriptor
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 {PackageCompartmentMapDescriptor|FileCompartmentMapDescriptor} compartmentMap
* @param {LinkOptions} options
* @returns {LinkResult} the root compartment of the compartment DAG
*/
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,
});
const compartmentDescriptorEntries =
/** @type {[FileUrlString, PackageCompartmentDescriptor|FileCompartmentDescriptor][]} */ (
entries(compartmentDescriptors)
);
for (const [
compartmentName,
compartmentDescriptor,
] of compartmentDescriptorEntries) {
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 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,
archiveOnly,
);
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 {PackageCompartmentMapDescriptor} compartmentMap
* @param {LinkOptions} options
* @deprecated Use {@link link}.
*/
export const assemble = (compartmentMap, options) =>
link(compartmentMap, options).compartment;