Add import, importNow, and module methods to Compartment
MODIFY
packages/ses/README.md
packages/ses/README.md
@@ -74,6 +74,47 @@ c1.global === c2.global; // false
c1.global.JSON === c2.global.JSON; // true
```
+### Compartments
+
+Any code executed within a compartment shares a set of module instances.
+For modules to work within a compartment, the creator must provide
+a `resolveHook` and an `importHook`.
+The `resolveHook` determines how the compartment will infer the full module
+specifier for another module from a referrer module and the module specifier
+imported within that module.
+The `importHook` accepts a module specifier and asynchronously returns a
+`ModuleStaticRecord` for that module.
+
+```js
+import { Compartment, ModuleStaticRecord } from 'ses';
+const c1 = new Compartment({}, {}, {
+ resolveHook: (moduleSpecifier, moduleReferrer) => {
+ return new URL(moduleSpecifier, moduleReferrer).toString();
+ },
+ importHook: async moduleSpecifier => {
+ const moduleLocation = locate(moduleSpecifier);
+ const moduleText = await retrieve(moduleLocation);
+ return new ModuleStaticRecord(moduleText);
+ },
+});
+```
+
+A compartment can also link a module in another compartment.
+Each compartment has a `module` function that accepts a module specifier
+and returns the ModuleNamespace for that module.
+The ModuleNamespace is not useful for inspecting the exports of the
+module until that module has been imported, but it can be passed into the
+module map of another Compartment, creating a link.
+
+```js
+const c2 = new Compartment({}, {
+ 'https://example.com/packages/example/': c1.module('./main.js'),
+}, {
+ resolveHook,
+ importHook,
+});
+```
+
## Bug Disclosure
Please help us practice coordinated security bug disclosure, by using the

MODIFY
packages/ses/src/compartment-shim.js
packages/ses/src/compartment-shim.js
@@ -17,6 +17,13 @@ import { assign } from './commons.js';
import { createGlobalObject } from './global-object.js';
import { performEval } from './evaluate.js';
import { getCurrentRealmRec } from './realm-rec.js';
+import { load } from './module-load.js';
+import { link } from './module-link.js';
+import { getDeferredExports } from './module-proxy.js';
+
+// q, for quoting strings.
+const q = JSON.stringify;
+const { entries } = Object;
const analyzeModule = makeModuleAnalyzer(babel.default);
@@ -52,17 +59,39 @@ export class ModuleStaticRecord {
}
}
+// privateFields captures the private state for each compartment.
+const privateFields = new WeakMap();
+
+// moduleAliases associates every public module exports namespace with its
+// corresponding compartment and specifier so they can be used to link modules
+// across compartments.
+// The mechanism to thread an alias is to use the compartment.module function
+// to obtain the exports namespace of a foreign module and pass it into another
+// compartment's moduleMap constructor option.
+const moduleAliases = new WeakMap();
+
+// Compartments do not need an importHook or resolveHook to be useful
+// as a vessel for evaluating programs.
+// However, any method that operates the module system will throw an exception
+// if these hooks are not available.
+const assertModuleHooks = compartment => {
+ const { importHook, resolveHook } = privateFields.get(compartment);
+ if (typeof importHook !== 'function' || typeof resolveHook !== 'function') {
+ throw new TypeError(
+ `Compartment must be constructed with an importHook and a resolveHook for it to be able to load modules`,
+ );
+ }
+};
+
/**
* Compartment()
* The Compartment constructor is a global. A host that wants to execute
* code in a context bound to a new global creates a new compartment.
*/
-const privateFields = new WeakMap();
-
export class Compartment {
- constructor(endowments, modules, options = {}) {
+ constructor(endowments = {}, modules = {}, options = {}) {
// Extract options, and shallow-clone transforms.
- const { transforms = [] } = options;
+ const { transforms = [], resolveHook, importHook } = options;
const globalTransforms = [...transforms];
const realmRec = getCurrentRealmRec();
@@ -72,7 +101,45 @@ export class Compartment {
assign(globalObject, endowments);
+ // Map<FullSpecifier, ModuleCompartmentRecord>
+ const moduleRecords = new Map();
+ // Map<FullSpecifier, ModuleInstance>
+ const instances = new Map();
+ // Map<FullSpecifier, Alias{Compartment, FullSpecifier}>
+ const aliases = new Map();
+ // Map<FullSpecifier, {ExportsProxy, ProxiedExports, activate()}>
+ const deferredExports = new Map();
+
+ for (const [specifier, module] of entries(modules)) {
+ if (typeof module === 'string') {
+ throw new TypeError(
+ `Cannot map module ${q(specifier)} to ${q(
+ module,
+ )} in parent compartment`,
+ );
+ } else {
+ const alias = moduleAliases.get(module);
+ if (alias != null) {
+ // Modules from other components.
+ aliases.set(specifier, alias);
+ } else {
+ // TODO create and link a synthetic module instance from the given namespace object.
+ throw ReferenceError(
+ `Cannot map module ${q(
+ specifier,
+ )} because it has no known compartment in this realm`,
+ );
+ }
+ }
+ }
+
privateFields.set(this, {
+ resolveHook,
+ importHook,
+ aliases,
+ moduleRecords,
+ deferredExports,
+ instances,
globalTransforms,
globalObject,
});
@@ -83,12 +150,16 @@ export class Compartment {
}
/**
- * The options are:
- * "x": the source text of a program to execute.
+ * @param {string} source is a JavaScript program grammar construction.
+ * @param {{
+ * endowments: Object<name:string, endowment:any>,
+ * transforms: Array<Transform>,
+ * sloppyGlobalsMode: bool,
+ * }} options.
*/
- evaluate(x, options = {}) {
+ evaluate(source, options = {}) {
// Perform this check first to avoid unecessary sanitizing.
- if (typeof x !== 'string') {
+ if (typeof source !== 'string') {
throw new TypeError('first argument of evaluate() must be a string');
}
@@ -102,13 +173,58 @@ export class Compartment {
const { globalTransforms, globalObject } = privateFields.get(this);
const realmRec = getCurrentRealmRec();
- return performEval(realmRec, x, globalObject, endowments, {
+ return performEval(realmRec, source, globalObject, endowments, {
globalTransforms,
localTransforms,
sloppyGlobalsMode,
});
}
+ module(specifier) {
+ if (typeof specifier !== 'string') {
+ throw new TypeError('first argument of module() must be a string');
+ }
+
+ assertModuleHooks(this);
+
+ const { exportsProxy } = getDeferredExports(
+ this,
+ privateFields.get(this),
+ moduleAliases,
+ specifier,
+ );
+
+ return exportsProxy;
+ }
+
+ async import(specifier) {
+ if (typeof specifier !== 'string') {
+ throw new TypeError('first argument of import() must be a string');
+ }
+
+ assertModuleHooks(this);
+
+ await load(privateFields, moduleAnalyses, this, specifier);
+ const module = this.importNow(specifier);
+ // TODO consider revising the specification to use the term `module`
+ // instead of `namespace` to be consistent with the `module` method,
+ // since this establishes a precedent that the term `module` without any
+ // further qualification denotes the module exports namespace.
+ return { namespace: module };
+ }
+
+ importNow(specifier) {
+ if (typeof specifier !== 'string') {
+ throw new TypeError('first argument of importNow() must be a string');
+ }
+
+ assertModuleHooks(this);
+
+ const moduleInstance = link(privateFields, moduleAliases, this, specifier);
+ moduleInstance.execute();
+ return moduleInstance.exportsProxy;
+ }
+
// eslint-disable-next-line class-methods-use-this
toString() {
return '[object Compartment]';

MODIFY
packages/ses/src/module-link.js
packages/ses/src/module-link.js
@@ -27,7 +27,7 @@ export const link = (
const { moduleRecords } = compartmentPrivateFields.get(compartment);
const moduleRecord = moduleRecords.get(moduleSpecifier);
- if (moduleRecord == null) {
+ if (moduleRecord === undefined) {
throw new ReferenceError(`Missing link to module ${q(moduleSpecifier)}`);
}

MODIFY
packages/ses/test/compartment-instance.test.js
packages/ses/test/compartment-instance.test.js
@@ -38,7 +38,15 @@ test('Compartment instance', t => {
t.deepEqual(Reflect.ownKeys(c), [], 'static properties');
t.deepEqual(
Reflect.ownKeys(Object.getPrototypeOf(c)).sort(),
- ['constructor', 'evaluate', 'global', 'toString'].sort(),
+ [
+ 'constructor',
+ 'evaluate',
+ 'import',
+ 'importNow',
+ 'module',
+ 'global',
+ 'toString',
+ ].sort(),
'prototype properties',
);

MODIFY
packages/ses/test/compartment-prototype.test.js
packages/ses/test/compartment-prototype.test.js
@@ -14,7 +14,15 @@ test('Compartment prototype', t => {
t.deepEqual(
Reflect.ownKeys(Compartment.prototype).sort(),
- ['constructor', 'evaluate', 'global', 'toString'].sort(),
+ [
+ 'constructor',
+ 'evaluate',
+ 'import',
+ 'importNow',
+ 'module',
+ 'global',
+ 'toString',
+ ].sort(),
'prototype properties',
);
});

ADD
packages/ses/test/import-commons.js
+import { ModuleStaticRecord } from '../src/main.js';
+
+// q, to quote strings in error messages.
+const q = JSON.stringify;
+
+// makeStaticRetriever mocks the behavior of a real retriever, like HTTP fetch
+// or a file system fetch function, using an in memory map of sources to file
+// text.
+export const makeStaticRetriever = sources => {
+ return async moduleLocation => {
+ const string = sources[moduleLocation];
+ if (string === undefined) {
+ throw new ReferenceError(
+ `Cannot retrieve module at location ${q(moduleLocation)}.`,
+ );
+ }
+ return string;
+ };
+};
+
+// makeImporter combines a locator and retriever to make an importHook suitable
+// for a Compartment.
+export const makeImporter = (locate, retrieve) => async moduleSpecifier => {
+ const moduleLocation = locate(moduleSpecifier);
+ const string = await retrieve(moduleLocation);
+ return new ModuleStaticRecord(string);
+};

ADD
packages/ses/test/import-gauntlet.test.js
+// These tests exercise all forms of import and export between a pair of
+// modules using a single Compartment.
+
+import tap from 'tap';
+import { Compartment } from '../src/main.js';
+import { resolveNode, makeNodeImporter } from './node.js';
+
+const { test } = tap;
+
+test('import for side effect', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-for-side-effect.js': `
+ // empty
+ `,
+ 'https://example.com/main.js': `
+ import './import-for-side-effect.js';
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ await compartment.import('./main.js');
+
+ t.end();
+});
+
+test('import all from module', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-all-from-me.js': `
+ export const a = 10;
+ export const b = 20;
+ `,
+ 'https://example.com/main.js': `
+ import * as bar from './import-all-from-me.js';
+ export default bar;
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.default.a, 10);
+ t.equal(namespace.default.b, 20);
+
+ t.end();
+});
+
+test('import named exports from me', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-named-exports-from-me.js': `
+ export const fizz = 10;
+ export const buzz = 20;
+ `,
+ 'https://example.com/main.js': `
+ import { fizz, buzz } from './import-named-exports-from-me.js';
+ export default { fizz, buzz };
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.default.fizz, 10);
+ t.equal(namespace.default.buzz, 20);
+
+ t.end();
+});
+
+test('import all from module', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-named-export-and-rename.js': `
+ export const color = 'blue';
+ `,
+ 'https://example.com/main.js': `
+ import { color as colour } from './import-named-export-and-rename.js';
+ export const color = colour;
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.color, 'blue');
+
+ t.end();
+});
+
+test('import and reexport', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-and-reexport-name-from-me.js': `
+ export const qux = 42;
+ `,
+ 'https://example.com/main.js': `
+ export { qux } from './import-and-reexport-name-from-me.js';
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.qux, 42);
+
+ t.end();
+});
+
+test('import and export all', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-and-export-all-from-me.js': `
+ export const alpha = 0;
+ export const omega = 23;
+ `,
+ 'https://example.com/main.js': `
+ export * from './import-and-export-all-from-me.js';
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.alpha, 0);
+ t.equal(namespace.omega, 23);
+
+ t.end();
+});
+
+test('live binding', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/import-live-export.js': `
+ export let quuux = null;
+ // Live binding of an exported variable.
+ quuux = 'Hello, World!';
+ `,
+ 'https://example.com/main.js': `
+ import { quuux } from './import-live-export.js';
+ export default quuux;
+ `,
+ });
+
+ const compartment = new Compartment(
+ {},
+ {},
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.default, 'Hello, World!');
+});

ADD
packages/ses/test/import.test.js
+// These tests exercise the Compartment import interface and linkage
+// between compartments, and Compartment endowments.
+
+import tap from 'tap';
+import { Compartment } from '../src/main.js';
+import { resolveNode, makeNodeImporter } from './node.js';
+import { makeImporter, makeStaticRetriever } from './import-commons.js';
+
+const { test } = tap;
+
+// This test demonstrates a system of modules in a single Compartment
+// that uses fully qualified URLs as module specifiers and module locations,
+// not distinguishing one from the other.
+test('import within one compartment, web resolution', async t => {
+ const retrieve = makeStaticRetriever({
+ 'https://example.com/packages/example/half.js': `
+ export default 21;
+ `,
+ 'https://example.com/packages/example/': `
+ import half from 'half.js';
+ export const meaning = double(half);
+ `,
+ });
+ const locate = moduleSpecifier => moduleSpecifier;
+ const resolveHook = (spec, referrer) => new URL(spec, referrer).toString();
+ const importHook = makeImporter(locate, retrieve);
+
+ const compartment = new Compartment(
+ // endowments:
+ {
+ double: n => n * 2,
+ },
+ // module map:
+ {},
+ // options:
+ {
+ resolveHook,
+ importHook,
+ },
+ );
+
+ const { namespace } = await compartment.import(
+ 'https://example.com/packages/example/',
+ );
+
+ t.equal(namespace.meaning, 42, 'dynamically imports the meaning');
+});
+
+// This case demonstrates the same arrangement except that the Compartment uses
+// Node.js module specifier resolution.
+test('import within one compartment, node resolution', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/packages/example/half.js': `
+ export default 21;
+ `,
+ 'https://example.com/packages/example/main.js': `
+ import half from './half.js';
+ export const meaning = double(half);
+ `,
+ });
+
+ const compartment = new Compartment(
+ // endowments:
+ {
+ double: n => n * 2,
+ },
+ // module map:
+ {},
+ // options:
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/example'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.meaning, 42, 'dynamically imports the meaning');
+});
+
+// This demonstrates a pair of linked Node.js compartments.
+test('two compartments, three modules, one endowment', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/packages/example/half.js': `
+ if (typeof double !== 'undefined') {
+ throw new Error('Unexpected leakage of double(n) endowment: ' + typeof double);
+ }
+ export default 21;
+ `,
+ 'https://example.com/packages/example/main.js': `
+ import half from './half.js';
+ import double from 'double';
+ export const meaning = double(half);
+ `,
+ 'https://example.com/packages/double/main.js': `
+ export default double;
+ `,
+ });
+
+ const doubleCompartment = new Compartment(
+ // endowments:
+ {
+ double: n => n * 2,
+ },
+ // module map:
+ {},
+ // options:
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/double'),
+ },
+ );
+
+ const compartment = new Compartment(
+ // endowments:
+ {},
+ // module map:
+ {
+ // Notably, this is the first case where we thread a depencency between
+ // two compartments, using the sigil of one's namespace to indicate
+ // linkage before the module has been loaded.
+ double: doubleCompartment.module('./main.js'),
+ },
+ // options:
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/example'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.equal(namespace.meaning, 42, 'dynamically imports the meaning');
+});
+
+test('module exports namespace as an object', async t => {
+ t.plan(6);
+
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/packages/meaning/main.js': `
+ export const meaning = 42;
+ `,
+ });
+
+ const compartment = new Compartment(
+ // endowments:
+ {},
+ // module map:
+ {},
+ // options:
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/meaning'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+
+ t.throws(() => {
+ namespace.alternateMeaning = 10;
+ }, /^Cannot set property/);
+
+ // The first should not throw.
+ t.ok(Reflect.preventExtensions(namespace), 'extensions must be preventable');
+ // The second should agree.
+ t.ok(
+ Reflect.preventExtensions(namespace),
+ 'preventing extensions must be idempotent',
+ );
+
+ const desc = Object.getOwnPropertyDescriptor(namespace, 'meaning');
+ t.equals(
+ typeof desc,
+ 'object',
+ 'property descriptor for defined export must be an object',
+ );
+ t.equals(desc.set, undefined, 'constant export must not be writeable');
+
+ t.equal(
+ Object.getPrototypeOf(namespace),
+ null,
+ 'module exports namespace prototype must be null',
+ );
+});
+
+test('modules are memoized', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/packages/example/c-s-lewis.js': `
+ export const entity = {};
+ `,
+ 'https://example.com/packages/example/clive-hamilton.js': `
+ import { entity } from './c-s-lewis.js';
+ export default entity;
+ `,
+ 'https://example.com/packages/example/n-w-clerk.js': `
+ import { entity } from './c-s-lewis.js';
+ export default entity;
+ `,
+ 'https://example.com/packages/example/main.js': `
+ import clive from './clive-hamilton.js';
+ import clerk from './n-w-clerk.js';
+ export default { clerk, clive };
+ `,
+ });
+
+ const compartment = new Compartment(
+ // endowments:
+ {},
+ // module map:
+ {},
+ // options:
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/example'),
+ },
+ );
+
+ const { namespace } = await compartment.import('./main.js');
+ const { clive, clerk } = namespace;
+
+ t.ok(clive === clerk, 'diamond dependency must refer to the same module');
+ t.end();
+});
+
+test('compartments with same sources do not share instances', async t => {
+ const makeImportHook = makeNodeImporter({
+ 'https://example.com/packages/arm/main.js': `
+ export default {};
+ `,
+ });
+
+ const leftCompartment = new Compartment(
+ {}, // endowments
+ {}, // module map
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/arm'),
+ },
+ );
+
+ const rightCompartment = new Compartment(
+ {}, // endowments
+ {}, // module map
+ {
+ resolveHook: resolveNode,
+ importHook: makeImportHook('https://example.com/packages/arm'),
+ },
+ );
+
+ const [
+ {
+ namespace: { default: leftArm },
+ },
+ {
+ namespace: { default: rightArm },
+ },
+ ] = await Promise.all([
+ leftCompartment.import('./main.js'),
+ rightCompartment.import('./main.js'),
+ ]);
+
+ t.ok(
+ leftArm !== rightArm,
+ 'different compartments with same sources do not share instances',
+ );
+ t.end();
+});

MODIFY
packages/ses/test/node.js
packages/ses/test/node.js
// Module node.js provides resolve and locate hooks that follow a subset of
// Node.js module semantics.
+import { makeStaticRetriever, makeImporter } from './import-commons.js';
+
const q = JSON.stringify;
const isRelative = spec =>
@@ -9,7 +11,7 @@ const isRelative = spec =>
spec === '.' ||
spec === '..';
-export const resolve = (spec, referrer) => {
+export const resolveNode = (spec, referrer) => {
spec = String(spec || '');
referrer = String(referrer || '');
@@ -60,3 +62,11 @@ export const makeLocator = root => {
return new URL(spec, root).toString();
};
};
+
+// makeNodeImporter conveniently curries makeImporter with a Node.js style
+// locator and static file retriever.
+export const makeNodeImporter = sources => compartmentLocation => {
+ const locate = makeLocator(compartmentLocation);
+ const retrieve = makeStaticRetriever(sources);
+ return makeImporter(locate, retrieve);
+};

MODIFY
packages/ses/test/node.test.js
packages/ses/test/node.test.js
import tap from 'tap';
-import { resolve } from './node.js';
+import { resolveNode as resolve } from './node.js';
const { test } = tap;

Add import, importNow, and module methods to Compartment (#269)