diff --git a/packages/marshal/src/deeplyFulfilled.js b/packages/marshal/src/deeplyFulfilled.js index 15873be355..5cbcfa966a 100644 --- a/packages/marshal/src/deeplyFulfilled.js +++ b/packages/marshal/src/deeplyFulfilled.js @@ -63,6 +63,9 @@ export const deeplyFulfilled = async val => { const valPs = val.map(p => deeplyFulfilled(p)); return E.when(Promise.all(valPs), vals => harden(vals)); } + case 'byteArray': { + return val; + } case 'tagged': { // @ts-expect-error FIXME narrowed const tag = getTag(val); diff --git a/packages/marshal/src/encodePassable.js b/packages/marshal/src/encodePassable.js index 002d3be0f7..0f2eda2198 100644 --- a/packages/marshal/src/encodePassable.js +++ b/packages/marshal/src/encodePassable.js @@ -16,6 +16,7 @@ import { * @template {Passable} [T=Passable] * @typedef {import('@endo/pass-style').CopyRecord} CopyRecord */ +/** @typedef {import('@endo/pass-style').ByteArray} ByteArray */ /** @typedef {import('./types.js').RankCover} RankCover */ const { quote: q, Fail } = assert; @@ -270,6 +271,18 @@ const decodeArray = (encoded, decodePassable) => { return harden(elements); }; +/** + * @param {ByteArray} byteArray + * @param {(byteArray: ByteArray) => string} _encodePassable + * @returns {string} + */ +const encodeByteArray = (byteArray, _encodePassable) => { + // TODO implement + throw Fail`encodePassable(copyData) not yet implemented: ${byteArray}`; + // eslint-disable-next-line no-unreachable + return ''; // Just for the type +}; + const encodeRecord = (record, encodePassable) => { const names = recordNames(record); const values = recordValues(record, names); @@ -381,6 +394,9 @@ export const makeEncodePassable = (encodeOptions = {}) => { case 'copyArray': { return encodeArray(passable, encodePassable); } + case 'byteArray': { + return encodeByteArray(passable, encodePassable); + } case 'copyRecord': { return encodeRecord(passable, encodePassable); } @@ -500,6 +516,7 @@ export const passStylePrefixes = { tagged: ':', promise: '?', copyArray: '[', + byteArray: '', // TODO pick a prefix boolean: 'b', number: 'f', bigint: 'np', diff --git a/packages/marshal/src/encodeToCapData.js b/packages/marshal/src/encodeToCapData.js index a8303a5667..72b1f95f88 100644 --- a/packages/marshal/src/encodeToCapData.js +++ b/packages/marshal/src/encodeToCapData.js @@ -196,6 +196,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 ae6f88c61a..5bc8541b44 100644 --- a/packages/marshal/src/encodeToSmallcaps.js +++ b/packages/marshal/src/encodeToSmallcaps.js @@ -230,6 +230,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 f8638c9b2c..e92569442b 100644 --- a/packages/marshal/src/rankOrder.js +++ b/packages/marshal/src/rankOrder.js @@ -57,6 +57,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); @@ -224,6 +226,30 @@ export const makeComparatorKit = (compareRemotables = (_x, _y) => 0) => { // @ts-expect-error FIXME narrowed return comparator(left.length, right.length); } + case 'byteArray': { + // @ts-expect-error FIXME narrowed + const leftArray = new Uint8Array(left.slice()); + // @ts-expect-error FIXME narrowed + const rightArray = new Uint8Array(right.slice()); + // @ts-expect-error FIXME 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 FIXME narrowed + return comparator(left.byteLength, right.byteLength); + } case 'tagged': { // Lexicographic by `[Symbol.toStringTag]` then `.payload`. // @ts-expect-error FIXME narrowed diff --git a/packages/pass-style/src/byteArray.js b/packages/pass-style/src/byteArray.js new file mode 100644 index 0000000000..b5a5528b79 --- /dev/null +++ b/packages/pass-style/src/byteArray.js @@ -0,0 +1,85 @@ +/// + +import { PASS_STYLE, assertChecker } from './passStyle-helpers.js'; + +/** @typedef {import('./types.js').ByteArray} ByteArray */ + +const { Fail } = assert; +const { setPrototypeOf } = Object; +const { apply } = Reflect; + +/** + * @type {WeakSet} + */ +const genuineByteArray = new WeakSet(); + +const slice = ArrayBuffer.prototype.slice; +const sliceOf = (buffer, start, end) => apply(slice, buffer, [start, end]); + +/** + * A ByteArray is much like an ArrayBuffer, but immutable. + * It cannot be used as an ArrayBuffer argument when a genuine ArrayBuffer is + * needed. But a `byteArray.slice()` is a genuine ArrayBuffer, initially with + * a copy of the copyByte's data. + * + * On platforms that support freezing ArrayBuffer, like perhaps a future XS, + * (TODO) the intention is that `byteArray` could hold on to a single frozen + * one and return it for every call to `arrayBuffer.slice`, rather than making + * a fresh copy each time. + * + * @param {ArrayBuffer} arrayBuffer + * @returns {ByteArray} + */ +export const makeByteArray = arrayBuffer => { + try { + // Both validates and gets an exclusive copy. + // This `arrayBuffer` must not escape, to emulate immutability. + arrayBuffer = sliceOf(arrayBuffer); + } catch { + Fail`Expected genuine ArrayBuffer" ${arrayBuffer}`; + } + /** @type {ByteArray} */ + const byteArray = { + // Can't say it this way because it confuses TypeScript + // __proto__: ArrayBuffer.prototype, + byteLength: arrayBuffer.byteLength, + slice(start, end) { + return sliceOf(arrayBuffer, start, end); + }, + [PASS_STYLE]: 'byteArray', + [Symbol.toStringTag]: 'ByteArray', + }; + setPrototypeOf(byteArray, ArrayBuffer.prototype); + harden(byteArray); + genuineByteArray.add(byteArray); + return byteArray; +}; +harden(makeByteArray); + +/** + * TODO: This technique for recognizing genuine ByteArray is incompatible + * with our normal assumption of uncontrolled multiple instantiation of + * a single module. However, our only alternative to this technique is + * unprivileged re-validation of open data, which is incompat with our + * need to encapsulate `arrayBuffer`, the genuinely mutable ArrayBuffer. + * + * @param {unknown} candidate + * @param {import('./types.js').Checker} [check] + * @returns {boolean} + */ +const canBeValid = (candidate, check = undefined) => + // @ts-expect-error `has` argument can actually be anything. + genuineByteArray.has(candidate); + +/** + * @type {import('./internal-types.js').PassStyleHelper} + */ +export const ByteArrayHelper = harden({ + styleName: 'byteArray', + + canBeValid, + + assertValid: (candidate, _passStyleOfRecur) => { + canBeValid(candidate, assertChecker); + }, +}); diff --git a/packages/pass-style/src/passStyleOf.js b/packages/pass-style/src/passStyleOf.js index f635d47a21..862d1b852f 100644 --- a/packages/pass-style/src/passStyleOf.js +++ b/packages/pass-style/src/passStyleOf.js @@ -6,6 +6,7 @@ import { isPromise } from '@endo/promise-kit'; 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 { ErrorHelper } from './error.js'; @@ -36,6 +37,7 @@ const makeHelperTable = passStyleHelpers => { const HelperTable = { __proto__: null, copyArray: undefined, + byteArray: undefined, copyRecord: undefined, tagged: undefined, error: undefined, @@ -209,6 +211,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 ffc6e7c398..4ad9dbfa42 100644 --- a/packages/pass-style/src/typeGuards.js +++ b/packages/pass-style/src/typeGuards.js @@ -5,6 +5,7 @@ import { passStyleOf } from './passStyleOf.js'; * @template {Passable} [T=Passable] * @typedef {import('./types.js').CopyArray} CopyArray */ +/** @typedef {import('./types.js').ByteArray} ByteArray */ /** * @template {Passable} [T=Passable] * @typedef {import('./types.js').CopyRecord} CopyRecord @@ -23,6 +24,16 @@ const { Fail, quote: q } = assert; 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 @@ -57,6 +68,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} @@ -90,8 +119,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 65080677cc..07122b611c 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 @@ -31,14 +35,14 @@ export type PassStyle = | 'error' | 'promise'; -export type TaggedOrRemotable = 'tagged' | 'remotable'; +export type ManifestPassStyle = 'byteArray' | 'tagged' | 'remotable'; /** * Tagged has own [PASS_STYLE]: "tagged", [Symbol.toStringTag]: $tag. * * Remotable has a prototype chain in which the penultimate object has own [PASS_STYLE]: "remotable", [Symbol.toStringTag]: $iface (where both $tag and $iface must be strings, and the latter must either be "Remotable" or start with "Alleged: " or "DebugName: "). */ -export type PassStyled = { +export type PassStyled = { [PASS_STYLE]: S; [Symbol.toStringTag]: I; }; @@ -49,6 +53,7 @@ export type PassByCopy = | Primitive | Error | CopyArray + | ByteArray | CopyRecord | CopyTagged; @@ -67,6 +72,7 @@ export type PassByRef = * | 'string' | 'symbol'). * * 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 @@ -109,17 +117,16 @@ export type PassStyleOf = { (p: any[]): 'copyArray'; (p: Iterable): 'remotable'; (p: Iterator): 'remotable'; - >(p: T): ExtractStyle; + >(p: T): ExtractStyle; (p: { [key: string]: any }): 'copyRecord'; (p: any): PassStyle; }; /** * 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,17 @@ export type PassableCap = Promise | RemotableObject; */ export type CopyArray = Array; +/** + * It has the same structural type. But because it is not a builtin ArrayBuffer, + * it does not have the same nominal type; meaning, it cannot be used as an + * argument where an ArrayBuffer is expected, like the `DataView` or typed + * array constructors. + */ +export type ByteArray = PassStyled<'byteArray', string> & { + byteLength: number; + slice(start?: number, end?: number): 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 4cdd2334c1..e38a4cc371 100644 --- a/packages/patterns/src/keys/checkKey.js +++ b/packages/patterns/src/keys/checkKey.js @@ -563,6 +563,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 b86325897d..c499f44d02 100644 --- a/packages/patterns/src/keys/compareKeys.js +++ b/packages/patterns/src/keys/compareKeys.js @@ -148,6 +148,26 @@ export const compareKeys = (left, right) => { // @ts-expect-error FIXME narrowed return compareRank(left.length, right.length); } + case 'byteArray': { + const leftArray = new Uint8Array(left.slice()); + const rightArray = new Uint8Array(right.slice()); + 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 compareRank(left.byteLength, right.byteLength); + } case 'copyRecord': { // Pareto partial order comparison. // @ts-expect-error FIXME narrowed diff --git a/packages/patterns/src/patterns/patternMatchers.js b/packages/patterns/src/patterns/patternMatchers.js index c492b2d97e..e05e5db62c 100644 --- a/packages/patterns/src/patterns/patternMatchers.js +++ b/packages/patterns/src/patterns/patternMatchers.js @@ -69,6 +69,7 @@ export const defaultLimits = harden({ numPropertiesLimit: 80, propertyNameLengthLimit: 100, arrayLengthLimit: 10_000, + byteLengthLimit: 100_000, numSetElementsLimit: 10_000, numUniqueBagElementsLimit: 10_000, numMapEntriesLimit: 5000, @@ -367,6 +368,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. @@ -442,6 +446,7 @@ const makePatternKit = () => { case 'bigint': case 'string': case 'symbol': + case 'byteArray': case 'copySet': case 'copyBag': case 'remotable': { @@ -623,6 +628,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 @@ -1163,6 +1172,34 @@ const makePatternKit = () => { getRankCover: () => getPassStyleCover('copyArray'), }); + /** @type {import('./types.js').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 {import('./types.js').MatchHelper} */ const matchSetOfHelper = Far('match:setOf helper', { checkMatches: (specimen, [keyPatt, limits = undefined], check) => { @@ -1517,6 +1554,7 @@ const makePatternKit = () => { 'match:gt': matchGTHelper, 'match:arrayOf': matchArrayOfHelper, + 'match:bytes': matchBytesHelper, 'match:recordOf': matchRecordOfHelper, 'match:setOf': matchSetOfHelper, 'match:bagOf': matchBagOfHelper, @@ -1545,6 +1583,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]); @@ -1623,6 +1662,8 @@ const makePatternKit = () => { limits ? M.recordOf(M.any(), M.any(), limits) : RecordShape, array: (limits = undefined) => limits ? M.arrayOf(M.any(), limits) : ArrayShape, + 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/patterns/types.js b/packages/patterns/src/patterns/types.js index 3bcf00bbe3..10f960c914 100644 --- a/packages/patterns/src/patterns/types.js +++ b/packages/patterns/src/patterns/types.js @@ -17,6 +17,7 @@ export {}; * @template {Passable} [T=Passable] * @typedef {import('@endo/pass-style').CopyArray} CopyArray */ +/** @typedef {import('@endo/pass-style').ByteArray} ByteArray */ /** @typedef {import('@endo/pass-style').Checker} Checker */ /** @typedef {import('@endo/marshal').RankCompare} RankCompare */ /** @typedef {import('@endo/marshal').RankCover} RankCover */ diff --git a/packages/patterns/src/types.js b/packages/patterns/src/types.js index 63f22e887b..359f949da1 100644 --- a/packages/patterns/src/types.js +++ b/packages/patterns/src/types.js @@ -18,6 +18,7 @@ export {}; * @template {Passable} [T=Passable] * @typedef {import('@endo/pass-style').CopyArray} CopyArray */ +/** @typedef {import('@endo/pass-style').ByteArray} ByteArray */ /** @typedef {import('@endo/pass-style').Checker} Checker */ /** @typedef {import('@endo/marshal').RankCompare} RankCompare */ /** @typedef {import('@endo/marshal').RankCover} RankCover */ @@ -27,7 +28,7 @@ export {}; * @typedef {import('@endo/pass-style').Passable} 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. @@ -67,7 +68,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. * @@ -227,6 +228,7 @@ export {}; * @property {number} numPropertiesLimit * @property {number} propertyNameLengthLimit * @property {number} arrayLengthLimit + * @property {number} byteLengthLimit * @property {number} numSetElementsLimit * @property {number} numUniqueBagElementsLimit * @property {number} numMapEntriesLimit @@ -301,6 +303,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. *