Skip to content

Commit

Permalink
fix: split marshal from its encoder
Browse files Browse the repository at this point in the history
  • Loading branch information
erights committed Aug 21, 2022
1 parent 43b7962 commit 7c298e2
Show file tree
Hide file tree
Showing 4 changed files with 428 additions and 320 deletions.
3 changes: 2 additions & 1 deletion packages/marshal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ export { deeplyFulfilled } from './src/deeplyFulfilled.js';
export { makeTagged } from './src/makeTagged.js';
export { Remotable, Far, ToFarFunction } from './src/make-far.js';

export { QCLASS, makeMarshal } from './src/marshal.js';
export { QCLASS } from './src/encodeToJSON.js';
export { makeMarshal } from './src/marshal.js';
export { stringify, parse } from './src/marshal-stringify.js';

export { decodeToJustin } from './src/marshal-justin.js';
Expand Down
367 changes: 367 additions & 0 deletions packages/marshal/src/encodeToJSON.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,367 @@
// @ts-check

/// <reference types="ses"/>

// This module is based on the `encodeParssable.js` in `@agoric/store`,
// which may migrate here. The main external difference is that
// `encodePassable` goes directly to string, whereas `encodeToJSON`
// encodes to a raw JSON tree and leaves to the caller (`marshal.js`)
// to stringify it.

import { passStyleOf } from './passStyleOf.js';

import { ErrorHelper } from './helpers/error.js';
import { makeTagged } from './makeTagged.js';
import { isObject, getTag } from './helpers/passStyle-helpers.js';
import {
assertPassableSymbol,
nameForPassableSymbol,
passableSymbolForName,
} from './helpers/symbol.js';

/** @typedef {import('./types.js').MakeMarshalOptions} MakeMarshalOptions */
/** @template Slot @typedef {import('./types.js').ConvertSlotToVal<Slot>} ConvertSlotToVal */
/** @template Slot @typedef {import('./types.js').ConvertValToSlot<Slot>} ConvertValToSlot */
/** @template Slot @typedef {import('./types.js').Serialize<Slot>} Serialize */
/** @template Slot @typedef {import('./types.js').Unserialize<Slot>} Unserialize */
/** @typedef {import('./types.js').Passable} Passable */
/** @typedef {import('./types.js').InterfaceSpec} InterfaceSpec */
/** @typedef {import('./types.js').Encoding} Encoding */
/** @typedef {import('./types.js').Remotable} Remotable */

const { ownKeys } = Reflect;
const { isArray } = Array;
const {
getOwnPropertyDescriptors,
defineProperties,
is,
fromEntries,
freeze,
} = Object;
const { details: X, quote: q } = assert;

/**
* Special property name that indicates an encoding that needs special
* decoding.
*/
const QCLASS = '@qclass';
export { QCLASS };

/**
* @typedef {object} EncodeToJSONOptionsRecord
* @property {(remotable: object) => Encoding} encodeRemotableToJSON
* @property {(promise: object) => Encoding} encodePromiseToJSON
* @property {(error: object) => Encoding} encodeErrorToJSON
*/

/**
* @typedef {Partial<EncodeToJSONOptionsRecord>} EncodeToJSONOptions
*/

/**
* @param {EncodeToJSONOptions=} encodeOptions
* @returns {(passable: Passable) => Encoding}
*/
export const makeEncodeToJSON = ({
encodeRemotableToJSON = rem => assert.fail(X`remotable unexpected: ${rem}`),
encodePromiseToJSON = prom => assert.fail(X`promise unexpected: ${prom}`),
encodeErrorToJSON = err => assert.fail(X`error unexpected: ${err}`),
} = {}) => {
/**
* Must encode `val` into plain JSON data *canonically*, such that
* `JSON.stringify(encode(v1)) === JSON.stringify(encode(v1))`
* For each copyRecord, we only accept string property names,
* not symbols. The encoded form the sort
* order of these names must be the same as their enumeration
* order, so a `JSON.stringify` of the encoded form agrees with
* a canonical-json stringify of the encoded form.
*
* @param {Passable} passable
* @returns {Encoding}
*/
const encodeToJSON = passable => {
if (ErrorHelper.canBeValid(passable)) {
return encodeErrorToJSON(passable);
}
// First we handle all primitives. Some can be represented directly as
// JSON, and some must be encoded as [QCLASS] composites.
const passStyle = passStyleOf(passable);
switch (passStyle) {
case 'null': {
return null;
}
case 'undefined': {
return harden({ [QCLASS]: 'undefined' });
}
case 'string':
case 'boolean': {
return passable;
}
case 'number': {
if (Number.isNaN(passable)) {
return harden({ [QCLASS]: 'NaN' });
}
if (is(passable, -0)) {
return 0;
}
if (passable === Infinity) {
return harden({ [QCLASS]: 'Infinity' });
}
if (passable === -Infinity) {
return harden({ [QCLASS]: '-Infinity' });
}
return passable;
}
case 'bigint': {
return harden({
[QCLASS]: 'bigint',
digits: String(passable),
});
}
case 'symbol': {
assertPassableSymbol(passable);
const name = /** @type {string} */ (nameForPassableSymbol(passable));
return harden({
[QCLASS]: 'symbol',
name,
});
}
case 'copyRecord': {
if (QCLASS in passable) {
// Hilbert hotel
const { [QCLASS]: qclassValue, ...rest } = passable;
if (ownKeys(rest).length === 0) {
/** @type {Encoding} */
const result = harden({
[QCLASS]: 'hilbert',
original: encodeToJSON(qclassValue),
});
return result;
} else {
/** @type {Encoding} */
const result = harden({
[QCLASS]: 'hilbert',
original: encodeToJSON(qclassValue),
// See https://github.com/Agoric/agoric-sdk/issues/4313
rest: encodeToJSON(freeze(rest)),
});
return result;
}
}
// Currently copyRecord allows only string keys so this will
// work. If we allow sortable symbol keys, this will need to
// become more interesting.
const names = ownKeys(passable).sort();
return fromEntries(
names.map(name => [name, encodeToJSON(passable[name])]),
);
}
case 'copyArray': {
return passable.map(encodeToJSON);
}
case 'tagged': {
/** @type {Encoding} */
const result = harden({
[QCLASS]: 'tagged',
tag: getTag(passable),
payload: encodeToJSON(passable.payload),
});
return result;
}
case 'remotable': {
return encodeRemotableToJSON(passable);
}
case 'error': {
return encodeErrorToJSON(passable);
}
case 'promise': {
return encodePromiseToJSON(passable);
}
default: {
assert.fail(X`unrecognized passStyle ${q(passStyle)}`, TypeError);
}
}
};
return harden(encodeToJSON);
};
harden(makeEncodeToJSON);

/**
* @typedef {object} DecodeOptionsRecord
* @property {(encodedRemotable: Encoding) => (Promise|Remotable)} decodeRemotableFromJSON
* @property {(encodedPromise: Encoding) => (Promise|Remotable)} decodePromiseFromJSON
* @property {(encodedError: Encoding) => Error} decodeErrorFromJSON
*/

/**
* @typedef {Partial<DecodeOptionsRecord>} DecodeOptions
*/

/**
* @param {DecodeOptions=} decodeOptions
* @returns {(encoded: Encoding) => Passable}
*/
export const makeDecodeFromJSON = ({
decodeRemotableFromJSON = rem => assert.fail(X`remotable unexpected: ${rem}`),
decodePromiseFromJSON = rem => assert.fail(X`promise unexpected: ${rem}`),
decodeErrorFromJSON = err => assert.fail(X`error unexpected: ${err}`),
} = {}) => {
/**
* We stay close to the algorithm at
* https://tc39.github.io/ecma262/#sec-json.parse , where
* fullRevive(harden(JSON.parse(str))) is like JSON.parse(str, revive))
* for a similar reviver. But with the following differences:
*
* Rather than pass a reviver to JSON.parse, we first call a plain
* (one argument) JSON.parse to get rawTree, and then post-process
* the rawTree with fullRevive. The kind of revive function
* handled by JSON.parse only does one step in post-order, with
* JSON.parse doing the recursion. By contrast, fullParse does its
* own recursion in the same pre-order in which the replacer visited them.
*
* In order to break cycles, the potentially cyclic objects are
* not frozen during the recursion. Rather, the whole graph is
* hardened before being returned. Error objects are not
* potentially recursive, and so may be harmlessly hardened when
* they are produced.
*
* fullRevive can produce properties whose value is undefined,
* which a JSON.parse on a reviver cannot do. If a reviver returns
* undefined to JSON.parse, JSON.parse will delete the property
* instead.
*
* fullRevive creates and returns a new graph, rather than
* modifying the original tree in place.
*
* fullRevive may rely on rawTree being the result of a plain call
* to JSON.parse. However, it *cannot* rely on it having been
* produced by JSON.stringify on the replacer above, i.e., it
* cannot rely on it being a valid marshalled
* representation. Rather, fullRevive must validate that.
*
* @param {Encoding} jsonEncoded must be hardened
*/
const decodeFromJSON = jsonEncoded => {
if (!isObject(jsonEncoded)) {
// primitives pass through
return jsonEncoded;
}
// Assertions of the above to narrow the type.
assert.typeof(jsonEncoded, 'object');
assert(jsonEncoded !== null);
if (QCLASS in jsonEncoded) {
const qclass = jsonEncoded[QCLASS];
assert.typeof(
qclass,
'string',
X`invalid qclass typeof ${q(typeof qclass)}`,
);
assert(!isArray(jsonEncoded));
// Switching on `jsonEncoded[QCLASS]` (or anything less direct, like
// `qclass`) does not discriminate jsonEncoded in typescript@4.2.3 and
// earlier.
switch (jsonEncoded['@qclass']) {
// Encoding of primitives not handled by JSON
case 'undefined': {
return undefined;
}
case 'NaN': {
return NaN;
}
case 'Infinity': {
return Infinity;
}
case '-Infinity': {
return -Infinity;
}
case 'bigint': {
const { digits } = jsonEncoded;
assert.typeof(
digits,
'string',
X`invalid digits typeof ${q(typeof digits)}`,
);
return BigInt(digits);
}
case '@@asyncIterator': {
// Deprectated qclass. TODO make conditional
// on environment variable. Eventually remove, but after confident
// that there are no more supported senders.
return Symbol.asyncIterator;
}
case 'symbol': {
const { name } = jsonEncoded;
return passableSymbolForName(name);
}

case 'tagged': {
const { tag, payload } = jsonEncoded;
return makeTagged(tag, decodeFromJSON(payload));
}

case 'error': {
return decodeErrorFromJSON(jsonEncoded);
}

case 'slot': {
if ('iface' in jsonEncoded) {
return decodeRemotableFromJSON(jsonEncoded);
} else {
return decodePromiseFromJSON(jsonEncoded);
}
}

case 'hilbert': {
const { original, rest } = jsonEncoded;
assert(
'original' in jsonEncoded,
X`Invalid Hilbert Hotel encoding ${jsonEncoded}`,
);
// Don't harden since we're not done mutating it
const result = { [QCLASS]: decodeFromJSON(original) };
if ('rest' in jsonEncoded) {
assert(rest !== undefined, X`Rest encoding must not be undefined`);
const restObj = decodeFromJSON(rest);
// TODO really should assert that `passStyleOf(rest)` is
// `'copyRecord'` but we'd have to harden it and it is too
// early to do that.
assert(
!(QCLASS in restObj),
X`Rest must not contain its own definition of ${q(QCLASS)}`,
);
defineProperties(result, getOwnPropertyDescriptors(restObj));
}
return result;
}

default: {
assert(
// @ts-expect-error exhaustive check should make condition true
qclass !== 'ibid',
X`The protocol no longer supports ibid encoding: ${jsonEncoded}.`,
);
assert.fail(X`unrecognized ${q(QCLASS)} ${q(qclass)}`, TypeError);
}
}
} else if (isArray(jsonEncoded)) {
const result = [];
const { length } = jsonEncoded;
for (let i = 0; i < length; i += 1) {
result[i] = decodeFromJSON(jsonEncoded[i]);
}
return result;
} else {
const result = {};
for (const name of ownKeys(jsonEncoded)) {
assert.typeof(
name,
'string',
X`Property ${name} of ${jsonEncoded} must be a string`,
);
result[name] = decodeFromJSON(jsonEncoded[name]);
}
return result;
}
};
return harden(decodeFromJSON);
};
2 changes: 1 addition & 1 deletion packages/marshal/src/marshal-justin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// <reference types="ses"/>

import { Nat } from '@endo/nat';
import { QCLASS } from './marshal.js';
import { QCLASS } from './encodeToJSON.js';

import { getErrorConstructor } from './helpers/error.js';
import { isObject } from './helpers/passStyle-helpers.js';
Expand Down
Loading

0 comments on commit 7c298e2

Please sign in to comment.