feat(compartment-mapper): Validate compartment maps
MODIFY
packages/compartment-mapper/src/archive.js
packages/compartment-mapper/src/archive.js
@@ -24,6 +24,7 @@ import { parseArchiveCjs } from './parse-archive-cjs.js';
import { parseArchiveMjs } from './parse-archive-mjs.js';
import { parseLocatedJson } from './json.js';
import { unpackReadPowers } from './powers.js';
+import { assertCompartmentMap } from './compartment-map.js';
const textEncoder = new TextEncoder();
@@ -77,13 +78,14 @@ const translateCompartmentMap = (compartments, sources, renames) => {
if (compartment.modules) {
for (const name of keys(compartmentModules).sort()) {
const module = compartmentModules[name];
- const compartment = module.compartment
- ? renames[module.compartment]
- : undefined;
+ if (module.compartment !== undefined) {
modules[name] = {
...module,
- compartment,
+ compartment: renames[module.compartment],
};
+ } else {
+ modules[name] = module;
+ }
}
}
@@ -93,12 +95,17 @@ const translateCompartmentMap = (compartments, sources, renames) => {
for (const name of keys(compartmentSources).sort()) {
const source = compartmentSources[name];
const { location, parser, exit, sha512 } = source;
+ if (location !== undefined) {
modules[name] = {
location,
parser,
- exit,
sha512,
};
+ } else if (exit !== undefined) {
+ modules[name] = {
+ exit,
+ };
+ }
}
}
@@ -247,6 +254,13 @@ const digestLocation = async (powers, moduleLocation, options) => {
},
compartments: archiveCompartments,
};
+
+ // Cross-check:
+ // We assert that we have constructed a valid compartment map, not because it
+ // might not be, but to ensure that the assertCompartmentMap function can
+ // accept all valid compartment maps.
+ assertCompartmentMap(archiveCompartmentMap);
+
const archiveCompartmentMapText = JSON.stringify(
archiveCompartmentMap,
null,

ADD
packages/compartment-mapper/src/compartment-map.js
+// @ts-check
+/// <reference types="ses"/>
+
+const q = JSON.stringify;
+
+const moduleLanguages = ['cjs', 'mjs', 'json', 'pre-mjs-json', 'pre-cjs-json'];
+
+/**
+ * @template T
+ * @param {Iterable<T>} iterable
+ */
+function* enumerate(iterable) {
+ let index = 0;
+ for (const value of iterable) {
+ yield [index, value];
+ index += 1;
+ }
+}
+
+/**
+ * @param {Record<string, unknown>} object
+ * @param {string} message
+ */
+const assertEmptyObject = (object, message) => {
+ assert(Object.keys(object).length === 0, message);
+};
+
+/**
+ * @param {unknown} tags
+ * @param {string} url
+ */
+const assertTags = (tags, url) => {
+ if (tags === undefined) return;
+ assert(
+ Array.isArray(tags),
+ `tags must be an array, got ${tags} in ${q(url)}`,
+ );
+ for (const [index, value] of enumerate(tags)) {
+ assert.typeof(
+ value,
+ 'string',
+ `tags[${index}] must be a string, got ${value} in ${q(url)}`,
+ );
+ }
+};
+
+/**
+ * @param {Record<string, unknown>} allegedModule
+ * @param {string} path
+ * @param {string} url
+ */
+const assertCompartmentModule = (allegedModule, path, url) => {
+ const { compartment, module, ...extra } = allegedModule;
+ assertEmptyObject(
+ extra,
+ `${path} must not have extra properties, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+ assert.typeof(
+ compartment,
+ 'string',
+ `${path}.compartment must be a string, got ${q(compartment)} in ${q(url)}`,
+ );
+ assert.typeof(
+ module,
+ 'string',
+ `${path}.module must be a string, got ${q(module)} in ${q(url)}`,
+ );
+};
+
+/**
+ * @param {Record<string, unknown>} allegedModule
+ * @param {string} path
+ * @param {string} url
+ */
+const assertFileModule = (allegedModule, path, url) => {
+ const { location, parser, sha512, ...extra } = allegedModule;
+ assertEmptyObject(
+ extra,
+ `${path} must not have extra properties, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+ assert.typeof(
+ location,
+ 'string',
+ `${path}.location must be a string, got ${q(location)} in ${q(url)}`,
+ );
+ assert.typeof(
+ parser,
+ 'string',
+ `${path}.parser must be a string, got ${q(parser)} in ${q(url)}`,
+ );
+ assert(
+ moduleLanguages.includes(parser),
+ `${path}.parser must be one of ${q(moduleLanguages)}, got ${parser} in ${q(
+ url,
+ )}`,
+ );
+
+ if (sha512 !== undefined) {
+ assert.typeof(
+ sha512,
+ 'string',
+ `${path}.sha512 must be a string, got ${q(sha512)} in ${q(url)}`,
+ );
+ }
+};
+
+/**
+ * @param {Record<string, unknown>} allegedModule
+ * @param {string} path
+ * @param {string} url
+ */
+const assertExitModule = (allegedModule, path, url) => {
+ const { exit, ...extra } = allegedModule;
+ assertEmptyObject(
+ extra,
+ `${path} must not have extra properties, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+ assert.typeof(
+ exit,
+ 'string',
+ `${path}.exit must be a string, got ${q(exit)} in ${q(url)}`,
+ );
+};
+
+/**
+ * @param {unknown} allegedModule
+ * @param {string} path
+ * @param {string} url
+ */
+const assertModule = (allegedModule, path, url) => {
+ const moduleDescriptor = Object(allegedModule);
+ assert(
+ allegedModule === moduleDescriptor && !Array.isArray(moduleDescriptor),
+ `${path} must be an object, got ${allegedModule} in ${q(url)}`,
+ );
+
+ const { compartment, module, location, parser, exit } = moduleDescriptor;
+ if (compartment !== undefined || module !== undefined) {
+ assertCompartmentModule(moduleDescriptor, path, url);
+ } else if (location !== undefined || parser !== undefined) {
+ assertFileModule(moduleDescriptor, path, url);
+ } else if (exit !== undefined) {
+ assertExitModule(moduleDescriptor, path, url);
+ } else {
+ assert.fail(
+ `${path} is not a valid module descriptor, got ${q(allegedModule)} in ${q(
+ url,
+ )}`,
+ );
+ }
+};
+
+/**
+ * @param {unknown} allegedModules
+ * @param {string} path
+ * @param {string} url
+ */
+const assertModules = (allegedModules, path, url) => {
+ const modules = Object(allegedModules);
+ assert(
+ allegedModules === modules || !Array.isArray(modules),
+ `modules must be an object, got ${allegedModules} in ${q(url)}`,
+ );
+ for (const [key, value] of Object.entries(modules)) {
+ assertModule(value, `${path}.modules[${q(key)}]`, url);
+ }
+};
+
+/**
+ * @param {unknown} allegedParsers
+ * @param {string} path
+ * @param {string} url
+ */
+const assertParsers = (allegedParsers, path, url) => {
+ if (allegedParsers === undefined) {
+ return;
+ }
+ const parsers = Object(allegedParsers);
+ assert(
+ allegedParsers === parsers && !Array.isArray(parsers),
+ `${path}.parsers must be an object, got ${allegedParsers} in ${q(url)}`,
+ );
+
+ for (const [key, value] of Object.entries(parsers)) {
+ assert.typeof(
+ key,
+ 'string',
+ `all keys of ${path}.parsers must be strings, got ${key} in ${q(url)}`,
+ );
+ assert.typeof(
+ value,
+ 'string',
+ `${path}.parsers[${q(key)}] must be a string, got ${value} in ${q(url)}`,
+ );
+ assert(
+ moduleLanguages.includes(value),
+ `${path}.parsers[${q(key)}] must be one of ${q(
+ moduleLanguages,
+ )}, got ${value} in ${q(url)}`,
+ );
+ }
+};
+
+/**
+ * @param {unknown} allegedScope
+ * @param {string} path
+ * @param {string} url
+ */
+const assertScope = (allegedScope, path, url) => {
+ const scope = Object(allegedScope);
+ assert(
+ allegedScope === scope && !Array.isArray(scope),
+ `${path} must be an object, got ${allegedScope} in ${q(url)}`,
+ );
+
+ const { compartment, ...extra } = scope;
+ assertEmptyObject(
+ extra,
+ `${path} must not have extra properties, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+
+ assert.typeof(
+ compartment,
+ 'string',
+ `${path}.compartment must be a string, got ${q(compartment)} in ${q(url)}`,
+ );
+};
+
+/**
+ * @param {unknown} allegedScopes
+ * @param {string} path
+ * @param {string} url
+ */
+const assertScopes = (allegedScopes, path, url) => {
+ if (allegedScopes === undefined) {
+ return;
+ }
+ const scopes = Object(allegedScopes);
+ assert(
+ allegedScopes === scopes && !Array.isArray(scopes),
+ `${path}.scopes must be an object, got ${allegedScopes} in ${q(url)}`,
+ );
+
+ for (const [key, value] of Object.entries(scopes)) {
+ assert.typeof(
+ key,
+ 'string',
+ `all keys of ${path}.scopes must be strings, got ${key} in ${q(url)}`,
+ );
+ assertScope(value, `${path}.scopes[${q(key)}]`, url);
+ }
+};
+
+/**
+ * @param {unknown} allegedTypes
+ * @param {string} path
+ * @param {string} url
+ */
+const assertTypes = (allegedTypes, path, url) => {
+ if (allegedTypes === undefined) {
+ return;
+ }
+ const types = Object(allegedTypes);
+ assert(
+ allegedTypes === types && !Array.isArray(types),
+ `${path}.types must be an object, got ${allegedTypes} in ${q(url)}`,
+ );
+
+ for (const [key, value] of Object.entries(types)) {
+ assert.typeof(
+ key,
+ 'string',
+ `all keys of ${path}.types must be strings, got ${key} in ${q(url)}`,
+ );
+ assert.typeof(
+ value,
+ 'string',
+ `${path}.types[${q(key)}] must be a string, got ${value} in ${q(url)}`,
+ );
+ assert(
+ moduleLanguages.includes(value),
+ `${path}.types[${q(key)}] must be one of ${q(
+ moduleLanguages,
+ )}, got ${value} in ${q(url)}`,
+ );
+ }
+};
+
+/**
+ * @param {unknown} allegedCompartment
+ * @param {string} path
+ * @param {string} url
+ */
+const assertCompartment = (allegedCompartment, path, url) => {
+ const compartment = Object(allegedCompartment);
+ assert(
+ allegedCompartment === compartment && !Array.isArray(compartment),
+ `${path} must be an object, got ${allegedCompartment} in ${q(url)}`,
+ );
+
+ const {
+ location,
+ name,
+ label,
+ parsers,
+ types,
+ scopes,
+ modules,
+ ...extra
+ } = compartment;
+
+ assertEmptyObject(
+ extra,
+ `${path} must not have extra properties, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+
+ assert.typeof(
+ location,
+ 'string',
+ `${path}.location in ${q(url)} must be string, got ${q(location)}`,
+ );
+ assert.typeof(
+ name,
+ 'string',
+ `${path}.name in ${q(url)} must be string, got ${q(name)}`,
+ );
+ assert.typeof(
+ label,
+ 'string',
+ `${path}.label in ${q(url)} must be string, got ${q(label)}`,
+ );
+
+ assertModules(modules, path, url);
+ assertParsers(parsers, path, url);
+ assertScopes(scopes, path, url);
+ assertTypes(types, path, url);
+};
+
+/**
+ * @param {unknown} allegedCompartments
+ * @param {string} url
+ */
+const assertCompartments = (allegedCompartments, url) => {
+ const compartments = Object(allegedCompartments);
+ assert(
+ allegedCompartments === compartments || !Array.isArray(compartments),
+ `compartments must be an object, got ${allegedCompartments} in ${q(url)}`,
+ );
+ for (const [key, value] of Object.entries(compartments)) {
+ assertCompartment(value, `compartments[${q(key)}]`, url);
+ }
+};
+
+/**
+ * @param {unknown} allegedEntry
+ * @param {string} url
+ */
+const assertEntry = (allegedEntry, url) => {
+ const entry = Object(allegedEntry);
+ assert(
+ allegedEntry === entry && !Array.isArray(entry),
+ `"entry" must be an object in compartment map, got ${allegedEntry} in ${q(
+ url,
+ )}`,
+ );
+ const { compartment, module, ...extra } = entry;
+ assertEmptyObject(
+ extra,
+ `"entry" must not have extra properties in compartment map, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+ assert.typeof(
+ compartment,
+ 'string',
+ `entry.compartment must be a string in compartment map, got ${compartment} in ${q(
+ url,
+ )}`,
+ );
+ assert.typeof(
+ module,
+ 'string',
+ `entry.module must be a string in compartment map, got ${module} in ${q(
+ url,
+ )}`,
+ );
+};
+
+/**
+ * @param {unknown} allegedCompartmentMap
+ * @param {string} [url]
+ * @returns {asserts compartmentMap is import('./types.js').CompartmentMapDescriptor}
+ */
+export const assertCompartmentMap = (
+ allegedCompartmentMap,
+ url = '<unknown-compartment-map.json>',
+) => {
+ const compartmentMap = Object(allegedCompartmentMap);
+ assert(
+ allegedCompartmentMap === compartmentMap && !Array.isArray(compartmentMap),
+ `Compartment map must be an object, got ${allegedCompartmentMap} in ${q(
+ url,
+ )}`,
+ );
+ const { tags, entry, compartments, ...extra } = Object(compartmentMap);
+ assertEmptyObject(
+ extra,
+ `Compartment map must not have extra properties, got ${q(
+ Object.keys(extra),
+ )} in ${q(url)}`,
+ );
+ assertTags(tags, url);
+ assertEntry(entry, url);
+ assertCompartments(compartments, url);
+ return undefined;
+};

MODIFY
packages/compartment-mapper/src/import-archive.js
packages/compartment-mapper/src/import-archive.js
@@ -23,6 +23,7 @@ import { parsePreMjs } from './parse-pre-mjs.js';
import { parseLocatedJson } from './json.js';
import { unpackReadPowers } from './powers.js';
import { join } from './node-module-specifier.js';
+import { assertCompartmentMap } from './compartment-map.js';
// q as in quote for strings in error messages.
const q = JSON.stringify;
@@ -164,12 +165,11 @@ export const parseArchive = async (
}
const compartmentMapText = textDecoder.decode(compartmentMapBytes);
- const compartmentMap = /** @type {CompartmentMapDescriptor} */ (parseLocatedJson(
+ const compartmentMap = parseLocatedJson(
compartmentMapText,
'compartment-map.json',
- ));
- // TODO validate compartmentMap instead of leaning hard on the above type
- // assertion.
+ );
+ assertCompartmentMap(compartmentMap);
/** @type {ExecuteFn} */
const execute = options => {

MODIFY
packages/compartment-mapper/src/node-modules.js
packages/compartment-mapper/src/node-modules.js
import { inferExports } from './infer-exports.js';
import { parseLocatedJson } from './json.js';
import { unpackReadPowers } from './powers.js';
+import { assertCompartmentMap } from './compartment-map.js';
const { assign, create, keys, values } = Object;
@@ -497,5 +498,18 @@ export const compartmentMapForNodeModules = async (
packageDescriptor,
dev,
);
- return translateGraph(packageLocation, moduleSpecifier, graph, tags);
+ const compartmentMap = translateGraph(
+ packageLocation,
+ moduleSpecifier,
+ graph,
+ tags,
+ );
+
+ // Cross-check:
+ // We assert that we have constructed a valid compartment map, not because it
+ // might not be, but to ensure that the assertCompartmentMap function can
+ // accept all valid compartment maps.
+ assertCompartmentMap(compartmentMap);
+
+ return compartmentMap;
};

feat(compartment-mapper): Validate compartment maps