diff --git a/packages/marshal/src/builders/builder-types.js b/packages/marshal/src/builders/builder-types.js new file mode 100644 index 0000000000..5ca9478b32 --- /dev/null +++ b/packages/marshal/src/builders/builder-types.js @@ -0,0 +1,41 @@ +/** @typedef {import('@endo/pass-style').Remotable} Remotable */ + +/** + * @template N + * @template R + * @typedef {object} Builder + * @property {(buildTopFn: () => N) => R} buildRoot + * + * @property {() => N} buildUndefined + * @property {() => N} buildNull + * @property {(num: number) => N} buildNumber + * @property {(flag: boolean) => N} buildBoolean + * @property {(bigint: bigint) => N} buildBigint + * @property {(str: string) => N} buildString + * @property {(sym: symbol) => N} buildSymbol + * + * @property {(names: string[], buildValuesIter: Iterable) => N} buildRecord + * The recognizer must pass the actual property names through. It is + * up to the builder whether it wants to encode them. + * It is up to the recognizer to sort the entries by their actual + * property name first, and to encode their values in the resulting + * sorted order. The builder may assume that sorted order. + * @property {(count: number, buildElementsIter: Iterable) => N} buildArray + * @property {(tagName: string, buildPayloadFn: () => N) => N} buildTagged + * The recognizer must pass the actual tagName through. It is + * up to the builder whether it wants to encode it. + * + * @property {(error :Error) => N} buildError + * @property {(remotable: Remotable) => N} buildRemotable + * @property {(promise: Promise) => N} buildPromise + */ + +/** + * @template E + * @template N + * @template R + * @callback Recognize + * @param {E} encoding + * @param {Builder} builder + * @returns {R} + */ diff --git a/packages/marshal/src/builders/justinBuilder.js b/packages/marshal/src/builders/justinBuilder.js new file mode 100644 index 0000000000..53310e8bde --- /dev/null +++ b/packages/marshal/src/builders/justinBuilder.js @@ -0,0 +1,132 @@ +/// + +import { Far, getInterfaceOf, nameForPassableSymbol } from '@endo/pass-style'; +import { + identPattern, + AtAtPrefixPattern, + makeNoIndenter, + makeYesIndenter, +} from '../marshal-justin.js'; + +const { stringify } = JSON; +const { Fail, quote: q } = assert; +const { is } = Object; + +export const makeJustinBuilder = (shouldIndent = false, _slots = []) => { + let out; + let slotIndex; + const outNextJSON = val => out.next(stringify(val)); + + /** @type {Builder} */ + const justinBuilder = Far('JustinBuilder', { + buildRoot: buildTopFn => { + const makeIndenter = shouldIndent ? makeYesIndenter : makeNoIndenter; + out = makeIndenter(); + slotIndex = -1; + buildTopFn(); + return out.done(); + }, + + buildUndefined: () => out.next('undefined'), + buildNull: () => out.next('null'), + buildBoolean: outNextJSON, + buildNumber: num => { + if (num === Infinity) { + return out.next('Infinity'); + } else if (num === -Infinity) { + return out.next('-Infinity'); + } else if (is(num, NaN)) { + return out.next('NaN'); + } else { + return out.next(stringify(num)); + } + }, + buildBigint: bigint => out.next(`${bigint}n`), + buildString: outNextJSON, + buildSymbol: sym => { + assert.typeof(sym, 'symbol'); + const name = nameForPassableSymbol(sym); + if (name === undefined) { + throw Fail`Symbol must be either registered or well known: ${q(sym)}`; + } + const registeredName = Symbol.keyFor(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(`Symbol.for(${stringify(registeredName)})`); + }, + + buildRecord: (names, buildValuesIter) => { + if (names.length === 0) { + return out.next('{}'); + } + out.open('{'); + const iter = buildValuesIter[Symbol.iterator](); + for (const name of names) { + 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(`${stringify(name)}:`); + } + const { value: _, done } = iter.next(); + if (done) { + break; + } + out.next(','); + } + return out.close('}'); + }, + buildArray: (count, buildElementsIter) => { + if (count === 0) { + return out.next('[]'); + } + out.open('['); + const iter = buildElementsIter[Symbol.iterator](); + for (let i = 0; ; i += 1) { + if (i < count) { + out.line(); + } + const { value: _, done } = iter.next(); + if (done) { + break; + } + out.next(','); + } + return out.close(']'); + }, + buildTagged: (tagName, buildPayloadFn) => { + out.next(`makeTagged(${stringify(tagName)},`); + buildPayloadFn(); + return out.next(')'); + }, + + buildError: error => out.next(`${error.name}(${stringify(error.message)})`), + buildRemotable: remotable => { + slotIndex += 1; + return out.next( + `slot(${slotIndex},${stringify(getInterfaceOf(remotable))})`, + ); + }, + buildPromise: _promise => { + slotIndex += 1; + return out.next(`slot(${slotIndex})`); + }, + }); + return justinBuilder; +}; +harden(makeJustinBuilder); + +export const makeBuilder = () => makeJustinBuilder(); +harden(makeBuilder); diff --git a/packages/marshal/src/builders/smallcapsBuilder.js b/packages/marshal/src/builders/smallcapsBuilder.js new file mode 100644 index 0000000000..36a2417f91 --- /dev/null +++ b/packages/marshal/src/builders/smallcapsBuilder.js @@ -0,0 +1,239 @@ +/// + +import { + assertPassableSymbol, + Far, + getErrorConstructor, + hasOwnPropertyOf, + mapIterable, + nameForPassableSymbol, + passableSymbolForName, +} from '@endo/pass-style'; +import { + startsSpecial, + encodeStringToSmallcaps as buildString, +} from '../encodeToSmallcaps.js'; + +/** @typedef {import('../encodeToSmallcaps.js').SmallcapsEncoding} SmallcapsEncoding */ + +const { is, fromEntries } = Object; +const { isArray } = Array; +const { ownKeys } = Reflect; +const { quote: q, details: X, Fail } = assert; + +const makeSmallcapsBuilder = () => { + /** @type {Builder} */ + const smallcapsBuilder = Far('SmallcapsBuilder', { + buildRoot: buildTopFn => buildTopFn(), + + buildUndefined: () => '#undefined', + buildNull: () => null, + buildBoolean: flag => flag, + buildNumber: num => { + // Special-case numbers with no digit-based representation. + if (Number.isNaN(num)) { + return '#NaN'; + } else if (num === Infinity) { + return '#Infinity'; + } else if (num === -Infinity) { + return '#-Infinity'; + } + // Pass through everything else, replacing -0 with 0. + return is(num, -0) ? 0 : num; + }, + buildBigint: bigint => { + const str = String(bigint); + return bigint < 0n ? str : `+${str}`; + }, + buildString, + buildSymbol: sym => { + assertPassableSymbol(sym); + const name = /** @type {string} */ (nameForPassableSymbol(sym)); + return `%${name}`; + }, + buildRecord: (names, buildValuesIter) => { + const builtValues = [...buildValuesIter]; + assert(names.length === builtValues.length); + // TODO Should be fromUniqueEntries, but utils needs to be + // relocated first. + return fromEntries(names.map((name, i) => [name, builtValues[i]])); + }, + buildArray: (_count, buildElementsIter) => harden([...buildElementsIter]), + buildTagged: (tagName, buildPayloadFn) => ({ + '#tag': buildString(tagName), + payload: buildPayloadFn(), + }), + + // TODO slots and options and all that. Also errorId + buildError: error => ({ + '#error': buildString(error.message), + name: buildString(error.name), + }), + // TODO slots and options and all that. + buildRemotable: _remotable => '$', + // TODO slots and options and all that. + buildPromise: _promise => '&', + }); + return smallcapsBuilder; +}; +harden(makeSmallcapsBuilder); + +/** + * Must be consistent with the full string recognition algorithm. + * Must return what that algorithm would pass to builder.buildString. + * + * @param {string} str + */ +const recognizeString = str => { + typeof str === 'string' || Fail`${str} must be a string`; + if (!startsSpecial(str)) { + return str; + } + const c = str.charAt(0); + c === '!' || Fail`${str} must encode a string`; + return str.slice(1); +}; + +const makeSmallcapsRecognizer = () => { + /** + * @param {SmallcapsEncoding} encoding + * @param {Builder} builder + * @returns {SmallcapsEncoding} + */ + const recognizeNode = (encoding, builder) => { + switch (typeof encoding) { + case 'boolean': { + return builder.buildBoolean(encoding); + } + case 'number': { + return builder.buildNumber(encoding); + } + case 'string': { + if (!startsSpecial(encoding)) { + return builder.buildString(encoding); + } + const c = encoding.charAt(0); + switch (c) { + case '!': { + // un-hilbert-ify the string + return builder.buildString(encoding.slice(1)); + } + case '%': { + const sym = passableSymbolForName(encoding.slice(1)); + assert(sym !== undefined); + return builder.buildSymbol(sym); + } + case '#': { + switch (encoding) { + case '#undefined': { + return builder.buildUndefined(); + } + case '#NaN': { + return builder.buildNumber(NaN); + } + case '#Infinity': { + return builder.buildNumber(Infinity); + } + case '#-Infinity': { + return builder.buildNumber(-Infinity); + } + default: { + throw assert.fail( + X`unknown constant "${q(encoding)}"`, + TypeError, + ); + } + } + } + case '+': + case '-': { + return builder.buildBigint(BigInt(encoding)); + } + case '$': { + // TODO slots and options and all that. + return builder.buildRemotable(Far('dummy')); + } + case '&': { + // TODO slots and options and all that. + return builder.buildPromise(Promise.resolve('dummy')); + } + default: { + throw Fail`Special char ${q( + c, + )} reserved for future use: ${encoding}`; + } + } + } + case 'object': { + if (encoding === null) { + return builder.buildNull(); + } + + if (isArray(encoding)) { + const buildElementsIter = mapIterable(encoding, val => + recognizeNode(val, builder), + ); + return builder.buildArray(encoding.length, buildElementsIter); + } + + if (hasOwnPropertyOf(encoding, '#tag')) { + const { '#tag': tag, payload, ...rest } = encoding; + ownKeys(rest).length === 0 || + Fail`#tag record unexpected properties: ${q(ownKeys(rest))}`; + const buildPayloadFn = () => recognizeNode(payload, builder); + return builder.buildTagged(recognizeString(tag), buildPayloadFn); + } + + if (hasOwnPropertyOf(encoding, '#error')) { + // TODO slots and options and all that. Also errorId + const { '#error': message, name } = encoding; + const dMessage = recognizeString(message); + const dName = recognizeString(name); + const EC = getErrorConstructor(dName) || Error; + const errorName = `Remote${EC.name}`; + const error = assert.error(dMessage, EC, { errorName }); + return builder.buildError(error); + } + + const encodedNames = /** @type {string[]} */ (ownKeys(encoding)).sort(); + for (const encodedName of encodedNames) { + typeof encodedName === 'string' || + Fail`Property name ${q( + encodedName, + )} of ${encoding} must be a string`; + !encodedName.startsWith('#') || + Fail`Unrecognized record type ${q(encodedName)}: ${encoding}`; + } + const buildValuesIter = mapIterable(encodedNames, encodedName => + recognizeNode(encoding[encodedName], builder), + ); + return builder.buildRecord( + encodedNames.map(recognizeString), + buildValuesIter, + ); + } + default: { + throw assert.fail( + X`internal: unrecognized JSON typeof ${q( + typeof encoding, + )}: ${encoding}`, + TypeError, + ); + } + } + }; + /** + * @param {SmallcapsEncoding} encoding + * @param {Builder} builder + * @returns {SmallcapsEncoding} + */ + const recognizeSmallcaps = (encoding, builder) => + builder.buildRoot(() => recognizeNode(encoding, builder)); + return harden(recognizeSmallcaps); +}; +harden(makeSmallcapsRecognizer); + +export { + makeSmallcapsBuilder as makeBuilder, + makeSmallcapsRecognizer as makeRecognizer, +}; diff --git a/packages/marshal/src/builders/subgraphBuilder.js b/packages/marshal/src/builders/subgraphBuilder.js new file mode 100644 index 0000000000..5701efd27b --- /dev/null +++ b/packages/marshal/src/builders/subgraphBuilder.js @@ -0,0 +1,129 @@ +/// + +import { + Far, + getTag, + makeTagged, + mapIterable, + passStyleOf, +} from '@endo/pass-style'; + +/** @typedef {import('@endo/pass-style').Passable} Passable */ + +const { fromEntries } = Object; +const { ownKeys } = Reflect; +const { quote: q, details: X } = assert; + +const makeSubgraphBuilder = () => { + const ident = val => val; + + /** @type {Builder} */ + const subgraphBuilder = Far('SubgraphBuilder', { + buildRoot: buildTopFn => buildTopFn(), + + buildUndefined: () => undefined, + buildNull: () => null, + buildBoolean: ident, + buildNumber: ident, + buildBigint: ident, + buildString: ident, + buildSymbol: ident, + buildRecord: (names, buildValuesIter) => { + const builtValues = [...buildValuesIter]; + assert(names.length === builtValues.length); + const builtEntries = names.map((name, i) => [name, builtValues[i]]); + return harden(fromEntries(builtEntries)); + }, + buildArray: (_count, buildElementsIter) => harden([...buildElementsIter]), + buildTagged: (tagName, buildPayloadFn) => + makeTagged(tagName, buildPayloadFn()), + + buildError: ident, + buildRemotable: ident, + buildPromise: ident, + }); + return subgraphBuilder; +}; +harden(makeSubgraphBuilder); + +const makeSubgraphRecognizer = () => { + /** + * @param {any} passable + * @param {Builder} builder + * @returns {Passable} + */ + const recognizeNode = (passable, builder) => { + // First we handle all primitives. Some can be represented directly as + // JSON, and some must be encoded into smallcaps strings. + const passStyle = passStyleOf(passable); + switch (passStyle) { + case 'null': { + return builder.buildNull(); + } + case 'boolean': { + return builder.buildBoolean(passable); + } + case 'string': { + return builder.buildString(passable); + } + case 'undefined': { + return builder.buildUndefined(); + } + case 'number': { + return builder.buildNumber(passable); + } + case 'bigint': { + return builder.buildBigint(passable); + } + case 'symbol': { + return builder.buildSymbol(passable); + } + case 'copyRecord': { + const names = /** @type {string[]} */ (ownKeys(passable)).sort(); + const buildValuesIter = mapIterable(names, name => + recognizeNode(passable[name], builder), + ); + return builder.buildRecord(names, buildValuesIter); + } + case 'copyArray': { + const buildElementsIter = mapIterable(passable, el => + recognizeNode(el, builder), + ); + return builder.buildArray(passable.length, buildElementsIter); + } + case 'tagged': { + const buildPayloadFn = () => recognizeNode(passable.payload, builder); + return builder.buildTagged(getTag(passable), buildPayloadFn); + } + case 'remotable': { + return builder.buildRemotable(passable); + } + case 'promise': { + return builder.buildPromise(passable); + } + case 'error': { + return builder.buildError(passable); + } + default: { + throw assert.fail( + X`internal: Unrecognized passStyle ${q(passStyle)}`, + TypeError, + ); + } + } + }; + /** + * @param {Passable} passable + * @param {Builder} builder + * @returns {Passable} + */ + const recognizeSubgraph = (passable, builder) => + builder.buildRoot(() => recognizeNode(passable, builder)); + return harden(recognizeSubgraph); +}; +harden(makeSubgraphRecognizer); + +export { + makeSubgraphBuilder as makeBuilder, + makeSubgraphRecognizer as makeRecognizer, +}; diff --git a/packages/marshal/src/encodeToSmallcaps.js b/packages/marshal/src/encodeToSmallcaps.js index ae6f88c61a..4b5b89a2df 100644 --- a/packages/marshal/src/encodeToSmallcaps.js +++ b/packages/marshal/src/encodeToSmallcaps.js @@ -76,7 +76,7 @@ const DASH = '-'.charCodeAt(0); * @param {string} encodedStr * @returns {boolean} */ -const startsSpecial = encodedStr => { +export const startsSpecial = encodedStr => { if (encodedStr === '') { return false; } @@ -86,6 +86,19 @@ const startsSpecial = encodedStr => { return BANG <= code && code <= DASH; }; +export const encodeStringToSmallcaps = str => { + if (startsSpecial(str)) { + // Strings that start with a special char are quoted with `!`. + // Since `!` is itself a special character, this trivially does + // the Hilbert hotel. Also, since the special characters are + // a continuous subrange of ascii, this quoting is sort-order + // preserving. + return `!${str}`; + } + // All other strings pass through to JSON + return str; +}; + /** * @typedef {object} EncodeToSmallcapsOptions * @property {( @@ -180,16 +193,7 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { return passable; } case 'string': { - if (startsSpecial(passable)) { - // Strings that start with a special char are quoted with `!`. - // Since `!` is itself a special character, this trivially does - // the Hilbert hotel. Also, since the special characters are - // a continuous subrange of ascii, this quoting is sort-order - // preserving. - return `!${passable}`; - } - // All other strings pass through to JSON - return passable; + return encodeStringToSmallcaps(passable); } case 'undefined': { return '#undefined'; @@ -216,13 +220,12 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { return `%${name}`; } case 'copyRecord': { - // Currently copyRecord allows only string keys so this will - // work. If we allow sortable symbol keys, this will need to - // become more interesting. + // copyRecord allows only string keys so this will + // work. const names = ownKeys(passable).sort(); return fromEntries( names.map(name => [ - encodeToSmallcapsRecur(name), + encodeStringToSmallcaps(name), encodeToSmallcapsRecur(passable[name]), ]), ); @@ -232,7 +235,7 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { } case 'tagged': { return { - '#tag': encodeToSmallcapsRecur(getTag(passable)), + '#tag': encodeStringToSmallcaps(getTag(passable)), payload: encodeToSmallcapsRecur(passable.payload), }; } diff --git a/packages/marshal/src/marshal-justin.js b/packages/marshal/src/marshal-justin.js index 054e689d90..cbc23a319f 100644 --- a/packages/marshal/src/marshal-justin.js +++ b/packages/marshal/src/marshal-justin.js @@ -30,7 +30,7 @@ const { quote: q, details: X, Fail } = assert; * * @returns {Indenter} */ -const makeYesIndenter = () => { +export const makeYesIndenter = () => { const strings = []; let level = 0; let needSpace = false; @@ -85,7 +85,7 @@ const badPairPattern = /^(?:\w\w|<<|>>|\+\+|--|)$/; * * @returns {Indenter} */ -const makeNoIndenter = () => { +export const makeNoIndenter = () => { /** @type {string[]} */ const strings = []; return harden({ @@ -114,9 +114,9 @@ const makeNoIndenter = () => { }); }; -const identPattern = /^[a-zA-Z]\w*$/; +export const identPattern = /^[a-zA-Z]\w*$/; harden(identPattern); -const AtAtPrefixPattern = /^@@(.*)$/; +export const AtAtPrefixPattern = /^@@(.*)$/; harden(AtAtPrefixPattern); /** diff --git a/packages/marshal/test/builders/test-justin-builder.js b/packages/marshal/test/builders/test-justin-builder.js new file mode 100644 index 0000000000..cfd72456c6 --- /dev/null +++ b/packages/marshal/test/builders/test-justin-builder.js @@ -0,0 +1,57 @@ +import { test } from '../prepare-test-env-ava.js'; + +import * as js from '../../src/builders/subgraphBuilder.js'; +import { makeJustinBuilder } from '../../src/builders/justinBuilder.js'; +import { makeMarshal } from '../../src/marshal.js'; +import { decodeToJustin } from '../../src/marshal-justin.js'; +import { + fakeJustinCompartment, + justinPairs, +} from '../test-marshal-justin-builder.js'; + +// this only includes the tests that do not use liveSlots + +test('justin builder round trip pairs', t => { + const jsRecognizer = js.makeRecognizer(); + const { toCapData } = makeMarshal(undefined, undefined, { + // We're turning `errorTagging`` off only for the round trip tests, not in + // general. + errorTagging: 'off', + // TODO retire the old format in justin test cases + serializeBodyFormat: 'capdata', + }); + for (const [body, justinSrc, slots] of justinPairs) { + const c = fakeJustinCompartment(); + const encoding = JSON.parse(body); + const justinExpr = decodeToJustin(encoding, false, slots); + t.is(justinExpr, justinSrc); + const value = harden(c.evaluate(`(${justinExpr})`)); + const { body: newBody } = toCapData(value); + t.is(newBody, body); + + const justinBuilder = makeJustinBuilder(false, slots); + t.is(jsRecognizer(value, justinBuilder), justinExpr); + } +}); + +test('justin indented builder round trip pairs', t => { + const jsRecognizer = js.makeRecognizer(); + const { toCapData } = makeMarshal(undefined, undefined, { + // We're turning `errorTagging`` off only for the round trip tests, not in + // general. + errorTagging: 'off', + // TODO retire the old format in justin test cases + serializeBodyFormat: 'capdata', + }); + for (const [body, _, slots] of justinPairs) { + const c = fakeJustinCompartment(); + const encoding = JSON.parse(body); + const justinExpr = decodeToJustin(encoding, true, slots); + const value = harden(c.evaluate(`(${justinExpr})`)); + const { body: newBody } = toCapData(value); + t.is(newBody, body); + + const justinBuilder = makeJustinBuilder(true, slots); + t.is(jsRecognizer(value, justinBuilder), justinExpr); + } +}); diff --git a/packages/marshal/test/builders/test-smallcaps-builder.js b/packages/marshal/test/builders/test-smallcaps-builder.js new file mode 100644 index 0000000000..846845af4f --- /dev/null +++ b/packages/marshal/test/builders/test-smallcaps-builder.js @@ -0,0 +1,40 @@ +import { test } from '../prepare-test-env-ava.js'; + +import * as sc from '../../src/builders/smallcapsBuilder.js'; +import * as js from '../../src/builders/subgraphBuilder.js'; +import { roundTripPairs } from '../test-marshal-capdata.js'; +import { makeSmallcapsTestMarshal } from '../test-marshal-smallcaps-builder.js'; + +const { isFrozen } = Object; + +test('smallcaps builder', t => { + const scBuilder = sc.makeBuilder(); + const scRecognizer = sc.makeRecognizer(); + t.is(scRecognizer('#Infinity', scBuilder), '#Infinity'); + + const jsBuilder = js.makeBuilder(); + const jsRecognizer = js.makeRecognizer(); + t.is(jsRecognizer(Infinity, jsBuilder), Infinity); + t.is(scRecognizer('#Infinity', jsBuilder), Infinity); + t.is(jsRecognizer(Infinity, scBuilder), '#Infinity'); +}); + +test('smallcaps builder round trip half pairs', t => { + const scBuilder = sc.makeBuilder(); + const scRecognizer = sc.makeRecognizer(); + const jsBuilder = js.makeBuilder(); + const jsRecognizer = js.makeRecognizer(); + const { toCapData, fromCapData } = makeSmallcapsTestMarshal(); + for (const [plain, _] of roundTripPairs) { + const { body } = toCapData(plain); + const encoding = JSON.parse(body.slice(1)); + const decoding = fromCapData({ body, slots: [] }); + t.deepEqual(decoding, plain); + t.assert(isFrozen(decoding)); + + t.deepEqual(scRecognizer(encoding, scBuilder), encoding); + t.deepEqual(jsRecognizer(plain, jsBuilder), plain); + t.deepEqual(scRecognizer(encoding, jsBuilder), plain); + t.deepEqual(jsRecognizer(plain, scBuilder), encoding); + } +}); diff --git a/packages/marshal/test/test-marshal-justin-builder.js b/packages/marshal/test/test-marshal-justin-builder.js new file mode 100644 index 0000000000..a194dda641 --- /dev/null +++ b/packages/marshal/test/test-marshal-justin-builder.js @@ -0,0 +1,161 @@ +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { Remotable, makeTagged } from '@endo/pass-style'; +import { makeMarshal } from '../src/marshal.js'; +import { decodeToJustin } from '../src/marshal-justin.js'; + +// this only includes the tests that do not use liveSlots + +/** + * Based on roundTripPairs from test-marshal-capdata.js + * + * A list of `[body, justinSrc]` pairs, where the body parses into + * an encoding that decodes to a Justin expression that evaluates to something + * that has the same encoding. + * + * @type {([string, string] | [string, string, unknown[]])[]} + */ +export const justinPairs = harden([ + // Justin is the same as the JSON encoding but without unnecessary quoting + ['[1,2]', '[1,2]'], + ['{"foo":1}', '{foo:1}'], + ['{"a":1,"b":2}', '{a:1,b:2}'], + ['{"a":1,"b":{"c":3}}', '{a:1,b:{c:3}}'], + ['true', 'true'], + ['1', '1'], + ['"abc"', '"abc"'], + ['null', 'null'], + + // Primitives not representable in JSON + ['{"@qclass":"undefined"}', 'undefined'], + ['{"@qclass":"NaN"}', 'NaN'], + ['{"@qclass":"Infinity"}', 'Infinity'], + ['{"@qclass":"-Infinity"}', '-Infinity'], + ['{"@qclass":"bigint","digits":"4"}', '4n'], + ['{"@qclass":"bigint","digits":"9007199254740993"}', '9007199254740993n'], + ['{"@qclass":"symbol","name":"@@asyncIterator"}', 'Symbol.asyncIterator'], + ['{"@qclass":"symbol","name":"@@match"}', 'Symbol.match'], + ['{"@qclass":"symbol","name":"foo"}', 'Symbol.for("foo")'], + ['{"@qclass":"symbol","name":"@@@@foo"}', 'Symbol.for("@@foo")'], + + // Arrays and objects + ['[{"@qclass":"undefined"}]', '[undefined]'], + ['{"foo":{"@qclass":"undefined"}}', '{foo:undefined}'], + ['{"@qclass":"error","message":"","name":"Error"}', 'Error("")'], + [ + '{"@qclass":"error","message":"msg","name":"ReferenceError"}', + 'ReferenceError("msg")', + ], + + // The one case where JSON is not a semantic subset of JS + ['{"__proto__":8}', '{["__proto__"]:8}'], + + // The Hilbert Hotel is always tricky + ['{"@qclass":"hilbert","original":8}', '{"@qclass":8}'], + ['{"@qclass":"hilbert","original":"@qclass"}', '{"@qclass":"@qclass"}'], + [ + '{"@qclass":"hilbert","original":{"@qclass":"hilbert","original":8}}', + '{"@qclass":{"@qclass":8}}', + ], + [ + '{"@qclass":"hilbert","original":{"@qclass":"hilbert","original":8,"rest":{"foo":"foo1"}},"rest":{"bar":{"@qclass":"hilbert","original":{"@qclass":"undefined"}}}}', + '{"@qclass":{"@qclass":8,foo:"foo1"},bar:{"@qclass":undefined}}', + ], + + // tagged + ['{"@qclass":"tagged","tag":"x","payload":8}', 'makeTagged("x",8)'], + [ + '{"@qclass":"tagged","tag":"x","payload":{"@qclass":"undefined"}}', + 'makeTagged("x",undefined)', + ], + + // TODO Make Justin Slots work + // // Slots + // [ + // '[{"@qclass":"slot","iface":"Alleged: for testing Justin","index":0}]', + // '[slot(0,"Alleged: for testing Justin")]', + // ], + // // More Slots + // [ + // '[{"@qclass":"slot","iface":"Alleged: for testing Justin","index":0},{"@qclass":"slot","iface":"Remotable","index":1}]', + // '[slotToVal("hello","Alleged: for testing Justin"),slotToVal(null,"Remotable")]', + // ['hello', null], + // ], + // // Tests https://github.com/endojs/endo/issues/1185 fix + // [ + // '[{"@qclass":"slot","iface":"Alleged: for testing Justin","index":0},{"@qclass":"slot","index":0}]', + // '[slot(0,"Alleged: for testing Justin"),slot(0)]', + // ], +]); + +export const fakeJustinCompartment = () => { + const slots = []; + const slotVals = new Map(); + const populateSlot = (index, iface) => { + assert.typeof(iface, 'string'); // Assumes not optional the first time + const r = Remotable(iface, undefined, { getIndex: () => index }); + const s = `s${index}`; + slotVals.set(s, r); + slots[index] = s; + return r; + }; + const slot = (index, iface = undefined) => { + if (slots[index] !== undefined) { + assert(iface === undefined); // Assumes backrefs omit iface + return slotVals.get(slots[index]); + } + return populateSlot(index, iface); + }; + const slotToVal = (s, iface = undefined) => { + if (slotVals.has(s)) { + assert(iface === undefined); // Assumes backrefs omit iface + return slotVals.get(s); + } + return populateSlot(slots.length, iface); + }; + return new Compartment({ slot, slotToVal, makeTagged }); +}; + +test('serialize decodeToJustin eval round trip pairs', t => { + const { toCapData } = makeMarshal(undefined, undefined, { + // We're turning `errorTagging`` off only for the round trip tests, not in + // general. + errorTagging: 'off', + // TODO make Justin work with smallcaps + serializeBodyFormat: 'capdata', + }); + for (const [body, justinSrc, slots] of justinPairs) { + const c = fakeJustinCompartment(); + const encoding = JSON.parse(body); + const justinExpr = decodeToJustin(encoding, false, slots); + t.is(justinExpr, justinSrc); + const value = harden(c.evaluate(`(${justinExpr})`)); + const { body: newBody } = toCapData(value); + t.is(newBody, body); + } +}); + +// Like "serialize decodeToJustin eval round trip pairs" but uses the indented +// representation *without* checking its specific whitespace decisions. +// Just checks that it has equivalent evaluation, and +// that the decoder passes the extra `level` balancing diagnostic in +// `makeYesIndenter`. +test('serialize decodeToJustin indented eval round trip', t => { + const { toCapData } = makeMarshal(undefined, undefined, { + // We're turning `errorTagging`` off only for the round trip tests, not in + // general. + errorTagging: 'off', + // TODO make Justin work with smallcaps + serializeBodyFormat: 'capdata', + }); + for (const [body, _, slots] of justinPairs) { + const c = fakeJustinCompartment(); + t.log(body); + const encoding = JSON.parse(body); + const justinExpr = decodeToJustin(encoding, true, slots); + const value = harden(c.evaluate(`(${justinExpr})`)); + const { body: newBody } = toCapData(value); + t.is(newBody, body); + } +}); diff --git a/packages/marshal/test/test-marshal-smallcaps-builder.js b/packages/marshal/test/test-marshal-smallcaps-builder.js new file mode 100644 index 0000000000..f794341ab2 --- /dev/null +++ b/packages/marshal/test/test-marshal-smallcaps-builder.js @@ -0,0 +1,419 @@ +import { test } from './prepare-test-env-ava.js'; + +// eslint-disable-next-line import/order +import { Far, makeTagged, passStyleOf } from '@endo/pass-style'; +import { makeMarshal } from '../src/marshal.js'; + +import { roundTripPairs } from './test-marshal-capdata.js'; + +const { freeze, isFrozen, create, prototype: objectPrototype } = Object; + +// this only includes the tests that do not use liveSlots + +/** + * @param {import('../src/types.js').MakeMarshalOptions} [opts] + */ +export const makeSmallcapsTestMarshal = (opts = { errorTagging: 'off' }) => + makeMarshal(undefined, undefined, { + serializeBodyFormat: 'smallcaps', + marshalSaveError: _err => {}, + ...opts, + }); + +test('smallcaps serialize unserialize round trip half pairs', t => { + const { toCapData, fromCapData } = makeSmallcapsTestMarshal(); + for (const [plain, _] of roundTripPairs) { + const { body } = toCapData(plain); + const decoding = fromCapData({ body, slots: [] }); + t.deepEqual(decoding, plain); + t.assert(isFrozen(decoding)); + } +}); + +test('smallcaps serialize static data', t => { + const { toCapData } = makeSmallcapsTestMarshal(); + const ser = val => toCapData(val); + + // @ts-ignore `isFake` purposely omitted from type + if (!harden.isFake) { + t.throws(() => ser([1, 2]), { + message: /Cannot pass non-frozen objects like/, + }); + } + // -0 serialized as 0 + t.deepEqual(ser(0), { body: '#0', slots: [] }); + t.deepEqual(ser(-0), { body: '#0', slots: [] }); + t.deepEqual(ser(-0), ser(0)); + // unregistered symbols + t.throws(() => ser(Symbol('sym2')), { + // An anonymous symbol is not Passable + message: /Only registered symbols or well-known symbols are passable:/, + }); + + const cd = ser(harden([1, 2])); + t.is(isFrozen(cd), true); + t.is(isFrozen(cd.slots), true); +}); + +test('smallcaps unserialize static data', t => { + const { fromCapData } = makeSmallcapsTestMarshal(); + const uns = body => fromCapData({ body, slots: [] }); + + // should be frozen + const arr = uns('#[1,2]'); + t.truthy(isFrozen(arr)); + const a = uns('#{"b":{"c":{"d": []}}}'); + t.truthy(isFrozen(a)); + t.truthy(isFrozen(a.b)); + t.truthy(isFrozen(a.b.c)); + t.truthy(isFrozen(a.b.c.d)); +}); + +test('smallcaps serialize errors', t => { + const { toCapData } = makeSmallcapsTestMarshal(); + const ser = val => toCapData(val); + + t.deepEqual(ser(harden(Error())), { + body: '#{"#error":"","name":"Error"}', + slots: [], + }); + + t.deepEqual(ser(harden(ReferenceError('msg'))), { + body: '#{"#error":"msg","name":"ReferenceError"}', + slots: [], + }); + + t.deepEqual(ser(harden(ReferenceError('#msg'))), { + body: '#{"#error":"!#msg","name":"ReferenceError"}', + slots: [], + }); + + const myError = harden({ + __proto__: Error.prototype, + message: 'mine', + }); + t.deepEqual(ser(myError), { + body: '#{"#error":"mine","name":"Error"}', + slots: [], + }); + + // TODO Once https://github.com/Agoric/SES-shim/issues/579 is merged + // do a golden of the error notes generated by the following + + // Extra properties + const errExtra = Error('has extra properties'); + // @ts-ignore Check dynamic consequences of type violation + errExtra.foo = []; + freeze(errExtra); + t.assert(isFrozen(errExtra)); + // @ts-ignore `isFake` purposely omitted from type + if (!harden.isFake) { + // @ts-ignore Check dynamic consequences of type violation + t.falsy(isFrozen(errExtra.foo)); + } + t.deepEqual(ser(errExtra), { + body: '#{"#error":"has extra properties","name":"Error"}', + slots: [], + }); + // @ts-ignore `isFake` purposely omitted from type + if (!harden.isFake) { + // @ts-ignore Check dynamic consequences of type violation + t.falsy(isFrozen(errExtra.foo)); + } + + // Bad prototype and bad "message" property + const nonErrorProto1 = { __proto__: Error.prototype, name: 'included' }; + const nonError1 = { __proto__: nonErrorProto1, message: [] }; + t.deepEqual(ser(harden(nonError1)), { + body: '#{"#error":"","name":"included"}', + slots: [], + }); +}); + +test('smallcaps unserialize errors', t => { + const { fromCapData } = makeSmallcapsTestMarshal(); + const uns = body => fromCapData({ body, slots: [] }); + + const em1 = uns('#{"#error":"msg","name":"ReferenceError"}'); + t.truthy(em1 instanceof ReferenceError); + t.is(em1.message, 'msg'); + t.truthy(isFrozen(em1)); + + const em2 = uns('#{"#error":"msg2","name":"TypeError"}'); + t.truthy(em2 instanceof TypeError); + t.is(em2.message, 'msg2'); + + const em3 = uns('#{"#error":"msg3","name":"Unknown"}'); + t.truthy(em3 instanceof Error); + t.is(em3.message, 'msg3'); +}); + +test('smallcaps mal-formed @qclass', t => { + const { fromCapData } = makeSmallcapsTestMarshal(); + const uns = body => fromCapData({ body, slots: [] }); + t.throws(() => uns('#{"#foo": 0}'), { + message: 'Unrecognized record type "#foo": {"#foo":0}', + }); +}); + +test('smallcaps records', t => { + const fauxPresence = Far('Alice', {}); + const { toCapData: ser, fromCapData: unser } = makeMarshal( + _val => 'slot', + _slot => fauxPresence, + { + errorTagging: 'off', + serializeBodyFormat: 'smallcaps', + }, + ); + + const emptyData = { body: '#{}', slots: [] }; + + function build(descriptors) { + const o = create(objectPrototype, descriptors); + return harden(o); + } + + const enumData = { enumerable: true, value: 'data' }; + const enumGetData = { enumerable: true, get: () => 0 }; + const enumGetFunc = { enumerable: true, get: () => () => 0 }; + const enumSet = { enumerable: true, set: () => undefined }; + const nonenumData = { enumerable: false, value: 3 }; + + const ERR_NOACCESSORS = { message: /must not be an accessor property:/ }; + const ERR_ONLYENUMERABLE = { message: /must be an enumerable property:/ }; + const ERR_REMOTABLE = { message: /cannot serialize Remotables/ }; + + // empty objects + + // @ts-ignore `isFake` purposely omitted from type + if (!harden.isFake) { + // rejected because it is not hardened + t.throws( + () => ser({}), + { message: /Cannot pass non-frozen objects/ }, + 'non-frozen data cannot be serialized', + ); + } + + // harden({}) + t.deepEqual(ser(build()), emptyData); + + const stringData = { body: '#{"enumData":"data"}', slots: [] }; + + // serialized data should roundtrip properly + t.deepEqual(unser(ser(harden({}))), {}); + t.deepEqual(unser(ser(harden({ enumData: 'data' }))), { enumData: 'data' }); + + // unserialized data can be serialized again + t.deepEqual(ser(unser(emptyData)), emptyData); + t.deepEqual(ser(unser(stringData)), stringData); + + // { key: data } + // all: pass-by-copy without warning + t.deepEqual(ser(build({ enumData })), { + body: '#{"enumData":"data"}', + slots: [], + }); + + // anything with an accessor is rejected + t.throws(() => ser(build({ enumGetData })), ERR_NOACCESSORS); + t.throws(() => ser(build({ enumGetData, enumData })), ERR_NOACCESSORS); + t.throws(() => ser(build({ enumSet })), ERR_NOACCESSORS); + t.throws(() => ser(build({ enumSet, enumData })), ERR_NOACCESSORS); + + // anything with non-enumerable properties is rejected + t.throws(() => ser(build({ nonenumData })), ERR_ONLYENUMERABLE); + t.throws(() => ser(build({ nonenumData, enumData })), ERR_ONLYENUMERABLE); + + // anything with a function-returning getter is treated as remotable + t.throws(() => ser(build({ enumGetFunc })), ERR_REMOTABLE); + t.throws(() => ser(build({ enumGetFunc, enumData })), ERR_REMOTABLE); + + const desertTopping = harden({ + '#tag': 'floor wax', + payload: 'what is it?', + '#error': 'old joke', + }); + const body = `#${JSON.stringify(desertTopping)}`; + t.throws(() => unser({ body, slots: [] }), { + message: '#tag record unexpected properties: ["#error"]', + }); +}); + +/** + * A test case to illustrate each of the encodings + * * `!` - escaped string + * * `+` - non-negative bigint + * * `-` - negative bigint + * * `#` - manifest constant + * * `%` - symbol + * * `$` - remotable + * * `&` - promise + */ +test('smallcaps encoding examples', t => { + const { toCapData, fromCapData } = makeMarshal( + val => val, + slot => slot, + { + errorTagging: 'off', + serializeBodyFormat: 'smallcaps', + }, + ); + + const assertSer = (val, body, slots, message) => + t.deepEqual(toCapData(val), { body, slots }, message); + + const assertRoundTrip = (val, body, slots, message) => { + assertSer(val, body, slots, message); + const val2 = fromCapData(harden({ body, slots })); + assertSer(val2, body, slots, message); + t.deepEqual(val, val2, message); + }; + + // Numbers + assertRoundTrip(0, '#0', [], 'zero'); + assertRoundTrip(500n, '#"+500"', [], 'bigint'); + assertRoundTrip(-400n, '#"-400"', [], '-bigint'); + + // Constants + assertRoundTrip(NaN, '#"#NaN"', [], 'NaN'); + assertRoundTrip(Infinity, '#"#Infinity"', [], 'Infinity'); + assertRoundTrip(-Infinity, '#"#-Infinity"', [], '-Infinity'); + assertRoundTrip(undefined, '#"#undefined"', [], 'undefined'); + + // Strings + assertRoundTrip('unescaped', '#"unescaped"', [], 'unescaped'); + assertRoundTrip('#escaped', `#"!#escaped"`, [], 'escaped #'); + assertRoundTrip('+escaped', `#"!+escaped"`, [], 'escaped +'); + assertRoundTrip('-escaped', `#"!-escaped"`, [], 'escaped -'); + assertRoundTrip('%escaped', `#"!%escaped"`, [], 'escaped %'); + + // Symbols + assertRoundTrip(Symbol.iterator, '#"%@@iterator"', [], 'well known symbol'); + assertRoundTrip(Symbol.for('foo'), '#"%foo"', [], 'reg symbol'); + assertRoundTrip( + Symbol.for('@@foo'), + '#"%@@@@foo"', + [], + 'reg symbol that looks well known', + ); + + // Remotables + const foo = Far('foo', {}); + const bar = Far('bar', { + [Symbol.toStringTag]: 'DebugName: Bart', + }); + assertRoundTrip(foo, '#"$0.Alleged: foo"', [foo], 'Remotable object'); + assertRoundTrip( + harden([foo, bar, foo, bar]), + '#["$0.Alleged: foo","$1.DebugName: Bart","$0","$1"]', + [foo, bar], + 'Only show iface once', + ); + + // Promises + const p = harden(Promise.resolve(null)); + assertRoundTrip(p, '#"&0"', [p], 'Promise'); + + // Arrays + assertRoundTrip(harden([1, 2n]), '#[1,"+2"]', [], 'array'); + + // Records + assertRoundTrip( + harden({ foo: 1, bar: 2n }), + '#{"bar":"+2","foo":1}', + [], + 'record', + ); + + // Tagged + const taggedFoo = makeTagged('foo', 'bar'); + assertRoundTrip(taggedFoo, '#{"#tag":"foo","payload":"bar"}', [], 'tagged'); + const taggedBangFoo = makeTagged('!foo', '!bar'); + assertRoundTrip( + taggedBangFoo, + '#{"#tag":"!!foo","payload":"!!bar"}', + [], + 'tagged', + ); + + // Error + const err1 = harden(URIError('bad uri')); + assertRoundTrip(err1, '#{"#error":"bad uri","name":"URIError"}', [], 'error'); + const err2 = harden(Error('#NaN')); + assertRoundTrip(err2, '#{"#error":"!#NaN","name":"Error"}', [], 'error'); + + // non-passable errors alone still serialize + const nonPassableErr = Error('foo'); + // @ts-expect-error this type error is what we're testing + nonPassableErr.extraProperty = 'something bad'; + harden(nonPassableErr); + t.throws(() => passStyleOf(nonPassableErr), { + message: + 'Passed Error has extra unpassed properties {"extraProperty":{"configurable":false,"enumerable":true,"value":"something bad","writable":false}}', + }); + assertSer( + nonPassableErr, + '#{"#error":"foo","name":"Error"}', + [], + 'non passable errors pass', + ); + // pseudo-errors + const pseudoErr1 = harden({ + __proto__: Error.prototype, + message: '$not real', + name: 'URIError', + }); + assertSer( + pseudoErr1, + '#{"#error":"!$not real","name":"URIError"}', + [], + 'pseudo error', + ); + const pseudoErr2 = harden({ + __proto__: Error.prototype, + message: '$not real', + name: '#MyError', + }); + assertSer( + pseudoErr2, + '#{"#error":"!$not real","name":"!#MyError"}', + [], + 'pseudo error', + ); + + // Hilbert record + assertRoundTrip( + harden({ + '#tag': 'what', + '#error': 'me', + '#huh': 'worry', + '': 'empty', + '%sym': 'not a symbol', + }), + '#{"":"empty","!#error":"me","!#huh":"worry","!#tag":"what","!%sym":"not a symbol"}', + [], + 'hilbert property names', + ); +}); + +test('smallcaps proto problems', t => { + const exampleAlice = Far('Alice', {}); + const { toCapData: toSmallcaps, fromCapData: fromSmallcaps } = makeMarshal( + _val => 'slot', + _slot => exampleAlice, + { + errorTagging: 'off', + serializeBodyFormat: 'smallcaps', + }, + ); + const wrongProto = harden({ ['__proto__']: exampleAlice }); + const wrongProtoSmallcaps = toSmallcaps(wrongProto); + t.deepEqual(wrongProtoSmallcaps, { + body: '#{"__proto__":"$0.Alleged: Alice"}', + slots: ['slot'], + }); + // Fails before https://github.com/endojs/endo/issues/1303 fix + t.deepEqual(fromSmallcaps(wrongProtoSmallcaps), wrongProto); +}); diff --git a/packages/marshal/test/test-marshal-smallcaps.js b/packages/marshal/test/test-marshal-smallcaps.js index ebc164a00b..97c6da09db 100644 --- a/packages/marshal/test/test-marshal-smallcaps.js +++ b/packages/marshal/test/test-marshal-smallcaps.js @@ -356,7 +356,7 @@ test('smallcaps encoding examples', t => { harden(nonPassableErr); t.throws(() => passStyleOf(nonPassableErr), { message: - /Passed Error has extra unpassed properties {"extraProperty":{"configurable":.*,"enumerable":true,"value":"something bad","writable":.*}}/, + 'Passed Error has extra unpassed properties {"extraProperty":{"configurable":false,"enumerable":true,"value":"something bad","writable":false}}', }); assertSer( nonPassableErr,