diff --git a/packages/marshal/src/encodePassable.js b/packages/marshal/src/encodePassable.js index d5863af1ec..04ae8cf067 100644 --- a/packages/marshal/src/encodePassable.js +++ b/packages/marshal/src/encodePassable.js @@ -10,7 +10,7 @@ import { } from '@endo/pass-style'; /** - * @import {CopyRecord, PassStyle, Passable, RemotableObject as Remotable} from '@endo/pass-style' + * @import {CopyRecord, PassStyle, Passable, RemotableObject as Remotable, ByteArray} from '@endo/pass-style' */ import { b, q, Fail } from '@endo/errors'; @@ -462,6 +462,17 @@ const decodeLegacyArray = (encoded, decodePassable, skip = 0) => { return harden(elements); }; +/** + * @param {ByteArray} byteArray + * @param {(byteArray: ByteArray) => string} _encodePassable + * @returns {string} + */ +const encodeByteArray = (byteArray, _encodePassable) => { + // TODO implement + Fail`encodePassable(copyData) not yet implemented: ${byteArray}`; + return ''; // Just for the type +}; + const encodeRecord = (record, encodeArray, encodePassable) => { const names = recordNames(record); const values = recordValues(record, names); @@ -626,6 +637,9 @@ const makeInnerEncode = (encodeStringSuffix, encodeArray, options) => { case 'copyArray': { return encodeArray(passable, innerEncode); } + case 'byteArray': { + return encodeByteArray(passable, innerEncode); + } case 'copyRecord': { return encodeRecord(passable, encodeArray, innerEncode); } @@ -870,6 +884,7 @@ export const passStylePrefixes = { tagged: ':', promise: '?', copyArray: '[^', + byteArray: 'a', boolean: 'b', number: 'f', bigint: 'np', diff --git a/packages/marshal/src/encodeToCapData.js b/packages/marshal/src/encodeToCapData.js index 052b5795a1..c76e8a33b0 100644 --- a/packages/marshal/src/encodeToCapData.js +++ b/packages/marshal/src/encodeToCapData.js @@ -194,6 +194,10 @@ export const makeEncodeToCapData = (encodeOptions = {}) => { case 'copyArray': { return passable.map(encodeToCapDataRecur); } + case 'byteArray': { + // TODO implement + throw Fail`marsal of byteArray not yet implemented: ${passable}`; + } case 'tagged': { return { [QCLASS]: 'tagged', diff --git a/packages/marshal/src/encodeToSmallcaps.js b/packages/marshal/src/encodeToSmallcaps.js index 9122e56c79..f4dec69ea4 100644 --- a/packages/marshal/src/encodeToSmallcaps.js +++ b/packages/marshal/src/encodeToSmallcaps.js @@ -229,6 +229,10 @@ export const makeEncodeToSmallcaps = (encodeOptions = {}) => { case 'copyArray': { return passable.map(encodeToSmallcapsRecur); } + case 'byteArray': { + // TODO implement + throw Fail`marsal of byteArray not yet implemented: ${passable}`; + } case 'tagged': { return { '#tag': encodeToSmallcapsRecur(getTag(passable)), diff --git a/packages/marshal/src/rankOrder.js b/packages/marshal/src/rankOrder.js index 2018833417..f24c7cc03a 100644 --- a/packages/marshal/src/rankOrder.js +++ b/packages/marshal/src/rankOrder.js @@ -55,6 +55,8 @@ export const trivialComparator = (left, right) => const passStyleRanks = /** @type {PassStyleRanksRecord} */ ( fromEntries( entries(passStylePrefixes) + // TODO Until byteArray prefix is chosen + .filter(([_style, prefixes]) => prefixes.length >= 1) // Sort entries by ascending prefix. .sort(([_leftStyle, leftPrefixes], [_rightStyle, rightPrefixes]) => { return trivialComparator(leftPrefixes, rightPrefixes); @@ -209,6 +211,26 @@ export const makeComparatorKit = (compareRemotables = (_x, _y) => 0) => { // If array X is a prefix of array Y, then X has an earlier rank than Y. return comparator(left.length, right.length); } + case 'byteArray': { + const leftArray = new Uint8Array(left.slice(0)); + const rightArray = new Uint8Array(right.slice(0)); + const byteLen = Math.min(left.byteLength, right.byteLength); + for (let i = 0; i < byteLen; i += 1) { + const leftByte = leftArray[i]; + const rightByte = rightArray[i]; + if (leftByte < rightByte) { + return -1; + } + if (leftByte > rightByte) { + return 1; + } + } + // If all corresponding bytes are the same, + // then according to their lengths. + // Thus, if the data of ByteArray X is a prefix of + // the data of ByteArray Y, then X is smaller than Y. + return comparator(left.byteLength, right.byteLength); + } case 'tagged': { // Lexicographic by `[Symbol.toStringTag]` then `.payload`. const labelComp = comparator(getTag(left), getTag(right)); diff --git a/packages/marshal/test/marshal-stringify.test.js b/packages/marshal/test/marshal-stringify.test.js index e72dd2a1a9..f96c449e89 100644 --- a/packages/marshal/test/marshal-stringify.test.js +++ b/packages/marshal/test/marshal-stringify.test.js @@ -38,11 +38,13 @@ test('marshal stringify errors', t => { t.throws(() => stringify({}), { message: /Cannot pass non-frozen objects like .*. Use harden()/, }); - // @ts-expect-error intentional error + // at-ts-ignore rather than at-expect-error because of disagreement + // @ts-ignore intentional error t.throws(() => stringify(harden(new Uint8Array(1))), { message: 'Cannot pass mutable typed arrays like "[Uint8Array]".', }); - // @ts-expect-error intentional error + // at-ts-ignore rather than at-expect-error because of disagreement + // @ts-ignore intentional error t.throws(() => stringify(harden(new Int16Array(1))), { message: 'Cannot pass mutable typed arrays like "[Int16Array]".', }); diff --git a/packages/pass-style/package.json b/packages/pass-style/package.json index 1da9aceae4..a57e7dcfd9 100644 --- a/packages/pass-style/package.json +++ b/packages/pass-style/package.json @@ -37,6 +37,7 @@ "@endo/env-options": "workspace:^", "@endo/errors": "workspace:^", "@endo/eventual-send": "workspace:^", + "@endo/immutable-arraybuffer": "workspace:^", "@endo/promise-kit": "workspace:^", "@fast-check/ava": "^1.1.5" }, diff --git a/packages/pass-style/src/byteArray.js b/packages/pass-style/src/byteArray.js new file mode 100644 index 0000000000..37a9a3752b --- /dev/null +++ b/packages/pass-style/src/byteArray.js @@ -0,0 +1,57 @@ +import { X } from '@endo/errors'; +import { + transferBufferToImmutable, + isBufferImmutable, +} from '@endo/immutable-arraybuffer'; +import { assertChecker } from './passStyle-helpers.js'; + +const { getPrototypeOf, getOwnPropertyDescriptor } = Object; +const { ownKeys, apply } = Reflect; + +const AnImmutableArrayBuffer = transferBufferToImmutable(new ArrayBuffer(0)); + +/** + * As proposed, this will be the same as `ArrayBuffer.prototype`. As shimmed, + * this will be a hidden intrinsic that inherits from `ArrayBuffer.prototype`. + * Either way, get this in a way that we can trust it after lockdown, and + * require that all immutable ArrayBuffers directly inherit from it. + */ +const ImmutableArrayBufferPrototype = getPrototypeOf(AnImmutableArrayBuffer); + +// @ts-expect-error ok to implicitly assert the access is found +const immutableGetter = getOwnPropertyDescriptor( + ImmutableArrayBufferPrototype, + 'immutable', +).get; + +/** + * @param {unknown} candidate + * @param {import('./types.js').Checker} [check] + * @returns {boolean} + */ +const canBeValid = (candidate, check = undefined) => + (candidate instanceof ArrayBuffer && isBufferImmutable(candidate)) || + (!!check && check(false, X`Immutable ArrayBuffer expected: ${candidate}`)); + +/** + * @type {import('./internal-types.js').PassStyleHelper} + */ +export const ByteArrayHelper = harden({ + styleName: 'byteArray', + + canBeValid, + + assertValid: (candidate, _passStyleOfRecur) => { + canBeValid(candidate, assertChecker); + getPrototypeOf(candidate) === ImmutableArrayBufferPrototype || + assert.fail(X`Malformed ByteArray ${candidate}`, TypeError); + // @ts-expect-error assume immutableGetter was found + apply(immutableGetter, candidate, []) || + assert.fail(X`Must be an immutable ArrayBuffer: ${candidate}`); + ownKeys(candidate).length === 0 || + assert.fail( + X`ByteArrays must not have own properties: ${candidate}`, + TypeError, + ); + }, +}); diff --git a/packages/pass-style/src/deeplyFulfilled.js b/packages/pass-style/src/deeplyFulfilled.js index bd65a270bd..c08ab3df76 100644 --- a/packages/pass-style/src/deeplyFulfilled.js +++ b/packages/pass-style/src/deeplyFulfilled.js @@ -6,7 +6,7 @@ import { passStyleOf } from './passStyleOf.js'; import { makeTagged } from './makeTagged.js'; /** - * @import {Passable, Primitive, CopyRecord, CopyArray, CopyTagged, RemotableObject} from '@endo/pass-style' + * @import {Passable, ByteArray, CopyRecord, CopyArray, CopyTagged, RemotableObject} from '@endo/pass-style' */ const { ownKeys } = Reflect; @@ -105,6 +105,13 @@ export const deeplyFulfilled = async val => { // @ts-expect-error not assignable to type 'DeeplyAwaited' return E.when(Promise.all(valPs), vals => harden(vals)); } + case 'byteArray': { + const bytes = /** @type {ByteArray} */ (val); + // @ts-expect-error Why + // "Type 'ArrayBuffer' is not assignable to type 'DeeplyAwaited'."? + // TODO fix. + return bytes; + } case 'tagged': { const tgd = /** @type {CopyTagged} */ (val); const tag = getTag(tgd); diff --git a/packages/pass-style/src/passStyleOf.js b/packages/pass-style/src/passStyleOf.js index 7c4dd78b2e..4ca6f665e2 100644 --- a/packages/pass-style/src/passStyleOf.js +++ b/packages/pass-style/src/passStyleOf.js @@ -7,6 +7,7 @@ import { X, Fail, q, annotateError, makeError } from '@endo/errors'; import { isObject, isTypedArray, PASS_STYLE } from './passStyle-helpers.js'; import { CopyArrayHelper } from './copyArray.js'; +import { ByteArrayHelper } from './byteArray.js'; import { CopyRecordHelper } from './copyRecord.js'; import { TaggedHelper } from './tagged.js'; import { @@ -43,6 +44,7 @@ const makeHelperTable = passStyleHelpers => { const HelperTable = { __proto__: null, copyArray: undefined, + byteArray: undefined, copyRecord: undefined, tagged: undefined, error: undefined, @@ -216,6 +218,7 @@ export const passStyleOf = (globalThis && globalThis[PassStyleOfEndowmentSymbol]) || makePassStyleOf([ CopyArrayHelper, + ByteArrayHelper, CopyRecordHelper, TaggedHelper, ErrorHelper, diff --git a/packages/pass-style/src/typeGuards.js b/packages/pass-style/src/typeGuards.js index facff7b81a..eb12e821e4 100644 --- a/packages/pass-style/src/typeGuards.js +++ b/packages/pass-style/src/typeGuards.js @@ -1,7 +1,9 @@ import { Fail, q } from '@endo/errors'; import { passStyleOf } from './passStyleOf.js'; -/** @import {CopyArray, CopyRecord, Passable, RemotableObject} from './types.js' */ +/** + * @import {CopyArray, CopyRecord, Passable, RemotableObject, ByteArray} from './types.js' + */ /** * Check whether the argument is a pass-by-copy array, AKA a "copyArray" @@ -13,6 +15,16 @@ import { passStyleOf } from './passStyleOf.js'; const isCopyArray = arr => passStyleOf(arr) === 'copyArray'; harden(isCopyArray); +/** + * Check whether the argument is a pass-by-copy binary data, AKA a "byteArray" + * in @endo/marshal terms + * + * @param {Passable} arr + * @returns {arr is ByteArray} + */ +const isByteArray = arr => passStyleOf(arr) === 'byteArray'; +harden(isByteArray); + /** * Check whether the argument is a pass-by-copy record, AKA a * "copyRecord" in @endo/marshal terms @@ -47,6 +59,24 @@ const assertCopyArray = (array, optNameOfArray = 'Alleged array') => { harden(assertCopyArray); /** + * @callback AssertByteArray + * @param {Passable} array + * @param {string=} optNameOfArray + * @returns {asserts array is ByteArray} + */ + +/** @type {AssertByteArray} */ +const assertByteArray = (array, optNameOfArray = 'Alleged byteArray') => { + const passStyle = passStyleOf(array); + passStyle === 'byteArray' || + Fail`${q( + optNameOfArray, + )} ${array} must be a pass-by-copy binary data, not ${q(passStyle)}`; +}; +harden(assertByteArray); + +/** + * @callback AssertRecord * @param {any} record * @param {string=} optNameOfRecord * @returns {asserts record is CopyRecord} @@ -80,8 +110,10 @@ harden(assertRemotable); export { assertRecord, assertCopyArray, + assertByteArray, assertRemotable, isRemotable, isRecord, isCopyArray, + isByteArray, }; diff --git a/packages/pass-style/src/types.d.ts b/packages/pass-style/src/types.d.ts index fdcd419a74..5adbc7c7b5 100644 --- a/packages/pass-style/src/types.d.ts +++ b/packages/pass-style/src/types.d.ts @@ -22,7 +22,11 @@ export type PrimitiveStyle = | 'string' | 'symbol'; -export type ContainerStyle = 'copyRecord' | 'copyArray' | 'tagged'; +export type ContainerStyle = + | 'copyRecord' + | 'copyArray' + | 'byteArray' + | 'tagged'; export type PassStyle = | PrimitiveStyle @@ -49,6 +53,7 @@ export type PassByCopy = | Primitive | Error | CopyArray + | ByteArray | CopyRecord | CopyTagged; @@ -67,6 +72,7 @@ export type PassByRef = * | 'string' | 'symbol'). (Passable considers `void` to be `undefined`.) * * Containers aggregate other Passables into * * sequences as CopyArrays (PassStyle 'copyArray'), or + * * sequences of 8-bit bytes (PassStyle 'byteArray'), or * * string-keyed dictionaries as CopyRecords (PassStyle 'copyRecord'), or * * higher-level types as CopyTaggeds (PassStyle 'tagged'). * * PassableCaps (PassStyle 'remotable' | 'promise') expose local values to @@ -86,10 +92,12 @@ export type Passable< export type Container = | CopyArrayI + | ByteArrayI | CopyRecordI | CopyTaggedI; interface CopyArrayI extends CopyArray> {} +interface ByteArrayI extends ByteArray {} interface CopyRecordI extends CopyRecord> {} interface CopyTaggedI @@ -116,10 +124,9 @@ export type PassStyleOf = { /** * A Passable is PureData when its entire data structure is free of PassableCaps * (remotables and promises) and error objects. - * PureData is an arbitrary composition of primitive values into CopyArray - * and/or - * CopyRecord and/or CopyTagged containers (or a single primitive value with no - * container), and is fully pass-by-copy. + * PureData is an arbitrary composition of primitive values into CopyArray, + * ByteArray, CopyRecord, and/or CopyTagged containers + * (or a single primitive value with no container), and is fully pass-by-copy. * * This restriction assures absence of side effects and interleaving risks *given* * that none of the containers can be a Proxy instance. @@ -156,6 +163,11 @@ export type PassableCap = Promise | RemotableObject; */ export type CopyArray = Array; +/** + * A `ByteArray` is a normal hardened immutable `ArrayBuffer` + */ +export type ByteArray = ArrayBuffer; + /** * A Passable dictionary in which each key is a string and each value is Passable. */ diff --git a/packages/patterns/src/keys/checkKey.js b/packages/patterns/src/keys/checkKey.js index 0ef9622b71..b645e237a9 100644 --- a/packages/patterns/src/keys/checkKey.js +++ b/packages/patterns/src/keys/checkKey.js @@ -551,6 +551,9 @@ const checkKeyInternal = (val, check) => { // A copyArray is a key iff all its children are keys return val.every(checkIt); } + case 'byteArray': { + return true; + } case 'tagged': { const tag = getTag(val); switch (tag) { diff --git a/packages/patterns/src/keys/compareKeys.js b/packages/patterns/src/keys/compareKeys.js index b68bc3b84c..1cd9cba9a5 100644 --- a/packages/patterns/src/keys/compareKeys.js +++ b/packages/patterns/src/keys/compareKeys.js @@ -147,6 +147,30 @@ export const compareKeys = (left, right) => { // @ts-expect-error narrowed return compareRank(left.length, right.length); } + case 'byteArray': { + // @ts-expect-error narrowed + const leftArray = new Uint8Array(left.slice(0)); + // @ts-expect-error narrowed + const rightArray = new Uint8Array(right.slice(0)); + // @ts-expect-error narrowed + const byteLen = Math.min(left.byteLength, right.byteLength); + for (let i = 0; i < byteLen; i += 1) { + const leftByte = leftArray[i]; + const rightByte = rightArray[i]; + if (leftByte < rightByte) { + return -1; + } + if (leftByte > rightByte) { + return 1; + } + } + // If all corresponding bytes are the same, + // then according to their lengths. + // Thus, if the data of ByteArray X is a prefix of + // the data of ByteArray Y, then X is smaller than Y. + // @ts-expect-error narrowed + return compareRank(left.byteLength, right.byteLength); + } case 'copyRecord': { // Pareto partial order comparison. // @ts-expect-error narrowed diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index 8cc6815f7f..5df1cfe2b8 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -70,6 +70,7 @@ export const defaultLimits = harden({ numPropertiesLimit: 80, propertyNameLengthLimit: 100, arrayLengthLimit: 10_000, + byteLengthLimit: 100_000, numSetElementsLimit: 10_000, numUniqueBagElementsLimit: 10_000, numMapEntriesLimit: 5000, @@ -366,6 +367,9 @@ const makePatternKit = () => { // patterns return patt.every(checkIt); } + case 'byteArray': { + return true; + } case 'copyMap': { // A copyMap's keys are keys and therefore already known to be // patterns. @@ -441,6 +445,7 @@ const makePatternKit = () => { case 'bigint': case 'string': case 'symbol': + case 'byteArray': case 'copySet': case 'copyBag': case 'remotable': { @@ -622,6 +627,10 @@ const makePatternKit = () => { // ]); break; } + case 'byteArray': { + // TODO implement + throw Fail`getCover of byteArray not yet implemented`; + } case 'copyRecord': { // XXX this doesn't get along with the world of cover === pair of // strings. In the meantime, fall through to the default which @@ -1190,6 +1199,34 @@ const makePatternKit = () => { getRankCover: () => getPassStyleCover('copyArray'), }); + /** @type {MatchHelper} */ + const matchBytesHelper = Far('match:bytes helper', { + checkMatches: (specimen, [limits = undefined], check) => { + const { byteLengthLimit } = limit(limits); + // prettier-ignore + return ( + checkKind(specimen, 'byteArray', check) && + // eslint-disable-next-line @endo/restrict-comparison-operands + (/** @type {import('./types.js').ByteArray} */ (specimen).byteLength <= byteLengthLimit || + check( + false, + X`bytes ${specimen} must not be bigger than ${byteLengthLimit}`, + )) + ); + }, + + checkIsWellFormed: (payload, check) => + checkIsWellFormedWithLimit( + payload, + harden([]), + check, + 'match:bytes payload', + ), + + getRankCover: (_matchPayload, _encodePassable) => + getPassStyleCover('string'), + }); + /** @type {MatchHelper} */ const matchSetOfHelper = Far('match:setOf helper', { checkMatches: (specimen, [keyPatt, limits = undefined], check) => { @@ -1545,6 +1582,7 @@ const makePatternKit = () => { 'match:gt': matchGTHelper, 'match:arrayOf': matchArrayOfHelper, + 'match:bytes': matchBytesHelper, 'match:recordOf': matchRecordOfHelper, 'match:setOf': matchSetOfHelper, 'match:bagOf': matchBagOfHelper, @@ -1573,6 +1611,7 @@ const makePatternKit = () => { const SymbolShape = makeTagged('match:symbol', []); const RecordShape = makeTagged('match:recordOf', [AnyShape, AnyShape]); const ArrayShape = makeTagged('match:arrayOf', [AnyShape]); + const BytesShape = makeTagged('match:bytes', []); const SetShape = makeTagged('match:setOf', [AnyShape]); const BagShape = makeTagged('match:bagOf', [AnyShape, AnyShape]); const MapShape = makeTagged('match:mapOf', [AnyShape, AnyShape]); @@ -1666,6 +1705,8 @@ const makePatternKit = () => { // For example, a pattern that matches CopyArrays of length 2 that have a // string at index 0 and a number at index 1 is: // harden([ M.string(), M.number() ]). + bytes: (limits = undefined) => + limits ? makeLimitsMatcher('match:bytes', [limits]) : BytesShape, set: (limits = undefined) => (limits ? M.setOf(M.any(), limits) : SetShape), bag: (limits = undefined) => limits ? M.bagOf(M.any(), M.any(), limits) : BagShape, diff --git a/packages/patterns/src/types.js b/packages/patterns/src/types.js index cbb4b6fa19..44532ca227 100644 --- a/packages/patterns/src/types.js +++ b/packages/patterns/src/types.js @@ -12,7 +12,7 @@ export {}; * @typedef {Exclude, Error | Promise>} Key * * Keys are Passable arbitrarily-nested pass-by-copy containers - * (CopyArray, CopyRecord, CopySet, CopyBag, CopyMap) in which every + * (CopyArray, ByteArray, CopyRecord, CopySet, CopyBag, CopyMap) in which every * non-container leaf is either a Passable primitive value or a Remotable (a * remotely-accessible object or presence for a remote object), or such leaves * in isolation with no container. @@ -55,7 +55,7 @@ export {}; * @typedef {Exclude} Pattern * * Patterns are Passable arbitrarily-nested pass-by-copy containers - * (CopyArray, CopyRecord, CopySet, CopyBag, CopyMap) in which every + * (CopyArray, ByteArray, CopyRecord, CopySet, CopyBag, CopyMap) in which every * non-container leaf is either a Key or a Matcher, or such leaves in isolation * with no container. * @@ -215,6 +215,7 @@ export {}; * @property {number} numPropertiesLimit * @property {number} propertyNameLengthLimit * @property {number} arrayLengthLimit + * @property {number} byteLengthLimit * @property {number} numSetElementsLimit * @property {number} numUniqueBagElementsLimit * @property {number} numMapEntriesLimit @@ -307,6 +308,9 @@ export {}; * @property {(limits?: Limits) => Matcher} array * Matches any CopyArray, subject to limits. * + * @property {(limits?: Limits) => Matcher} bytes + * Matches any ByteArray, subject to limits. + * * @property {(limits?: Limits) => Matcher} set * Matches any CopySet, subject to limits. * diff --git a/yarn.lock b/yarn.lock index 009ab5a775..586087291b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -523,7 +523,7 @@ __metadata: languageName: unknown linkType: soft -"@endo/immutable-arraybuffer@workspace:packages/immutable-arraybuffer": +"@endo/immutable-arraybuffer@workspace:^, @endo/immutable-arraybuffer@workspace:packages/immutable-arraybuffer": version: 0.0.0-use.local resolution: "@endo/immutable-arraybuffer@workspace:packages/immutable-arraybuffer" dependencies: @@ -702,6 +702,7 @@ __metadata: "@endo/env-options": "workspace:^" "@endo/errors": "workspace:^" "@endo/eventual-send": "workspace:^" + "@endo/immutable-arraybuffer": "workspace:^" "@endo/init": "workspace:^" "@endo/promise-kit": "workspace:^" "@endo/ses-ava": "workspace:^"