|
| 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 | +}; |
0 commit comments