UNPKG

@endo/marshal

Version:

marshal: encoding and deconding of Passable subgraphs

421 lines (384 loc) 14.7 kB
/// <reference types="ses"/> import { Nat } from '@endo/nat'; import { assertPassable, getInterfaceOf, getErrorConstructor, hasOwnPropertyOf, toPassableError, } from '@endo/pass-style'; import { X, Fail, q, makeError, annotateError } from '@endo/errors'; import { objectMap } from '@endo/common/object-map.js'; import { QCLASS, makeEncodeToCapData, makeDecodeFromCapData, } from './encodeToCapData.js'; import { makeDecodeFromSmallcaps, makeEncodeToSmallcaps, } from './encodeToSmallcaps.js'; /** * @import {ConvertSlotToVal, ConvertValToSlot, FromCapData, MakeMarshalOptions, ToCapData} from './types.js'; * @import {Passable, PassableCap, RemotableObject} from '@endo/pass-style'; * @import {InterfaceSpec} from '@endo/pass-style'; * @import {Encoding} from './types.js'; */ const { defineProperties } = Object; const { isArray } = Array; const { ownKeys } = Reflect; /** @type {ConvertValToSlot<any>} */ const defaultValToSlotFn = x => x; /** @type {ConvertSlotToVal<any>} */ const defaultSlotToValFn = (x, _) => x; /** * @template Slot * @param {ConvertValToSlot<Slot>} [convertValToSlot] * @param {ConvertSlotToVal<Slot>} [convertSlotToVal] * @param {MakeMarshalOptions} options */ export const makeMarshal = ( convertValToSlot = defaultValToSlotFn, convertSlotToVal = defaultSlotToValFn, { errorTagging = 'on', marshalName = 'anon-marshal', // TODO Temporary hack. // See https://github.com/Agoric/agoric-sdk/issues/2780 errorIdNum = 10000, // We prefer that the caller instead log to somewhere hidden // to be revealed when correlating with the received error. marshalSaveError = err => console.log('Temporary logging of sent error', err), // Default to 'capdata' because it was implemented first. // Sometimes, ontogeny does recapitulate phylogeny ;) serializeBodyFormat = 'capdata', } = {}, ) => { assert.typeof(marshalName, 'string'); errorTagging === 'on' || errorTagging === 'off' || Fail`The errorTagging option can only be "on" or "off" ${errorTagging}`; const nextErrorId = () => { errorIdNum += 1; return `error:${marshalName}#${errorIdNum}`; }; /** * @type {ToCapData<Slot>} */ const toCapData = root => { const slots = []; // maps val (promise or remotable) to index of slots[] const slotMap = new Map(); /** * @param {PassableCap} passable * @returns {{index: number, repeat: boolean}} */ const encodeSlotCommon = passable => { let index = slotMap.get(passable); if (index !== undefined) { // TODO assert that it's the same iface as before assert.typeof(index, 'number'); return harden({ index, repeat: true }); } index = slots.length; const slot = convertValToSlot(passable); slots.push(slot); slotMap.set(passable, index); return harden({ index, repeat: false }); }; /** * Even if an Error is not actually passable, we'd rather send * it anyway because the diagnostic info carried by the error * is more valuable than diagnosing why the error isn't * passable. See comments in isErrorLike. * * @param {Error} err * @param {(p: Passable) => unknown} encodeRecur * @returns {{errorId?: string, message: string, name: string}} */ const encodeErrorCommon = (err, encodeRecur) => { const message = encodeRecur(`${err.message}`); assert.typeof(message, 'string'); const name = encodeRecur(`${err.name}`); assert.typeof(name, 'string'); // TODO Must encode `cause`, `errors`, but // only once all possible counterparty decoders are tolerant of // receiving them. if (errorTagging === 'on') { // We deliberately do not share the stack, but it would // be useful to log the stack locally so someone who has // privileged access to the throwing Vat can correlate // the problem with the remote Vat that gets this // summary. If we do that, we could allocate some random // identifier and include it in the message, to help // with the correlation. const errorId = encodeRecur(nextErrorId()); assert.typeof(errorId, 'string'); annotateError(err, X`Sent as ${errorId}`); marshalSaveError(err); return harden({ errorId, message, name }); } else { return harden({ message, name }); } }; if (serializeBodyFormat === 'capdata') { /** * @param {PassableCap} passable * @param {InterfaceSpec} [iface] * @returns {Encoding} */ const encodeSlotToCapData = (passable, iface = undefined) => { const { index, repeat } = encodeSlotCommon(passable); if (repeat === true || iface === undefined) { return harden({ [QCLASS]: 'slot', index }); } else { return harden({ [QCLASS]: 'slot', iface, index }); } }; /** @type {(promise: RemotableObject, encodeRecur: (p: Passable) => Encoding) => Encoding} */ const encodeRemotableToCapData = (val, _encodeRecur) => encodeSlotToCapData(val, getInterfaceOf(val)); /** @type {(promise: Promise, encodeRecur: (p: Passable) => Encoding) => Encoding} */ const encodePromiseToCapData = (promise, _encodeRecur) => encodeSlotToCapData(promise); /** * Even if an Error is not actually passable, we'd rather send * it anyway because the diagnostic info carried by the error * is more valuable than diagnosing why the error isn't * passable. See comments in isErrorLike. * * @param {Error} err * @param {(p: Passable) => Encoding} encodeRecur * @returns {Encoding} */ const encodeErrorToCapData = (err, encodeRecur) => { const errData = encodeErrorCommon(err, encodeRecur); return harden({ [QCLASS]: 'error', ...errData }); }; const encodeToCapData = makeEncodeToCapData({ encodeRemotableToCapData, encodePromiseToCapData, encodeErrorToCapData, }); const encoded = encodeToCapData(root); const body = JSON.stringify(encoded); return harden({ body, slots, }); } else if (serializeBodyFormat === 'smallcaps') { /** * @param {string} prefix * @param {PassableCap} passable * @param {InterfaceSpec} [iface] * @returns {string} */ const encodeSlotToSmallcaps = (prefix, passable, iface = undefined) => { const { index, repeat } = encodeSlotCommon(passable); // TODO explore removing this special case if (repeat === true || iface === undefined) { return `${prefix}${index}`; } return `${prefix}${index}.${iface}`; }; const encodeRemotableToSmallcaps = (remotable, _encodeRecur) => encodeSlotToSmallcaps('$', remotable, getInterfaceOf(remotable)); const encodePromiseToSmallcaps = (promise, _encodeRecur) => encodeSlotToSmallcaps('&', promise); const encodeErrorToSmallcaps = (err, encodeRecur) => { const errData = encodeErrorCommon(err, encodeRecur); const { message, ...rest } = errData; return harden({ '#error': message, ...rest }); }; const encodeToSmallcaps = makeEncodeToSmallcaps({ encodeRemotableToSmallcaps, encodePromiseToSmallcaps, encodeErrorToSmallcaps, }); const encoded = encodeToSmallcaps(root); const smallcapsBody = JSON.stringify(encoded); return harden({ // Valid JSON cannot begin with a '#', so this is a valid signal // indicating smallcaps format. body: `#${smallcapsBody}`, slots, }); } else { // The `throw` is a noop since `Fail` throws. Added for confused linters. throw Fail`Unrecognized serializeBodyFormat: ${q(serializeBodyFormat)}`; } }; const makeFullRevive = slots => { /** @type {Map<number, RemotableObject | Promise>} */ const valMap = new Map(); /** * @param {{iface?: string, index: number}} slotData * @returns {RemotableObject | Promise} */ const decodeSlotCommon = slotData => { const { iface = undefined, index, ...rest } = slotData; ownKeys(rest).length === 0 || Fail`unexpected encoded slot properties ${q(ownKeys(rest))}`; const extant = valMap.get(index); if (extant) { return extant; } // TODO SECURITY HAZARD: must enfoce that remotable vs promise // is according to the encoded string. const slot = slots[Number(Nat(index))]; const val = convertSlotToVal(slot, iface); valMap.set(index, val); return val; }; /** * @param {{ * errorId?: string, * message: string, * name: string, * cause: unknown, * errors: unknown, * }} errData * @param {(e: unknown) => Passable} decodeRecur * @returns {Error} */ const decodeErrorCommon = (errData, decodeRecur) => { const { errorId = undefined, message, name, cause = undefined, errors = undefined, ...rest } = errData; // See https://github.com/endojs/endo/pull/2052 // capData does not transform strings. The immediately following calls // to `decodeRecur` are for reuse by other encodings that do, // such as smallcaps. const dName = decodeRecur(name); const dMessage = decodeRecur(message); // errorId is a late addition so be tolerant of its absence. const dErrorId = /** @type {string} */ (errorId && decodeRecur(errorId)); if (typeof dName !== 'string') { throw Fail`invalid error name typeof ${q(typeof dName)}`; } if (typeof dMessage !== 'string') { throw Fail`invalid error message typeof ${q(typeof dMessage)}`; } const errConstructor = getErrorConstructor(dName) || Error; const errorName = dErrorId === undefined ? `Remote${errConstructor.name}` : `Remote${errConstructor.name}(${dErrorId})`; const options = { errorName, sanitize: false, }; if (cause) { options.cause = decodeRecur(cause); } if (errors) { options.errors = decodeRecur(errors); } const rawError = makeError(dMessage, errConstructor, options); // Note that this does not decodeRecur rest's property names. // This would be inconsistent with smallcaps' expected handling, // but is fine here since it is only used for `annotateError`, // which is for diagnostic info that is otherwise unobservable. const descs = objectMap(rest, data => ({ value: decodeRecur(data), writable: false, enumerable: false, configurable: false, })); defineProperties(rawError, descs); harden(rawError); return toPassableError(rawError); }; // The current encoding does not give the decoder enough into to distinguish // whether a slot represents a promise or a remotable. As an implementation // restriction until this is fixed, if either is provided, both must be // provided and they must be the same. // See https://github.com/Agoric/agoric-sdk/issues/4334 const decodeRemotableOrPromiseFromCapData = (rawTree, _decodeRecur) => { const { [QCLASS]: _, ...slotData } = rawTree; return decodeSlotCommon(slotData); }; const decodeErrorFromCapData = (rawTree, decodeRecur) => { const { [QCLASS]: _, ...errData } = rawTree; return decodeErrorCommon(errData, decodeRecur); }; const reviveFromCapData = makeDecodeFromCapData({ decodeRemotableFromCapData: decodeRemotableOrPromiseFromCapData, decodePromiseFromCapData: decodeRemotableOrPromiseFromCapData, decodeErrorFromCapData, }); const makeDecodeSlotFromSmallcaps = prefix => { /** * @param {string} stringEncoding * @param {(e: unknown) => Passable} _decodeRecur * @returns {RemotableObject | Promise} */ return (stringEncoding, _decodeRecur) => { assert(stringEncoding.charAt(0) === prefix); // slots: $slotIndex.iface or $slotIndex const i = stringEncoding.indexOf('.'); const index = Number(stringEncoding.slice(1, i < 0 ? undefined : i)); // i < 0 means there was no iface included. const iface = i < 0 ? undefined : stringEncoding.slice(i + 1); return decodeSlotCommon({ iface, index }); }; }; const decodeRemotableFromSmallcaps = makeDecodeSlotFromSmallcaps('$'); const decodePromiseFromSmallcaps = makeDecodeSlotFromSmallcaps('&'); const decodeErrorFromSmallcaps = (encoding, decodeRecur) => { const { '#error': message, ...restErrData } = encoding; !hasOwnPropertyOf(restErrData, 'message') || Fail`unexpected encoded error property ${q('message')}`; return decodeErrorCommon({ message, ...restErrData }, decodeRecur); }; const reviveFromSmallcaps = makeDecodeFromSmallcaps({ // @ts-ignore XXX SmallCapsEncoding decodeRemotableFromSmallcaps, // @ts-ignore XXX SmallCapsEncoding decodePromiseFromSmallcaps, decodeErrorFromSmallcaps, }); return harden({ reviveFromCapData, reviveFromSmallcaps }); }; /** * @type {FromCapData<Slot>} */ const fromCapData = data => { const { body, slots } = data; typeof body === 'string' || Fail`unserialize() given non-capdata (.body is ${body}, not string)`; isArray(data.slots) || Fail`unserialize() given non-capdata (.slots are not Array)`; const { reviveFromCapData, reviveFromSmallcaps } = makeFullRevive(slots); let result; // JSON cannot begin with a '#', so this is an unambiguous signal. if (body.charAt(0) === '#') { const smallcapsBody = body.slice(1); const encoding = harden(JSON.parse(smallcapsBody)); result = harden(reviveFromSmallcaps(encoding)); } else { const rawTree = harden(JSON.parse(body)); result = harden(reviveFromCapData(rawTree)); } // See https://github.com/Agoric/agoric-sdk/issues/4337 // which should be considered fixed once we've completed the switch // to smallcaps. assertPassable(result); return /** @type {PassableCap} */ (result); }; return harden({ toCapData, fromCapData, // for backwards compatibility /** @deprecated use toCapData */ serialize: toCapData, /** @deprecated use fromCapData */ unserialize: fromCapData, }); };