Skip to content

Commit c31a862

Browse files
committed
feat(compartment-mapper): allow force-loading of compartments
This adds option `forceLoad` to `captureFromMap()`. This is an optional `string[]` of keys of the `CompartmentMapDescriptor` also provided to `captureFromMap()`. After loading the entry Compartment and attenuators Compartment, the Compartments having names (practically speaking, these will be _locations_) mentioned in `forceLoad` will then be loaded _if they were not already_. This option can be used to support dynamic requires and imports which would otherwise be omitted from the captured `CompartmentMapDescriptor` via digestion. * * * - Refactored `capture-lite.js`; stuffed all of the Compartment-loading business into a function. - Added test and fixture that shows how a `Compartment` which would otherwise be omitted from the captured Compartment Map is included when used with `forceLoad`.
1 parent 4ff7b9e commit c31a862

File tree

8 files changed

+284
-22
lines changed

8 files changed

+284
-22
lines changed

packages/compartment-mapper/src/capture-lite.js

Lines changed: 178 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,25 +36,31 @@
3636
* CaptureLiteOptions,
3737
* CaptureResult,
3838
* CompartmentMapDescriptor,
39+
* ForceLoadOption,
40+
* LogFn,
41+
* LogOptions,
42+
* PolicyOption,
3943
* ReadFn,
4044
* ReadPowers,
4145
* Sources,
4246
* } from './types.js'
4347
*/
4448

49+
import { digestCompartmentMap } from './digest.js';
4550
import {
4651
exitModuleImportHookMaker,
4752
makeImportHookMaker,
4853
} from './import-hook.js';
4954
import { link } from './link.js';
5055
import { resolve } from './node-module-specifier.js';
56+
import { ATTENUATORS_COMPARTMENT } from './policy-format.js';
5157
import { detectAttenuators } from './policy.js';
5258
import { unpackReadPowers } from './powers.js';
53-
import { digestCompartmentMap } from './digest.js';
5459

55-
const { freeze, assign, create } = Object;
60+
const { freeze, assign, create, keys } = Object;
61+
const { stringify: q } = JSON;
5662

57-
const defaultCompartment = Compartment;
63+
const DefaultCompartment = Compartment;
5864

5965
/**
6066
* @param {CompartmentMapDescriptor} compartmentMap
@@ -79,12 +85,156 @@ const captureCompartmentMap = (compartmentMap, sources) => {
7985
};
8086

8187
/**
82-
* @param {ReadFn | ReadPowers} powers
88+
* @type {LogFn}
89+
*/
90+
const noop = () => {};
91+
92+
/**
93+
* Factory for a function that loads compartments.
94+
*
95+
* @param {CompartmentMapDescriptor} compartmentMap Compartment map
96+
* @param {Sources} sources Sources
97+
* @param {LogOptions & PolicyOption & ForceLoadOption} [options]
98+
* @returns {(linkedCompartments: Record<string, Compartment>, entryCompartment: Compartment, attenuatorsCompartment: Compartment) => Promise<void>}
99+
*/
100+
const makeLoadCompartments = (
101+
compartmentMap,
102+
sources,
103+
{ log = noop, policy, forceLoad = [] } = {},
104+
) => {
105+
const {
106+
entry: { module: entryModuleSpecifier },
107+
} = compartmentMap;
108+
109+
/**
110+
* Given {@link CompartmentDescriptor CompartmentDescriptors}, loads any which a) are present in the {@link forceLoad forceLoad array}, and b) have not yet been loaded.
111+
*
112+
* Will not load the "attenuators" `Compartment`, nor will it load any
113+
* `Compartment` having a non-empty value in `sources` (since it is presumed it
114+
* has already been loaded).
115+
*
116+
* @param {Record<string, Compartment>} compartments
117+
* @returns {Promise<void>} Resolves when all appropriate compartments are loaded.
118+
*/
119+
const forceLoadCompartments = async compartments => {
120+
const compartmentsToLoad = forceLoad.reduce((acc, compartmentName) => {
121+
// skip; should already be loaded
122+
if (
123+
compartmentName === ATTENUATORS_COMPARTMENT ||
124+
compartmentName === compartmentMap.entry.compartment
125+
) {
126+
return acc;
127+
}
128+
129+
const compartmentDescriptor =
130+
compartmentMap.compartments[compartmentName];
131+
132+
if (!compartmentDescriptor) {
133+
throw ReferenceError(
134+
`Failed attempting to force-load unknown compartment ${q(compartmentName)}`,
135+
);
136+
}
137+
138+
const compartmentSources = sources[compartmentName];
139+
140+
if (keys(compartmentSources).length) {
141+
log(
142+
`Refusing to force-load Compartment ${q(compartmentName)}; already loaded`,
143+
);
144+
return acc;
145+
}
146+
147+
const compartment = compartments[compartmentName];
148+
if (!compartment) {
149+
throw ReferenceError(`No compartment found for ${q(compartmentName)}`);
150+
}
151+
const compartmentOwnModuleDescriptor =
152+
compartmentDescriptor.modules[compartmentDescriptor.name];
153+
154+
if (!compartmentOwnModuleDescriptor?.module) {
155+
throw Error(`Cannot determine entry point of ${q(compartmentName)}`);
156+
}
157+
acc.push([
158+
compartmentName,
159+
compartment,
160+
compartmentOwnModuleDescriptor.module,
161+
]);
162+
163+
// could delete the `load` flag here, but it gets dropped during
164+
// capture anyway
165+
return acc;
166+
}, /** @type {[compartmentName: string, compartment: Compartment, moduleSpecifier: string][]} */ ([]));
167+
168+
const { length: compartmentsToLoadCount } = compartmentsToLoad;
169+
/**
170+
* This index increments in the order in which compartments finish
171+
* loading—_not_ the order in which they began loading.
172+
*/
173+
let loadedCompartmentIndex = 0;
174+
await Promise.all(
175+
compartmentsToLoad.map(
176+
async ([compartmentName, compartment, moduleSpecifier]) => {
177+
await compartment.load(moduleSpecifier);
178+
log(
179+
`Force-loaded Compartment: ${q(compartmentName)} (${(loadedCompartmentIndex += 1)}/${compartmentsToLoadCount})`,
180+
);
181+
},
182+
),
183+
);
184+
};
185+
186+
/**
187+
* Loads, in order:
188+
*
189+
* 1. The entry compartment
190+
* 2. The attenuators compartment (_if and only if_ `policy` was provided)
191+
* 3. All compartments in the `compartmentMap` that have the `load` bit set.
192+
*
193+
* @param {Record<string, Compartment>} linkedCompartments
194+
* @param {Compartment} entryCompartment
195+
* @param {Compartment} attenuatorsCompartment
196+
* @returns {Promise<void>} Resolves when all compartments are loaded.
197+
*/
198+
const loadCompartments = async (
199+
linkedCompartments,
200+
entryCompartment,
201+
attenuatorsCompartment,
202+
) => {
203+
await entryCompartment.load(entryModuleSpecifier);
204+
205+
if (policy) {
206+
// retain all attenuators.
207+
await Promise.all(
208+
detectAttenuators(policy).map(attenuatorSpecifier =>
209+
attenuatorsCompartment.load(attenuatorSpecifier),
210+
),
211+
);
212+
}
213+
214+
await forceLoadCompartments(linkedCompartments);
215+
};
216+
217+
return loadCompartments;
218+
};
219+
220+
/**
221+
* "Captures" the compartment map descriptors and sources from a partially
222+
* completed compartment map—_without_ creating an archive.
223+
*
224+
* The resulting compartment map represents a well-formed dependency graph,
225+
* laden with useful metadata. This, for example, could be used for automatic
226+
* policy generation.
227+
*
228+
* @param {ReadFn | ReadPowers} readPowers Powers
83229
* @param {CompartmentMapDescriptor} compartmentMap
84230
* @param {CaptureLiteOptions} [options]
85231
* @returns {Promise<CaptureResult>}
86232
*/
87-
export const captureFromMap = async (powers, compartmentMap, options = {}) => {
233+
export const captureFromMap = async (
234+
readPowers,
235+
compartmentMap,
236+
options = {},
237+
) => {
88238
const {
89239
moduleTransforms,
90240
syncModuleTransforms,
@@ -94,14 +244,15 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
94244
policy = undefined,
95245
sourceMapHook = undefined,
96246
parserForLanguage: parserForLanguageOption = {},
97-
Compartment = defaultCompartment,
247+
Compartment: CompartmentOption = DefaultCompartment,
248+
log = noop,
249+
forceLoad = [],
98250
} = options;
99-
100251
const parserForLanguage = freeze(
101252
assign(create(null), parserForLanguageOption),
102253
);
103254

104-
const { read, computeSha512 } = unpackReadPowers(powers);
255+
const { read, computeSha512 } = unpackReadPowers(readPowers);
105256

106257
const {
107258
compartments,
@@ -111,6 +262,12 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
111262
/** @type {Sources} */
112263
const sources = Object.create(null);
113264

265+
const loadCompartments = makeLoadCompartments(compartmentMap, sources, {
266+
log,
267+
policy,
268+
forceLoad,
269+
});
270+
114271
const consolidatedExitModuleImportHook = exitModuleImportHookMaker({
115272
modules: exitModules,
116273
exitModuleImportHook,
@@ -128,25 +285,27 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
128285
importHook: consolidatedExitModuleImportHook,
129286
sourceMapHook,
130287
});
288+
131289
// Induce importHook to record all the necessary modules to import the given module specifier.
132-
const { compartment, attenuatorsCompartment } = link(compartmentMap, {
290+
const {
291+
compartment: entryCompartment,
292+
compartments: linkedCompartments,
293+
attenuatorsCompartment,
294+
} = link(compartmentMap, {
133295
resolve,
134296
makeImportHook,
135297
moduleTransforms,
136298
syncModuleTransforms,
137299
parserForLanguage,
138300
archiveOnly: true,
139-
Compartment,
301+
Compartment: CompartmentOption,
140302
});
141-
await compartment.load(entryModuleSpecifier);
142-
if (policy) {
143-
// retain all attenuators.
144-
await Promise.all(
145-
detectAttenuators(policy).map(attenuatorSpecifier =>
146-
attenuatorsCompartment.load(attenuatorSpecifier),
147-
),
148-
);
149-
}
303+
304+
await loadCompartments(
305+
linkedCompartments,
306+
entryCompartment,
307+
attenuatorsCompartment,
308+
);
150309

151310
return captureCompartmentMap(compartmentMap, sources);
152311
};

packages/compartment-mapper/src/types/external.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,18 @@ export type CompartmentMapForNodeModulesOptions = Omit<
135135
'conditions' | 'tags'
136136
>;
137137

138+
/**
139+
* Options for `captureFromMap()`
140+
*/
138141
export type CaptureLiteOptions = ImportingOptions &
139142
LinkingOptions &
140143
PolicyOption &
141-
LogOptions;
144+
LogOptions &
145+
ForceLoadOption;
146+
147+
export interface ForceLoadOption {
148+
forceLoad?: Array<string>;
149+
}
142150

143151
export type ArchiveLiteOptions = SyncOrAsyncArchiveOptions &
144152
ModuleTransformsOption &

packages/compartment-mapper/test/capture-lite.test.js

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'ses';
2+
23
import fs from 'node:fs';
34
import url from 'node:url';
45
import path from 'node:path';
@@ -10,7 +11,7 @@ import { defaultParserForLanguage } from '../src/import-parsers.js';
1011

1112
const { keys } = Object;
1213

13-
test('captureFromMap() should resolve with a CaptureResult', async t => {
14+
test('captureFromMap() - should resolve with a CaptureResult', async t => {
1415
t.plan(5);
1516

1617
const readPowers = makeReadPowers({ fs, url });
@@ -60,7 +61,73 @@ test('captureFromMap() should resolve with a CaptureResult', async t => {
6061
);
6162
});
6263

63-
test('captureFromMap() should round-trip sources based on parsers', async t => {
64+
test('captureFromMap() - should discard unretained CompartmentDescriptors', async t => {
65+
const readPowers = makeReadPowers({ fs, url });
66+
const moduleLocation = `${new URL(
67+
'fixtures-digest/node_modules/app/index.js',
68+
import.meta.url,
69+
)}`;
70+
71+
const nodeCompartmentMap = await mapNodeModules(readPowers, moduleLocation);
72+
73+
const nodeComartmentMapSize = keys(nodeCompartmentMap.compartments).length;
74+
75+
const { captureCompartmentMap } = await captureFromMap(
76+
readPowers,
77+
nodeCompartmentMap,
78+
{
79+
parserForLanguage: defaultParserForLanguage,
80+
},
81+
);
82+
83+
const captureCompartmentMapSize = keys(
84+
captureCompartmentMap.compartments,
85+
).length;
86+
87+
t.true(
88+
captureCompartmentMapSize < nodeComartmentMapSize,
89+
'captureCompartmentMap should contain fewer CompartmentDescriptors than nodeCompartmentMap',
90+
);
91+
92+
t.false(
93+
'fjord-v1.0.0' in captureCompartmentMap.compartments,
94+
'"fjord-v1.0.0" should not be retained in captureCompartmentMap',
95+
);
96+
});
97+
98+
test('captureFromMap() - should force-load', async t => {
99+
const readPowers = makeReadPowers({ fs, url });
100+
const moduleLocation = `${new URL(
101+
'fixtures-digest/node_modules/app/index.js',
102+
import.meta.url,
103+
)}`;
104+
105+
const nodeCompartmentMap = await mapNodeModules(readPowers, moduleLocation);
106+
107+
const fjordCompartment = Object.values(nodeCompartmentMap.compartments).find(
108+
c => c.name === 'fjord',
109+
);
110+
if (!fjordCompartment) {
111+
t.fail('Expected "fjord" compartment to be present in nodeCompartmentMap');
112+
return;
113+
}
114+
115+
const { captureCompartmentMap } = await captureFromMap(
116+
readPowers,
117+
nodeCompartmentMap,
118+
{
119+
forceLoad: [fjordCompartment.location],
120+
parserForLanguage: defaultParserForLanguage,
121+
},
122+
);
123+
124+
t.true(
125+
'fjord-v1.0.0' in captureCompartmentMap.compartments,
126+
'"fjord-v1.0.0" should be retained in captureCompartmentMap',
127+
);
128+
});
129+
130+
test('captureFromMap() - should round-trip sources based on parsers', async t => {
64131
const readPowers = makeReadPowers({ fs, url });
65132
const moduleLocation = `${new URL(
66133
'fixtures-0/node_modules/bundle/main.js',
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
This fixture illustrates a case where `mapNodeModules()` should return a `CompartmentMapDescriptor` having more compartments than the result of passing it thru `captureFromMap()`.

packages/compartment-mapper/test/fixtures-digest/node_modules/app/index.js

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compartment-mapper/test/fixtures-digest/node_modules/app/package.json

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/compartment-mapper/test/fixtures-digest/node_modules/fjord/index.js

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)