UNPKG

@endo/marshal

Version:

marshal: encoding and deconding of Passable subgraphs

515 lines (494 loc) 15.6 kB
/// <reference types="ses"/> import { q, X, Fail } from '@endo/errors'; import { Nat } from '@endo/nat'; import { getErrorConstructor, isObject, nameForPassableSymbol, passableSymbolForName, } from '@endo/pass-style'; import { QCLASS } from './encodeToCapData.js'; import { makeMarshal } from './marshal.js'; /** * @import {StringablePayload} from 'ses'; * @import {Passable} from '@endo/pass-style'; * @import {Encoding} from './types.js'; */ const { ownKeys } = Reflect; const { isArray } = Array; const { stringify: quote } = JSON; /** * @typedef {object} Indenter * @property {(openBracket: string) => number} open * @property {() => number} line * @property {(token: string) => number} next * @property {(closeBracket: string) => number} close * @property {() => string} done */ /** * Generous whitespace for readability * * @returns {Indenter} */ const makeYesIndenter = () => { const strings = []; let level = 0; let needSpace = false; const line = () => { needSpace = false; return strings.push('\n', ' '.repeat(level)); }; return harden({ open: openBracket => { level += 1; if (needSpace) { strings.push(' '); } needSpace = false; return strings.push(openBracket); }, line, next: token => { if (needSpace && token !== ',' && token !== ')') { strings.push(' '); } needSpace = true; return strings.push(token); }, close: closeBracket => { assert(level >= 1); level -= 1; line(); return strings.push(closeBracket); }, done: () => { assert.equal(level, 0); return strings.join(''); }, }); }; /** * If the last character of one token together with the first character * of the next token matches this pattern, then the two tokens must be * separated by whitespace to preserve their meaning. Otherwise the * whitespace in unnecessary. * * The `<!` and `->` cases prevent the accidental formation of an * html-like comment. I don't think the double angle brackets are actually * needed but I haven't thought about it enough to remove them. */ const badPairPattern = /^(?:\w\w|<<|>>|\+\+|--|<!|->)$/; /** * Minimum whitespace needed to preseve meaning. * * @returns {Indenter} */ const makeNoIndenter = () => { /** @type {string[]} */ const strings = []; return harden({ open: openBracket => strings.push(openBracket), line: () => strings.length, next: token => { if (strings.length >= 1) { const last = strings[strings.length - 1]; // eslint-disable-next-line @endo/restrict-comparison-operands -- error if (last.length >= 1 && token.length >= 1) { const pair = `${last[last.length - 1]}${token[0]}`; if (badPairPattern.test(pair)) { strings.push(' '); } } } return strings.push(token); }, close: closeBracket => { if (strings.length >= 1 && strings[strings.length - 1] === ',') { strings.pop(); } return strings.push(closeBracket); }, done: () => strings.join(''), }); }; const identPattern = /^[a-zA-Z]\w*$/; harden(identPattern); const AtAtPrefixPattern = /^@@(.*)$/; harden(AtAtPrefixPattern); /** * @param {Encoding} encoding * @param {boolean=} shouldIndent * @param {any[]} [slots] * @returns {string} */ const decodeToJustin = (encoding, shouldIndent = false, slots = []) => { /** * The first pass does some input validation. * Its control flow should mirror `recur` as closely as possible * and the two should be maintained together. They must visit everything * in the same order. * * TODO now that ibids are gone, we should fold this back together into * one validating pass. * * @param {Encoding} rawTree * @returns {void} */ const prepare = rawTree => { if (!isObject(rawTree)) { return; } // Assertions of the above to narrow the type. assert.typeof(rawTree, 'object'); assert(rawTree !== null); if (QCLASS in rawTree) { const qclass = rawTree[QCLASS]; typeof qclass === 'string' || Fail`invalid qclass typeof ${q(typeof qclass)}`; assert(!isArray(rawTree)); switch (rawTree['@qclass']) { case 'undefined': case 'NaN': case 'Infinity': case '-Infinity': { return; } case 'bigint': { const { digits } = rawTree; typeof digits === 'string' || Fail`invalid digits typeof ${q(typeof digits)}`; return; } case '@@asyncIterator': { return; } case 'symbol': { const { name } = rawTree; assert.typeof(name, 'string'); const sym = passableSymbolForName(name); assert.typeof(sym, 'symbol'); return; } case 'tagged': { const { tag, payload } = rawTree; assert.typeof(tag, 'string'); prepare(payload); return; } case 'slot': { const { index, iface } = rawTree; assert.typeof(index, 'number'); Nat(index); if (iface !== undefined) { assert.typeof(iface, 'string'); } return; } case 'hilbert': { const { original, rest } = rawTree; 'original' in rawTree || Fail`Invalid Hilbert Hotel encoding ${rawTree}`; prepare(original); if ('rest' in rawTree) { if (typeof rest !== 'object') { throw Fail`Rest ${rest} encoding must be an object`; } if (rest === null) { throw Fail`Rest ${rest} encoding must not be null`; } if (isArray(rest)) { throw Fail`Rest ${rest} encoding must not be an array`; } if (QCLASS in rest) { throw Fail`Rest encoding ${rest} must not contain ${q(QCLASS)}`; } const names = ownKeys(rest); for (const name of names) { typeof name === 'string' || Fail`Property name ${name} of ${rawTree} must be a string`; prepare(rest[name]); } } return; } case 'error': { const { name, message } = rawTree; if (typeof name !== 'string') { throw Fail`invalid error name typeof ${q(typeof name)}`; } getErrorConstructor(name) !== undefined || Fail`Must be the name of an Error constructor ${name}`; typeof message === 'string' || Fail`invalid error message typeof ${q(typeof message)}`; return; } default: { assert.fail(X`unrecognized ${q(QCLASS)} ${q(qclass)}`, TypeError); } } } else if (isArray(rawTree)) { const { length } = rawTree; for (let i = 0; i < length; i += 1) { prepare(rawTree[i]); } } else { const names = ownKeys(rawTree); for (const name of names) { if (typeof name !== 'string') { throw Fail`Property name ${name} of ${rawTree} must be a string`; } prepare(rawTree[name]); } } }; const makeIndenter = shouldIndent ? makeYesIndenter : makeNoIndenter; let out = makeIndenter(); /** * This is the second pass recursion after the first pass `prepare`. * The first pass did some input validation so * here we can safely assume everything those things are validated. * * @param {Encoding} rawTree * @returns {number} */ const decode = rawTree => { // eslint-disable-next-line no-use-before-define return recur(rawTree); }; const decodeProperty = (name, value) => { out.line(); if (name === '__proto__') { // JavaScript interprets `{__proto__: x, ...}` // as making an object inheriting from `x`, whereas // in JSON it is simply a property name. Preserve the // JSON meaning. out.next(`["__proto__"]:`); } else if (identPattern.test(name)) { out.next(`${name}:`); } else { out.next(`${quote(name)}:`); } decode(value); out.next(','); }; /** * Modeled after `fullRevive` in marshal.js * * @param {Encoding} rawTree * @returns {number} */ const recur = rawTree => { if (!isObject(rawTree)) { // primitives get quoted return out.next(quote(rawTree)); } // Assertions of the above to narrow the type. assert.typeof(rawTree, 'object'); assert(rawTree !== null); if (QCLASS in rawTree) { const qclass = rawTree[QCLASS]; assert.typeof(qclass, 'string'); assert(!isArray(rawTree)); // Switching on `encoded[QCLASS]` (or anything less direct, like // `qclass`) does not discriminate rawTree in typescript@4.2.3 and // earlier. switch (rawTree['@qclass']) { // Encoding of primitives not handled by JSON case 'undefined': case 'NaN': case 'Infinity': case '-Infinity': { // Their qclass is their expression source. return out.next(qclass); } case 'bigint': { const { digits } = rawTree; assert.typeof(digits, 'string'); return out.next(`${BigInt(digits)}n`); } case '@@asyncIterator': { // TODO deprecated. Eventually remove. return out.next('Symbol.asyncIterator'); } case 'symbol': { const { name } = rawTree; assert.typeof(name, 'string'); const sym = passableSymbolForName(name); assert.typeof(sym, 'symbol'); const registeredName = nameForPassableSymbol(sym); if (registeredName === undefined) { const match = AtAtPrefixPattern.exec(name); assert(match !== null); const suffix = match[1]; assert(Symbol[suffix] === sym); assert(identPattern.test(suffix)); return out.next(`Symbol.${suffix}`); } return out.next(`passableSymbolForName(${quote(registeredName)})`); } case 'tagged': { const { tag, payload } = rawTree; out.next(`makeTagged(${quote(tag)}`); out.next(','); decode(payload); return out.next(')'); } case 'slot': { let { iface } = rawTree; const index = Number(Nat(rawTree.index)); const nestedRender = arg => { const oldOut = out; try { out = makeNoIndenter(); decode(arg); return out.done(); } finally { out = oldOut; } }; if (index < slots.length) { const slot = nestedRender(slots[index]); if (iface === undefined) { return out.next(`slotToVal(${slot})`); } iface = nestedRender(iface); return out.next(`slotToVal(${slot},${iface})`); } else if (iface === undefined) { return out.next(`slot(${index})`); } iface = nestedRender(iface); return out.next(`slot(${index},${iface})`); } case 'hilbert': { const { original, rest } = rawTree; out.open('{'); decodeProperty(QCLASS, original); if ('rest' in rawTree) { assert.typeof(rest, 'object'); assert(rest !== null); const names = ownKeys(rest); for (const name of names) { if (typeof name !== 'string') { throw Fail`Property name ${q( name, )} of ${rest} must be a string`; } decodeProperty(name, rest[name]); } } return out.close('}'); } case 'error': { const { name, message, cause = undefined, errors = undefined, } = rawTree; cause === undefined || Fail`error cause not yet implemented in marshal-justin`; name !== `AggregateError` || Fail`AggregateError not yet implemented in marshal-justin`; errors === undefined || Fail`error errors not yet implemented in marshal-justin`; return out.next(`${name}(${quote(message)})`); } default: { throw assert.fail( X`unrecognized ${q(QCLASS)} ${q(qclass)}`, TypeError, ); } } } else if (isArray(rawTree)) { const { length } = rawTree; if (length === 0) { return out.next('[]'); } else { out.open('['); for (let i = 0; i < length; i += 1) { out.line(); decode(rawTree[i]); out.next(','); } return out.close(']'); } } else { // rawTree is an `EncodingRecord` which only has string keys, // but since ownKeys is not generic, it can't propagate that const names = /** @type {string[]} */ (ownKeys(rawTree)); if (names.length === 0) { return out.next('{}'); } else { out.open('{'); for (const name of names) { decodeProperty(name, rawTree[name]); } return out.close('}'); } } }; prepare(encoding); decode(encoding); return out.done(); }; harden(decodeToJustin); export { decodeToJustin }; /** * @param {Passable} passable * @param {boolean} [shouldIndent] * @returns {string} */ export const passableAsJustin = (passable, shouldIndent = true) => { let slotCount = 0; // Using post-increment below only so that the indexes start at zero // and the `slotCount` variable can be initialized to `0` rather than // `-1`. // eslint-disable-next-line no-plusplus const convertValToSlot = val => `s${slotCount++}`; const { toCapData } = makeMarshal(convertValToSlot); const { body, slots } = toCapData(passable); const encoded = JSON.parse(body); return decodeToJustin(encoded, shouldIndent, slots); }; harden(passableAsJustin); // The example below is the `patt1` test case from `qp-on-pattern.test.js`. // Please co-maintain the following doc-comment and that test module. /** * `qp` for quote passable as a quasi-quoted Justin expression. * * Both `q` from `@endo/errors` and this `qp` from `@endo/marshal` can * be used together with `Fail`, `X`, etc from `@endo/errors` to mark * a substitution value to be both * - visually quoted in some useful manner * - unredacted * * Differences: * - given a pattern `M.and(M.gte(-100), M.lte(100))`, * ```js * `${q(patt)}` * ``` * produces `"[match:and]"`, whereas * ```js * `${qp(patt)}` * ``` * produces quasi-quotes Justin of what would be passed: * ```js * `makeTagged("match:and", [ * makeTagged("match:gte", -100), * makeTagged("match:lte", 100), * ])` * ``` * - `q` is lazy, minimizing the cost for using it in an error that's never * logged. Unfortunately, due to layering constraints, `qp` is not * lazy, always rendering to quasi-quoted Justin immediately. * * Since Justin is a subset of HardenedJS, neither the name `qp` nor the * rendered form need to make clear that the rendered form is in Justin rather * than HardenedJS. * * @param {Passable} payload * @returns {StringablePayload} */ export const qp = payload => `\`${passableAsJustin(harden(payload), true)}\``; harden(qp);