Skip to content

Commit dc25949

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 0091e93 commit dc25949

File tree

8 files changed

+297
-22
lines changed

8 files changed

+297
-22
lines changed

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

Lines changed: 183 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,161 @@ 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
111+
* a) are present in the {@link forceLoad forceLoad array}, and b) have not
112+
* yet been loaded.
113+
*
114+
* Will not load the "attenuators" `Compartment`, nor will it load any
115+
* `Compartment` having a non-empty value in `sources` (since it is presumed
116+
* it has already been loaded).
117+
*
118+
* @param {Record<string, Compartment>} compartments
119+
* @returns {Promise<void>} Resolves when all appropriate compartments are
120+
* loaded.
121+
*/
122+
const forceLoadCompartments = async compartments => {
123+
const compartmentsToLoad = forceLoad.reduce((acc, compartmentName) => {
124+
// skip; should already be loaded
125+
if (
126+
compartmentName === ATTENUATORS_COMPARTMENT ||
127+
compartmentName === compartmentMap.entry.compartment
128+
) {
129+
return acc;
130+
}
131+
132+
const compartmentDescriptor =
133+
compartmentMap.compartments[compartmentName];
134+
135+
if (!compartmentDescriptor) {
136+
throw new ReferenceError(
137+
`Failed attempting to force-load unknown compartment ${q(compartmentName)}`,
138+
);
139+
}
140+
141+
const compartmentSources = sources[compartmentName];
142+
143+
if (keys(compartmentSources).length) {
144+
log(
145+
`Refusing to force-load Compartment ${q(compartmentName)}; already loaded`,
146+
);
147+
return acc;
148+
}
149+
150+
const compartment = compartments[compartmentName];
151+
if (!compartment) {
152+
throw new ReferenceError(
153+
`No compartment found for ${q(compartmentName)}`,
154+
);
155+
}
156+
const compartmentOwnModuleDescriptor =
157+
compartmentDescriptor.modules[compartmentDescriptor.name];
158+
159+
if (!compartmentOwnModuleDescriptor?.module) {
160+
throw new Error(
161+
`Cannot determine entry point of ${q(compartmentName)}`,
162+
);
163+
}
164+
acc.push([
165+
compartmentName,
166+
compartment,
167+
compartmentOwnModuleDescriptor.module,
168+
]);
169+
170+
return acc;
171+
}, /** @type {[compartmentName: string, compartment: Compartment, moduleSpecifier: string][]} */ ([]));
172+
173+
const { length: compartmentsToLoadCount } = compartmentsToLoad;
174+
/**
175+
* This index increments in the order in which compartments finish
176+
* loading—_not_ the order in which they began loading.
177+
*/
178+
let loadedCompartmentIndex = 0;
179+
await Promise.all(
180+
compartmentsToLoad.map(
181+
async ([compartmentName, compartment, moduleSpecifier]) => {
182+
await compartment.load(moduleSpecifier);
183+
log(
184+
`Force-loaded Compartment: ${q(compartmentName)} (${(loadedCompartmentIndex += 1)}/${compartmentsToLoadCount})`,
185+
);
186+
},
187+
),
188+
);
189+
};
190+
191+
/**
192+
* Loads, in order:
193+
*
194+
* 1. The entry compartment
195+
* 2. The attenuators compartment (_if and only if_ `policy` was provided)
196+
* 3. All compartments in the `compartmentMap` that have the `load` bit set.
197+
*
198+
* @param {Record<string, Compartment>} linkedCompartments
199+
* @param {Compartment} entryCompartment
200+
* @param {Compartment} attenuatorsCompartment
201+
* @returns {Promise<void>} Resolves when all compartments are loaded.
202+
*/
203+
const loadCompartments = async (
204+
linkedCompartments,
205+
entryCompartment,
206+
attenuatorsCompartment,
207+
) => {
208+
await entryCompartment.load(entryModuleSpecifier);
209+
210+
if (policy) {
211+
// retain all attenuators.
212+
await Promise.all(
213+
detectAttenuators(policy).map(attenuatorSpecifier =>
214+
attenuatorsCompartment.load(attenuatorSpecifier),
215+
),
216+
);
217+
}
218+
219+
await forceLoadCompartments(linkedCompartments);
220+
};
221+
222+
return loadCompartments;
223+
};
224+
225+
/**
226+
* "Captures" the compartment map descriptors and sources from a partially
227+
* completed compartment map—_without_ creating an archive.
228+
*
229+
* The resulting compartment map represents a well-formed dependency graph,
230+
* laden with useful metadata. This, for example, could be used for automatic
231+
* policy generation.
232+
*
233+
* @param {ReadFn | ReadPowers} readPowers Powers
83234
* @param {CompartmentMapDescriptor} compartmentMap
84235
* @param {CaptureLiteOptions} [options]
85236
* @returns {Promise<CaptureResult>}
86237
*/
87-
export const captureFromMap = async (powers, compartmentMap, options = {}) => {
238+
export const captureFromMap = async (
239+
readPowers,
240+
compartmentMap,
241+
options = {},
242+
) => {
88243
const {
89244
moduleTransforms,
90245
syncModuleTransforms,
@@ -94,14 +249,15 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
94249
policy = undefined,
95250
sourceMapHook = undefined,
96251
parserForLanguage: parserForLanguageOption = {},
97-
Compartment = defaultCompartment,
252+
Compartment: CompartmentOption = DefaultCompartment,
253+
log = noop,
254+
forceLoad = [],
98255
} = options;
99-
100256
const parserForLanguage = freeze(
101257
assign(create(null), parserForLanguageOption),
102258
);
103259

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

106262
const {
107263
compartments,
@@ -111,6 +267,12 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
111267
/** @type {Sources} */
112268
const sources = Object.create(null);
113269

270+
const loadCompartments = makeLoadCompartments(compartmentMap, sources, {
271+
log,
272+
policy,
273+
forceLoad,
274+
});
275+
114276
const consolidatedExitModuleImportHook = exitModuleImportHookMaker({
115277
modules: exitModules,
116278
exitModuleImportHook,
@@ -128,25 +290,27 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
128290
importHook: consolidatedExitModuleImportHook,
129291
sourceMapHook,
130292
});
293+
131294
// Induce importHook to record all the necessary modules to import the given module specifier.
132-
const { compartment, attenuatorsCompartment } = link(compartmentMap, {
295+
const {
296+
compartment: entryCompartment,
297+
compartments: linkedCompartments,
298+
attenuatorsCompartment,
299+
} = link(compartmentMap, {
133300
resolve,
134301
makeImportHook,
135302
moduleTransforms,
136303
syncModuleTransforms,
137304
parserForLanguage,
138305
archiveOnly: true,
139-
Compartment,
306+
Compartment: CompartmentOption,
140307
});
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-
}
308+
309+
await loadCompartments(
310+
linkedCompartments,
311+
entryCompartment,
312+
attenuatorsCompartment,
313+
);
150314

151315
return captureCompartmentMap(compartmentMap, sources);
152316
};

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,10 +135,26 @@ 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+
/**
148+
* Options bag containing a `forceLoad` array.
149+
*/
150+
export interface ForceLoadOption {
151+
/**
152+
* List of compartment names (the keys of
153+
* {@link CompartmentMapDescriptor.compartments}) to force-load _after_ the
154+
* entry compartment and any attenuators.
155+
*/
156+
forceLoad?: Array<string>;
157+
}
142158

143159
export type ArchiveLiteOptions = SyncOrAsyncArchiveOptions &
144160
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.

0 commit comments

Comments
 (0)