Skip to content

Commit e808d60

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 e808d60

File tree

8 files changed

+288
-22
lines changed

8 files changed

+288
-22
lines changed

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

Lines changed: 182 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,160 @@ 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 new 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 new ReferenceError(
150+
`No compartment found for ${q(compartmentName)}`,
151+
);
152+
}
153+
const compartmentOwnModuleDescriptor =
154+
compartmentDescriptor.modules[compartmentDescriptor.name];
155+
156+
if (!compartmentOwnModuleDescriptor?.module) {
157+
throw new Error(
158+
`Cannot determine entry point of ${q(compartmentName)}`,
159+
);
160+
}
161+
acc.push([
162+
compartmentName,
163+
compartment,
164+
compartmentOwnModuleDescriptor.module,
165+
]);
166+
167+
// could delete the `load` flag here, but it gets dropped during
168+
// capture anyway
169+
return acc;
170+
}, /** @type {[compartmentName: string, compartment: Compartment, moduleSpecifier: string][]} */ ([]));
171+
172+
const { length: compartmentsToLoadCount } = compartmentsToLoad;
173+
/**
174+
* This index increments in the order in which compartments finish
175+
* loading—_not_ the order in which they began loading.
176+
*/
177+
let loadedCompartmentIndex = 0;
178+
await Promise.all(
179+
compartmentsToLoad.map(
180+
async ([compartmentName, compartment, moduleSpecifier]) => {
181+
await compartment.load(moduleSpecifier);
182+
log(
183+
`Force-loaded Compartment: ${q(compartmentName)} (${(loadedCompartmentIndex += 1)}/${compartmentsToLoadCount})`,
184+
);
185+
},
186+
),
187+
);
188+
};
189+
190+
/**
191+
* Loads, in order:
192+
*
193+
* 1. The entry compartment
194+
* 2. The attenuators compartment (_if and only if_ `policy` was provided)
195+
* 3. All compartments in the `compartmentMap` that have the `load` bit set.
196+
*
197+
* @param {Record<string, Compartment>} linkedCompartments
198+
* @param {Compartment} entryCompartment
199+
* @param {Compartment} attenuatorsCompartment
200+
* @returns {Promise<void>} Resolves when all compartments are loaded.
201+
*/
202+
const loadCompartments = async (
203+
linkedCompartments,
204+
entryCompartment,
205+
attenuatorsCompartment,
206+
) => {
207+
await entryCompartment.load(entryModuleSpecifier);
208+
209+
if (policy) {
210+
// retain all attenuators.
211+
await Promise.all(
212+
detectAttenuators(policy).map(attenuatorSpecifier =>
213+
attenuatorsCompartment.load(attenuatorSpecifier),
214+
),
215+
);
216+
}
217+
218+
await forceLoadCompartments(linkedCompartments);
219+
};
220+
221+
return loadCompartments;
222+
};
223+
224+
/**
225+
* "Captures" the compartment map descriptors and sources from a partially
226+
* completed compartment map—_without_ creating an archive.
227+
*
228+
* The resulting compartment map represents a well-formed dependency graph,
229+
* laden with useful metadata. This, for example, could be used for automatic
230+
* policy generation.
231+
*
232+
* @param {ReadFn | ReadPowers} readPowers Powers
83233
* @param {CompartmentMapDescriptor} compartmentMap
84234
* @param {CaptureLiteOptions} [options]
85235
* @returns {Promise<CaptureResult>}
86236
*/
87-
export const captureFromMap = async (powers, compartmentMap, options = {}) => {
237+
export const captureFromMap = async (
238+
readPowers,
239+
compartmentMap,
240+
options = {},
241+
) => {
88242
const {
89243
moduleTransforms,
90244
syncModuleTransforms,
@@ -94,14 +248,15 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
94248
policy = undefined,
95249
sourceMapHook = undefined,
96250
parserForLanguage: parserForLanguageOption = {},
97-
Compartment = defaultCompartment,
251+
Compartment: CompartmentOption = DefaultCompartment,
252+
log = noop,
253+
forceLoad = [],
98254
} = options;
99-
100255
const parserForLanguage = freeze(
101256
assign(create(null), parserForLanguageOption),
102257
);
103258

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

106261
const {
107262
compartments,
@@ -111,6 +266,12 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
111266
/** @type {Sources} */
112267
const sources = Object.create(null);
113268

269+
const loadCompartments = makeLoadCompartments(compartmentMap, sources, {
270+
log,
271+
policy,
272+
forceLoad,
273+
});
274+
114275
const consolidatedExitModuleImportHook = exitModuleImportHookMaker({
115276
modules: exitModules,
116277
exitModuleImportHook,
@@ -128,25 +289,27 @@ export const captureFromMap = async (powers, compartmentMap, options = {}) => {
128289
importHook: consolidatedExitModuleImportHook,
129290
sourceMapHook,
130291
});
292+
131293
// Induce importHook to record all the necessary modules to import the given module specifier.
132-
const { compartment, attenuatorsCompartment } = link(compartmentMap, {
294+
const {
295+
compartment: entryCompartment,
296+
compartments: linkedCompartments,
297+
attenuatorsCompartment,
298+
} = link(compartmentMap, {
133299
resolve,
134300
makeImportHook,
135301
moduleTransforms,
136302
syncModuleTransforms,
137303
parserForLanguage,
138304
archiveOnly: true,
139-
Compartment,
305+
Compartment: CompartmentOption,
140306
});
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-
}
307+
308+
await loadCompartments(
309+
linkedCompartments,
310+
entryCompartment,
311+
attenuatorsCompartment,
312+
);
150313

151314
return captureCompartmentMap(compartmentMap, sources);
152315
};

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.

0 commit comments

Comments
 (0)