Skip to content

Commit f845665

Browse files
boneskullkriskowal
andauthored
expose captureFromMap() (#2308)
## Description This exposes `captureFromMap()` in `capture-lite.js`. This function is similar to e.g., `makeArchiveFromMap()` in `archive-lite.js`; but rather than creating a `.zip` archive, it simply returns the fully-completed `CompartmentMapDescriptor`, `Sources`, and a mapping of filename to compartment map name. This information is needed for next-gen-lavamoat-node ("endomoat")'s automatic policy generation. Another commit disables the hardcoded check for parsers in the compartment map validation functions (which are no longer necessary after #2304). ### Questions - Should this be split into two PRs? - Should any of this be renamed? - Internal functions were copy/pasted from `archive-lite.js` into `capture-lite.js`. Should these be extracted into a shared module? ### Security Considerations None that I'm aware of. ### Scaling Considerations If anything, it may shave a few nanoseconds off of compartment map validation. ### Documentation Considerations Probably should be added to `NEWS.md`. ### Testing Considerations - [ ] The compartment map validation is currently not tested in isolation and probably should be (removing the parser-name assertion did not cause a test to fail) - [x] `captureFromMap()` needs some sort of basic round-trip test. I think a snapshot of the return value may suffice? ### Compatibility Considerations None ### Upgrade Considerations None --------- Co-authored-by: Kris Kowal <[email protected]>
1 parent 0c0b93b commit f845665

File tree

6 files changed

+448
-28
lines changed

6 files changed

+448
-28
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { captureFromMap } from './src/capture-lite.js';

packages/compartment-mapper/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"./archive.js": "./archive.js",
3434
"./archive-lite.js": "./archive-lite.js",
3535
"./archive-parsers.js": "./archive-parsers.js",
36+
"./capture-lite.js": "./capture-lite.js",
3637
"./import-archive.js": "./import-archive.js",
3738
"./import-archive-lite.js": "./import-archive-lite.js",
3839
"./import-archive-parsers.js": "./import-archive-parsers.js",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/**
2+
* This module provides {@link captureFromMap}, which only "captures" the
3+
* compartment map descriptors and sources from a partially completed
4+
* compartment map--_without_ creating an archive. The resulting compartment map
5+
* represents a well-formed dependency graph, laden with useful metadata. This,
6+
* for example, could be used for automatic policy generation.
7+
*
8+
* The resulting data structure ({@link CaptureResult}) contains a
9+
* mapping of filepaths to compartment map names.
10+
*
11+
* These functions do not have a bias for any particular mapping, so you will
12+
* need to use `mapNodeModules` from `@endo/compartment-map/node-modules.js` or
13+
* a similar device to construct one. The default `parserForLanguage` mapping is
14+
* empty. You will need to provide the `defaultParserForLanguage` from
15+
* `@endo/compartment-mapper/import-parsers.js` or
16+
* `@endo/compartment-mapper/archive-parsers.js`.
17+
*
18+
* If you use `@endo/compartment-mapper/archive-parsers.js`, the archive will
19+
* contain pre-compiled ESM and CJS modules wrapped in a JSON envelope, suitable
20+
* for use with the SES shim in any environment including a web page, without a
21+
* client-side dependency on Babel.
22+
*
23+
* If you use `@endo/compartment-mapper/import-parsers.js`, the archive will
24+
* contain original sources, so to import the archive with
25+
* `src/import-archive-lite.js`, you will need to provide the archive parsers
26+
* and entrain a runtime dependency on Babel.
27+
*
28+
* @module
29+
*/
30+
31+
// @ts-check
32+
/* eslint no-shadow: 0 */
33+
34+
/** @import {ReadFn} from './types.js' */
35+
/** @import {ReadPowers} from './types.js' */
36+
/** @import {CompartmentMapDescriptor} from './types.js' */
37+
/** @import {CaptureOptions} from './types.js' */
38+
/** @import {Sources} from './types.js' */
39+
/** @import {CompartmentDescriptor} from './types.js' */
40+
/** @import {ModuleDescriptor} from './types.js' */
41+
/** @import {CaptureResult} from './types.js' */
42+
43+
import {
44+
assertCompartmentMap,
45+
pathCompare,
46+
stringCompare,
47+
} from './compartment-map.js';
48+
import {
49+
exitModuleImportHookMaker,
50+
makeImportHookMaker,
51+
} from './import-hook.js';
52+
import { link } from './link.js';
53+
import { resolve } from './node-module-specifier.js';
54+
import { detectAttenuators } from './policy.js';
55+
import { unpackReadPowers } from './powers.js';
56+
57+
const { freeze, assign, create, fromEntries, entries, keys } = Object;
58+
59+
/**
60+
* We attempt to produce compartment maps that are consistent regardless of
61+
* whether the packages were originally laid out on disk for development or
62+
* production, and other trivia like the fully qualified path of a specific
63+
* installation.
64+
*
65+
* Naming compartments for the self-ascribed name and version of each Node.js
66+
* package is insufficient because they are not guaranteed to be unique.
67+
* Dependencies do not necessarilly come from the npm registry and may be
68+
* for example derived from fully qualified URL's or Github org and project
69+
* names.
70+
* Package managers are also not required to fully deduplicate the hard
71+
* copy of each package even when they are identical resources.
72+
* Duplication is undesirable, but we elect to defer that problem to solutions
73+
* in the package managers, as the alternative would be to consistently hash
74+
* the original sources of the packages themselves, which may not even be
75+
* available much less pristine for us.
76+
*
77+
* So, instead, we use the lexically least path of dependency names, delimited
78+
* by hashes.
79+
* The compartment maps generated by the ./node-modules.js tooling pre-compute
80+
* these traces for our use here.
81+
* We sort the compartments lexically on their self-ascribed name and version,
82+
* and use the lexically least dependency name path as a tie-breaker.
83+
* The dependency path is logical and orthogonal to the package manager's
84+
* actual installation location, so should be orthogonal to the vagaries of the
85+
* package manager's deduplication algorithm.
86+
*
87+
* @param {Record<string, CompartmentDescriptor>} compartments
88+
* @returns {Record<string, string>} map from old to new compartment names.
89+
*/
90+
const renameCompartments = compartments => {
91+
/** @type {Record<string, string>} */
92+
const compartmentRenames = create(null);
93+
let index = 0;
94+
let prev = '';
95+
96+
// The sort below combines two comparators to avoid depending on sort
97+
// stability, which became standard as recently as 2019.
98+
// If that date seems quaint, please accept my regards from the distant past.
99+
// We are very proud of you.
100+
const compartmentsByPath = Object.entries(compartments)
101+
.map(([name, compartment]) => ({
102+
name,
103+
path: compartment.path,
104+
label: compartment.label,
105+
}))
106+
.sort((a, b) => {
107+
if (a.label === b.label) {
108+
assert(a.path !== undefined && b.path !== undefined);
109+
return pathCompare(a.path, b.path);
110+
}
111+
return stringCompare(a.label, b.label);
112+
});
113+
114+
for (const { name, label } of compartmentsByPath) {
115+
if (label === prev) {
116+
compartmentRenames[name] = `${label}-n${index}`;
117+
index += 1;
118+
} else {
119+
compartmentRenames[name] = label;
120+
prev = label;
121+
index = 1;
122+
}
123+
}
124+
return compartmentRenames;
125+
};
126+
127+
/**
128+
* @param {Record<string, CompartmentDescriptor>} compartments
129+
* @param {Sources} sources
130+
* @param {Record<string, string>} compartmentRenames
131+
*/
132+
const translateCompartmentMap = (compartments, sources, compartmentRenames) => {
133+
const result = create(null);
134+
for (const compartmentName of keys(compartmentRenames)) {
135+
const compartment = compartments[compartmentName];
136+
const { name, label, retained, policy } = compartment;
137+
if (retained) {
138+
// rename module compartments
139+
/** @type {Record<string, ModuleDescriptor>} */
140+
const modules = create(null);
141+
const compartmentModules = compartment.modules;
142+
if (compartment.modules) {
143+
for (const name of keys(compartmentModules).sort()) {
144+
const module = compartmentModules[name];
145+
if (module.compartment !== undefined) {
146+
modules[name] = {
147+
...module,
148+
compartment: compartmentRenames[module.compartment],
149+
};
150+
} else {
151+
modules[name] = module;
152+
}
153+
}
154+
}
155+
156+
// integrate sources into modules
157+
const compartmentSources = sources[compartmentName];
158+
if (compartmentSources) {
159+
for (const name of keys(compartmentSources).sort()) {
160+
const source = compartmentSources[name];
161+
const { location, parser, exit, sha512, deferredError } = source;
162+
if (location !== undefined) {
163+
modules[name] = {
164+
location,
165+
parser,
166+
sha512,
167+
};
168+
} else if (exit !== undefined) {
169+
modules[name] = {
170+
exit,
171+
};
172+
} else if (deferredError !== undefined) {
173+
modules[name] = {
174+
deferredError,
175+
};
176+
}
177+
}
178+
}
179+
180+
result[compartmentRenames[compartmentName]] = {
181+
name,
182+
label,
183+
location: compartmentRenames[compartmentName],
184+
modules,
185+
policy,
186+
// `scopes`, `types`, and `parsers` are not necessary since every
187+
// loadable module is captured in `modules`.
188+
};
189+
}
190+
}
191+
192+
return result;
193+
};
194+
195+
/**
196+
* @param {Sources} sources
197+
* @param {Record<string, string>} compartmentRenames
198+
* @returns {Sources}
199+
*/
200+
const renameSources = (sources, compartmentRenames) => {
201+
return fromEntries(
202+
entries(sources).map(([name, compartmentSources]) => [
203+
compartmentRenames[name],
204+
compartmentSources,
205+
]),
206+
);
207+
};
208+
209+
/**
210+
* @param {CompartmentMapDescriptor} compartmentMap
211+
* @param {Sources} sources
212+
* @returns {CaptureResult}
213+
*/
214+
const captureCompartmentMap = (compartmentMap, sources) => {
215+
const {
216+
compartments,
217+
entry: { compartment: entryCompartmentName, module: entryModuleSpecifier },
218+
} = compartmentMap;
219+
220+
const compartmentRenames = renameCompartments(compartments);
221+
const captureCompartments = translateCompartmentMap(
222+
compartments,
223+
sources,
224+
compartmentRenames,
225+
);
226+
const captureEntryCompartmentName = compartmentRenames[entryCompartmentName];
227+
const captureSources = renameSources(sources, compartmentRenames);
228+
229+
const captureCompartmentMap = {
230+
tags: [],
231+
entry: {
232+
compartment: captureEntryCompartmentName,
233+
module: entryModuleSpecifier,
234+
},
235+
compartments: captureCompartments,
236+
};
237+
238+
// Cross-check:
239+
// We assert that we have constructed a valid compartment map, not because it
240+
// might not be, but to ensure that the assertCompartmentMap function can
241+
// accept all valid compartment maps.
242+
assertCompartmentMap(captureCompartmentMap);
243+
244+
return {
245+
captureCompartmentMap,
246+
captureSources,
247+
compartmentRenames,
248+
};
249+
};
250+
251+
/**
252+
* @param {ReadFn | ReadPowers} powers
253+
* @param {CompartmentMapDescriptor} compartmentMap
254+
* @param {CaptureOptions} [options]
255+
* @returns {Promise<CaptureResult>}
256+
*/
257+
export const captureFromMap = async (powers, compartmentMap, options = {}) => {
258+
const {
259+
moduleTransforms,
260+
modules: exitModules = {},
261+
searchSuffixes = undefined,
262+
importHook: exitModuleImportHook = undefined,
263+
policy = undefined,
264+
sourceMapHook = undefined,
265+
parserForLanguage: parserForLanguageOption = {},
266+
languageForExtension: languageForExtensionOption = {},
267+
} = options;
268+
269+
const parserForLanguage = freeze(
270+
assign(create(null), parserForLanguageOption),
271+
);
272+
const languageForExtension = freeze(
273+
assign(create(null), languageForExtensionOption),
274+
);
275+
276+
const { read, computeSha512 } = unpackReadPowers(powers);
277+
278+
const {
279+
compartments,
280+
entry: { module: entryModuleSpecifier, compartment: entryCompartmentName },
281+
} = compartmentMap;
282+
283+
/** @type {Sources} */
284+
const sources = Object.create(null);
285+
286+
const consolidatedExitModuleImportHook = exitModuleImportHookMaker({
287+
modules: exitModules,
288+
exitModuleImportHook,
289+
});
290+
291+
const makeImportHook = makeImportHookMaker(read, entryCompartmentName, {
292+
sources,
293+
compartmentDescriptors: compartments,
294+
archiveOnly: true,
295+
computeSha512,
296+
searchSuffixes,
297+
entryCompartmentName,
298+
entryModuleSpecifier,
299+
exitModuleImportHook: consolidatedExitModuleImportHook,
300+
sourceMapHook,
301+
});
302+
// Induce importHook to record all the necessary modules to import the given module specifier.
303+
const { compartment, attenuatorsCompartment } = link(compartmentMap, {
304+
resolve,
305+
makeImportHook,
306+
moduleTransforms,
307+
parserForLanguage,
308+
languageForExtension,
309+
archiveOnly: true,
310+
});
311+
await compartment.load(entryModuleSpecifier);
312+
if (policy) {
313+
// retain all attenuators.
314+
await Promise.all(
315+
detectAttenuators(policy).map(attenuatorSpecifier =>
316+
attenuatorsCompartment.load(attenuatorSpecifier),
317+
),
318+
);
319+
}
320+
321+
return captureCompartmentMap(compartmentMap, sources);
322+
};

packages/compartment-mapper/src/compartment-map.js

-28
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,6 @@ import { assertPackagePolicy } from './policy-format.js';
1111
// this definition of `q` rather than `assert.quote`
1212
const q = JSON.stringify;
1313

14-
const moduleLanguages = [
15-
'cjs',
16-
'mjs',
17-
'json',
18-
'text',
19-
'bytes',
20-
'pre-mjs-json',
21-
'pre-cjs-json',
22-
];
23-
2414
/** @type {(a: string, b: string) => number} */
2515
// eslint-disable-next-line no-nested-ternary
2616
export const stringCompare = (a, b) => (a === b ? 0 : a < b ? -1 : 1);
@@ -162,12 +152,6 @@ const assertFileModule = (allegedModule, path, url) => {
162152
'string',
163153
`${path}.parser must be a string, got ${q(parser)} in ${q(url)}`,
164154
);
165-
assert(
166-
moduleLanguages.includes(parser),
167-
`${path}.parser must be one of ${q(moduleLanguages)}, got ${parser} in ${q(
168-
url,
169-
)}`,
170-
);
171155

172156
if (sha512 !== undefined) {
173157
assert.typeof(
@@ -275,12 +259,6 @@ const assertParsers = (allegedParsers, path, url) => {
275259
'string',
276260
`${path}.parsers[${q(key)}] must be a string, got ${value} in ${q(url)}`,
277261
);
278-
assert(
279-
moduleLanguages.includes(value),
280-
`${path}.parsers[${q(key)}] must be one of ${q(
281-
moduleLanguages,
282-
)}, got ${value} in ${q(url)}`,
283-
);
284262
}
285263
};
286264

@@ -362,12 +340,6 @@ const assertTypes = (allegedTypes, path, url) => {
362340
'string',
363341
`${path}.types[${q(key)}] must be a string, got ${value} in ${q(url)}`,
364342
);
365-
assert(
366-
moduleLanguages.includes(value),
367-
`${path}.types[${q(key)}] must be one of ${q(
368-
moduleLanguages,
369-
)}, got ${value} in ${q(url)}`,
370-
);
371343
}
372344
};
373345

0 commit comments

Comments
 (0)