diff --git a/packages/syrup/src/buffer-reader.js b/packages/syrup/src/buffer-reader.js new file mode 100644 index 0000000000..784fd669b5 --- /dev/null +++ b/packages/syrup/src/buffer-reader.js @@ -0,0 +1,336 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +const q = JSON.stringify; + +/** + * @typedef {object} BufferReaderState + * @property {Uint8Array} bytes + * @property {DataView} data + * @property {number} length + * @property {number} index + * @property {number} offset + */ + +export class BufferReader { + /** @type {BufferReaderState} */ + #state; + + /** + * @param {ArrayBuffer} buffer + */ + constructor(buffer) { + const bytes = new Uint8Array(buffer); + const data = new DataView(bytes.buffer); + this.#state = { + bytes, + data, + length: bytes.length, + index: 0, + offset: 0, + }; + } + + /** + * @param {Uint8Array} bytes + * @returns {BufferReader} + */ + static fromBytes(bytes) { + const empty = new ArrayBuffer(0); + const reader = new BufferReader(empty); + const state = reader.#state; + state.bytes = bytes; + state.data = new DataView(bytes.buffer); + state.length = bytes.length; + state.index = 0; + state.offset = bytes.byteOffset; + // Temporary check until we can handle non-zero byteOffset + if (state.offset !== 0) { + throw Error( + 'Cannot create BufferReader from Uint8Array with a non-zero byteOffset', + ); + } + return reader; + } + + /** + * @returns {number} + */ + get length() { + return this.#state.length; + } + + /** + * @returns {number} + */ + get index() { + return this.#state.index; + } + + /** + * @param {number} index + */ + set index(index) { + this.seek(index); + } + + /** + * @param {number} offset + */ + set offset(offset) { + const state = this.#state; + if (offset > state.data.byteLength) { + throw Error('Cannot set offset beyond length of underlying data'); + } + if (offset < 0) { + throw Error('Cannot set negative offset'); + } + state.offset = offset; + state.length = state.data.byteLength - state.offset; + } + + /** + * @param {number} index + * @returns {boolean} whether the read head can move to the given absolute + * index. + */ + canSeek(index) { + const state = this.#state; + return index >= 0 && state.offset + index <= state.length; + } + + /** + * @param {number} index the index to check. + * @throws {Error} an Error if the index is out of bounds. + */ + assertCanSeek(index) { + const state = this.#state; + if (!this.canSeek(index)) { + const err = Error( + `End of data reached (data length = ${state.length}, asked index ${index}`, + ); + // @ts-expect-error + err.code = 'EOD'; + // @ts-expect-error + err.index = index; + throw err; + } + } + + /** + * @param {number} index + * @returns {number} prior index + */ + seek(index) { + const state = this.#state; + const restore = state.index; + this.assertCanSeek(index); + state.index = index; + return restore; + } + + /** + * @param {number} size + * @returns {Uint8Array} + */ + peek(size) { + const state = this.#state; + // Clamp size. + size = Math.max(0, Math.min(state.length - state.index, size)); + if (size === 0) { + // in IE10, when using subarray(idx, idx), we get the array [0x00] instead of []. + return new Uint8Array(0); + } + const result = state.bytes.subarray( + state.offset + state.index, + state.offset + state.index + size, + ); + return result; + } + + peekByte() { + const state = this.#state; + this.assertCanRead(1); + return state.bytes[state.offset + state.index]; + } + + /** + * @param {number} offset + */ + canRead(offset) { + const state = this.#state; + return this.canSeek(state.index + offset); + } + + /** + * Check that the offset will not go too far. + * + * @param {number} offset the additional offset to check. + * @throws {Error} an Error if the offset is out of bounds. + */ + assertCanRead(offset) { + const state = this.#state; + this.assertCanSeek(state.index + offset); + } + + /** + * Get raw data without conversion, bytes. + * + * @param {number} size the number of bytes to read. + * @returns {Uint8Array} the raw data. + */ + read(size) { + const state = this.#state; + this.assertCanRead(size); + const result = this.peek(size); + state.index += size; + return result; + } + + /** + * @returns {number} + */ + readByte() { + return this.readUint8(); + } + + /** + * @returns {number} + */ + readUint8() { + const state = this.#state; + this.assertCanRead(1); + const index = state.offset + state.index; + const value = state.data.getUint8(index); + state.index += 1; + return value; + } + + /** + * @returns {number} + * @param {boolean=} littleEndian + */ + readUint16(littleEndian) { + const state = this.#state; + this.assertCanRead(2); + const index = state.offset + state.index; + const value = state.data.getUint16(index, littleEndian); + state.index += 2; + return value; + } + + /** + * @returns {number} + * @param {boolean=} littleEndian + */ + readUint32(littleEndian) { + const state = this.#state; + this.assertCanRead(4); + const index = state.offset + state.index; + const value = state.data.getUint32(index, littleEndian); + state.index += 4; + return value; + } + + /** + * @param {boolean=} littleEndian + * @returns {number} + */ + readFloat64(littleEndian = false) { + const state = this.#state; + this.assertCanRead(8); + const index = state.offset + state.index; + const value = state.data.getFloat64(index, littleEndian); + state.index += 8; + return value; + } + + /** + * @param {number} index + * @returns {number} + */ + byteAt(index) { + const state = this.#state; + return state.bytes[state.offset + index]; + } + + /** + * @param {number} index + * @param {number} size + * @returns {Uint8Array} + */ + bytesAt(index, size) { + this.assertCanSeek(index + size); + const state = this.#state; + return state.bytes.subarray( + state.offset + index, + state.offset + index + size, + ); + } + + /** + * @param {number} offset + */ + skip(offset) { + const state = this.#state; + this.seek(state.index + offset); + } + + /** + * @param {Uint8Array} expected + * @returns {boolean} + */ + expect(expected) { + const state = this.#state; + if (!this.matchAt(state.index, expected)) { + return false; + } + state.index += expected.length; + return true; + } + + /** + * @param {number} index + * @param {Uint8Array} expected + * @returns {boolean} + */ + matchAt(index, expected) { + const state = this.#state; + if (index + expected.length > state.length || index < 0) { + return false; + } + for (let i = 0; i < expected.length; i += 1) { + if (expected[i] !== this.byteAt(index + i)) { + return false; + } + } + return true; + } + + /** + * @param {Uint8Array} expected + */ + assert(expected) { + const state = this.#state; + if (!this.expect(expected)) { + throw Error( + `Expected ${q(expected)} at ${state.index}, got ${this.peek( + expected.length, + )}`, + ); + } + } + + /** + * @param {Uint8Array} expected + * @returns {number} + */ + findLast(expected) { + const state = this.#state; + let index = state.length - expected.length; + while (index >= 0 && !this.matchAt(index, expected)) { + index -= 1; + } + return index; + } +} diff --git a/packages/syrup/src/buffer-writer.js b/packages/syrup/src/buffer-writer.js new file mode 100644 index 0000000000..d7dc73809f --- /dev/null +++ b/packages/syrup/src/buffer-writer.js @@ -0,0 +1,213 @@ +// @ts-check +/* eslint no-bitwise: ["off"] */ + +const textEncoder = new TextEncoder(); + +/** + * @typedef {{ + * length: number, + * index: number, + * bytes: Uint8Array, + * data: DataView, + * capacity: number, + * }} BufferWriterState + */ + +const assertNatNumber = n => { + if (Number.isSafeInteger(n) && /** @type {number} */ (n) >= 0) { + return; + } + throw TypeError(`must be a non-negative integer, got ${n}`); +}; + +export class BufferWriter { + /** @type {BufferWriterState} */ + #state; + + /** + * @returns {number} + */ + get length() { + return this.#state.length; + } + + /** + * @returns {number} + */ + get index() { + return this.#state.index; + } + + /** + * @param {number} index + */ + set index(index) { + this.seek(index); + } + + /** + * @param {number=} capacity + */ + constructor(capacity = 16) { + const bytes = new Uint8Array(capacity); + const data = new DataView(bytes.buffer); + this.#state = { + bytes, + data, + index: 0, + length: 0, + capacity, + }; + } + + /** + * @param {number} required + */ + ensureCanSeek(required) { + assertNatNumber(required); + const state = this.#state; + let capacity = state.capacity; + if (capacity >= required) { + return; + } + while (capacity < required) { + capacity *= 2; + } + const bytes = new Uint8Array(capacity); + const data = new DataView(bytes.buffer); + bytes.set(state.bytes.subarray(0, state.length)); + state.bytes = bytes; + state.data = data; + state.capacity = capacity; + } + + /** + * @param {number} index + */ + seek(index) { + const state = this.#state; + this.ensureCanSeek(index); + state.index = index; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {number} size + */ + ensureCanWrite(size) { + assertNatNumber(size); + const state = this.#state; + this.ensureCanSeek(state.index + size); + } + + /** + * @param {Uint8Array} bytes + */ + write(bytes) { + const state = this.#state; + this.ensureCanWrite(bytes.byteLength); + state.bytes.set(bytes, state.index); + state.index += bytes.byteLength; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {number} byte + */ + writeByte(byte) { + assertNatNumber(byte); + if (byte > 0xff) { + throw RangeError(`byte must be in range 0..255, got ${byte}`); + } + this.writeUint8(byte); + } + + /** + * @param {number} start + * @param {number} end + */ + writeCopy(start, end) { + assertNatNumber(start); + assertNatNumber(end); + const state = this.#state; + const size = end - start; + this.ensureCanWrite(size); + state.bytes.copyWithin(state.index, start, end); + state.index += size; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {number} value + */ + writeUint8(value) { + const state = this.#state; + this.ensureCanWrite(1); + state.data.setUint8(state.index, value); + state.index += 1; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {number} value + * @param {boolean=} littleEndian + */ + writeUint16(value, littleEndian) { + const state = this.#state; + this.ensureCanWrite(2); + state.data.setUint16(state.index, value, littleEndian); + state.index += 2; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {number} value + * @param {boolean=} littleEndian + */ + writeUint32(value, littleEndian) { + const state = this.#state; + this.ensureCanWrite(4); + state.data.setUint32(state.index, value, littleEndian); + state.index += 4; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {number} value + * @param {boolean=} littleEndian + */ + writeFloat64(value, littleEndian) { + const state = this.#state; + this.ensureCanWrite(8); + state.data.setFloat64(state.index, value, littleEndian); + state.index += 8; + state.length = Math.max(state.index, state.length); + } + + /** + * @param {string} string + */ + writeString(string) { + const bytes = textEncoder.encode(string); + this.write(bytes); + } + + /** + * @param {number=} begin + * @param {number=} end + * @returns {Uint8Array} + */ + subarray(begin, end) { + const state = this.#state; + return state.bytes.subarray(0, state.length).subarray(begin, end); + } + + /** + * @param {number=} begin + * @param {number=} end + * @returns {Uint8Array} + */ + slice(begin, end) { + return this.subarray(begin, end).slice(); + } +} diff --git a/packages/syrup/src/codec.js b/packages/syrup/src/codec.js new file mode 100644 index 0000000000..bd7c44ea33 --- /dev/null +++ b/packages/syrup/src/codec.js @@ -0,0 +1,338 @@ +const { freeze } = Object; +const quote = JSON.stringify; + +/** @typedef {import('./decode.js').SyrupReader} SyrupReader */ +/** @typedef {import('./encode.js').SyrupWriter} SyrupWriter */ +/** @typedef {import('./decode.js').SyrupType} SyrupType */ +/** @typedef {import('./decode.js').TypeHintTypes} TypeHintTypes */ + +/** + * @typedef {object} SyrupCodec + * @property {function(SyrupReader): any} read + * @property {function(any, SyrupWriter): void} write + */ + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); + +/** @type {SyrupCodec} */ +export const SelectorCodec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeSelector(value), + read: syrupReader => syrupReader.readSelectorAsString(), +}); + +/** @type {SyrupCodec} */ +export const StringCodec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeString(value), + read: syrupReader => syrupReader.readString(), +}); + +/** @type {SyrupCodec} */ +export const BytestringCodec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeBytestring(value), + read: syrupReader => syrupReader.readBytestring(), +}); + +/** @type {SyrupCodec} */ +export const BooleanCodec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeBoolean(value), + read: syrupReader => syrupReader.readBoolean(), +}); + +/** @type {SyrupCodec} */ +export const IntegerCodec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeInteger(value), + read: syrupReader => syrupReader.readInteger(), +}); + +/** @type {SyrupCodec} */ +export const Float64Codec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeFloat64(value), + read: syrupReader => syrupReader.readFloat64(), +}); + +/** @type {SyrupCodec} */ +export const AnyCodec = freeze({ + write: (value, syrupWriter) => syrupWriter.writeAny(value), + read: syrupReader => syrupReader.readAny(), +}); + +/** @type {SyrupCodec} */ +export const ListCodec = freeze({ + /** + * @param {SyrupReader} syrupReader + * @returns {any[]} + */ + read(syrupReader) { + syrupReader.enterList(); + const result = []; + while (!syrupReader.peekListEnd()) { + const value = syrupReader.readAny(); + result.push(value); + } + syrupReader.exitList(); + return result; + }, + /** + * @param {any} value + * @param {SyrupWriter} syrupWriter + */ + write(value, syrupWriter) { + throw Error('SyrupListCodec: write must be implemented'); + }, +}); + +/** + * @param {SyrupCodec} childCodec + * @returns {SyrupCodec} + */ +export const makeListCodecFromEntryCodec = childCodec => { + return freeze({ + read: syrupReader => { + syrupReader.enterList(); + const result = []; + while (!syrupReader.peekListEnd()) { + const value = childCodec.read(syrupReader); + result.push(value); + } + syrupReader.exitList(); + return result; + }, + write: (value, syrupWriter) => { + syrupWriter.enterList(); + for (const child of value) { + childCodec.write(child, syrupWriter); + } + syrupWriter.exitList(); + }, + }); +}; + +/** + * @typedef {SyrupCodec & { + * label: string; + * readBody: (SyrupReader) => any; + * writeBody: (any, SyrupWriter) => void; + * }} SyrupRecordCodec + */ + +/** + * @typedef {'selector' | 'string' | 'bytestring'} SyrupRecordLabelType + * see https://github.com/ocapn/syrup/issues/22 + */ + +/** + * @param {string} label + * @param {SyrupRecordLabelType} labelType + * @param {function(SyrupReader): any} readBody + * @param {function(any, SyrupWriter): void} writeBody + * @returns {SyrupRecordCodec} + */ +export const makeRecordCodec = (label, labelType, readBody, writeBody) => { + /** + * @param {SyrupReader} syrupReader + * @returns {any} + */ + const read = syrupReader => { + syrupReader.enterRecord(); + const labelInfo = syrupReader.readRecordLabel(); + if (labelInfo.type !== labelType) { + throw Error( + `RecordCodec: Expected label type ${quote(labelType)} for ${quote(label)}, got ${quote(labelInfo.type)}`, + ); + } + const labelString = + labelInfo.type === 'bytestring' + ? textDecoder.decode(labelInfo.value) + : labelInfo.value; + if (labelString !== label) { + throw Error( + `RecordCodec: Expected label ${quote(label)}, got ${quote(labelString)}`, + ); + } + const result = readBody(syrupReader); + syrupReader.exitRecord(); + return result; + }; + /** + * @param {any} value + * @param {SyrupWriter} syrupWriter + */ + const write = (value, syrupWriter) => { + syrupWriter.enterRecord(); + if (labelType === 'selector') { + syrupWriter.writeSelector(value.type); + } else if (labelType === 'string') { + syrupWriter.writeString(value.type); + } else if (labelType === 'bytestring') { + syrupWriter.writeBytestring(textEncoder.encode(value.type)); + } + writeBody(value, syrupWriter); + syrupWriter.exitRecord(); + }; + return freeze({ + label, + read, + readBody, + write, + writeBody, + }); +}; + +/** @typedef {Array<[string, SyrupType | SyrupCodec]>} SyrupRecordDefinition */ + +/** + * @param {string} label + * @param {SyrupRecordLabelType} labelType + * @param {SyrupRecordDefinition} definition + * @returns {SyrupRecordCodec} + */ +export const makeRecordCodecFromDefinition = (label, labelType, definition) => { + /** + * @param {SyrupReader} syrupReader + * @returns {any} + */ + const readBody = syrupReader => { + const result = {}; + for (const field of definition) { + const [fieldName, fieldType] = field; + let fieldValue; + if (typeof fieldType === 'string') { + // @ts-expect-error fieldType is any string + fieldValue = syrupReader.readOfType(fieldType); + } else { + const fieldDefinition = fieldType; + fieldValue = fieldDefinition.read(syrupReader); + } + result[fieldName] = fieldValue; + } + result.type = label; + return result; + }; + /** + * @param {any} value + * @param {SyrupWriter} syrupWriter + */ + const writeBody = (value, syrupWriter) => { + for (const field of definition) { + const [fieldName, fieldType] = field; + const fieldValue = value[fieldName]; + if (typeof fieldType === 'string') { + // @ts-expect-error fieldType is any string + syrupWriter.writeOfType(fieldType, fieldValue); + } else { + fieldType.write(fieldValue, syrupWriter); + } + } + }; + + return makeRecordCodec(label, labelType, readBody, writeBody); +}; + +/** + * @param {function(SyrupReader): SyrupCodec} selectCodecForRead + * @param {function(any): SyrupCodec} selectCodecForWrite + * @returns {SyrupCodec} + */ +export const makeUnionCodec = (selectCodecForRead, selectCodecForWrite) => { + /** + * @param {SyrupReader} syrupReader + * @returns {SyrupCodec} + */ + const read = syrupReader => { + const codec = selectCodecForRead(syrupReader); + return codec.read(syrupReader); + }; + /** + * @param {any} value + * @param {SyrupWriter} syrupWriter + */ + const write = (value, syrupWriter) => { + const codec = selectCodecForWrite(value); + codec.write(value, syrupWriter); + }; + return freeze({ read, write }); +}; + +/** @typedef {'undefined'|'object'|'boolean'|'number'|'string'|'symbol'|'bigint'} JavascriptTypeofValueTypes */ +/** @typedef {Partial>} TypeHintUnionReadTable */ +/** @typedef {Partial SyrupCodec)>>} TypeHintUnionWriteTable */ + +/** + * @param {TypeHintUnionReadTable} readTable + * @param {TypeHintUnionWriteTable} writeTable + * @returns {SyrupCodec} + */ +export const makeTypeHintUnionCodec = (readTable, writeTable) => { + return makeUnionCodec( + syrupReader => { + const typeHint = syrupReader.peekTypeHint(); + const codec = readTable[typeHint]; + if (!codec) { + const expected = Object.keys(readTable).join(', '); + throw Error( + `Unexpected type hint ${quote(typeHint)}, expected one of ${expected}`, + ); + } + return codec; + }, + value => { + const codecOrGetter = writeTable[typeof value]; + const codec = + typeof codecOrGetter === 'function' + ? codecOrGetter(value) + : codecOrGetter; + if (!codec) { + const expected = Object.keys(writeTable).join(', '); + throw Error( + `Unexpected value type ${quote(typeof value)}, expected one of ${expected}`, + ); + } + return codec; + }, + ); +}; + +/** + * @typedef {SyrupCodec & { + * supports: (label: string) => boolean; + * }} SyrupRecordUnionCodec + */ + +/** + * @param {Record} recordTypes + * @returns {SyrupRecordUnionCodec} + */ +export const makeRecordUnionCodec = recordTypes => { + const recordTable = Object.fromEntries( + Object.values(recordTypes).map(recordCodec => { + return [recordCodec.label, recordCodec]; + }), + ); + const supports = label => { + return recordTable[label] !== undefined; + }; + const read = syrupReader => { + syrupReader.enterRecord(); + const labelInfo = syrupReader.readRecordLabel(); + const labelString = + labelInfo.type === 'bytestring' + ? textDecoder.decode(labelInfo.value) + : labelInfo.value; + const recordCodec = recordTable[labelString]; + if (!recordCodec) { + throw Error(`Unexpected record type: ${quote(labelString)}`); + } + const result = recordCodec.readBody(syrupReader); + syrupReader.exitRecord(); + return result; + }; + const write = (value, syrupWriter) => { + const recordCodec = recordTable[value.type]; + if (!recordCodec) { + throw Error(`Unexpected record type: ${quote(value.type)}`); + } + recordCodec.write(value, syrupWriter); + }; + return freeze({ read, write, supports }); +}; diff --git a/packages/syrup/src/decode.js b/packages/syrup/src/decode.js index a11c659a8b..d0530b39d5 100644 --- a/packages/syrup/src/decode.js +++ b/packages/syrup/src/decode.js @@ -1,270 +1,370 @@ // @ts-check +import { BufferReader } from './buffer-reader.js'; import { compareByteArrays } from './compare.js'; +import { SyrupSelectorFor } from './selector.js'; const MINUS = '-'.charCodeAt(0); const PLUS = '+'.charCodeAt(0); const ZERO = '0'.charCodeAt(0); -const ONE = '1'.charCodeAt(0); +// const ONE = '1'.charCodeAt(0); const NINE = '9'.charCodeAt(0); const LIST_START = '['.charCodeAt(0); const LIST_END = ']'.charCodeAt(0); const DICT_START = '{'.charCodeAt(0); const DICT_END = '}'.charCodeAt(0); -// const SET_START = '#'.charCodeAt(0); -// const SET_END = '$'.charCodeAt(0); +const SET_START = '#'.charCodeAt(0); +const SET_END = '$'.charCodeAt(0); const BYTES_START = ':'.charCodeAt(0); const STRING_START = '"'.charCodeAt(0); -// const SYMBOL_START = "'".charCodeAt(0); -// const RECORD_START = '<'.charCodeAt(0); -// const RECORD_END = '>'.charCodeAt(0); +const SELECTOR_START = "'".charCodeAt(0); +const RECORD_START = '<'.charCodeAt(0); +const RECORD_END = '>'.charCodeAt(0); const TRUE = 't'.charCodeAt(0); const FALSE = 'f'.charCodeAt(0); // const SINGLE = 'F'.charCodeAt(0); -const DOUBLE = 'D'.charCodeAt(0); +const FLOAT64 = 'D'.charCodeAt(0); const textDecoder = new TextDecoder(); -const scratch = new ArrayBuffer(8); -const scratchBytes = new Uint8Array(scratch); -const scratchData = new DataView(scratch); - const { defineProperty, freeze } = Object; +const quote = o => JSON.stringify(o); +const toChar = code => String.fromCharCode(code); + +const canonicalNaN64 = freeze([0x7f, 0xf8, 0, 0, 0, 0, 0, 0]); +const canonicalZero64 = freeze([0, 0, 0, 0, 0, 0, 0, 0]); + /** - * @param {Uint8Array} bytes + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {boolean} */ -function isCanonicalNaN64(bytes) { - const [a, b, c, d, e, f, g, h] = bytes; - return ( - a === 0x7f && - b === 0xf8 && - c === 0 && - d === 0 && - e === 0 && - f === 0 && - g === 0 && - h === 0 +function readBoolean(bufferReader, name) { + const cc = bufferReader.readByte(); + if (cc === TRUE) { + return true; + } + if (cc === FALSE) { + return false; + } + throw Error( + `Unexpected byte ${quote(toChar(cc))}, Syrup booleans must start with ${quote(toChar(TRUE))} or ${quote(toChar(FALSE))} at index ${bufferReader.index} of ${name}`, ); } +/** @typedef {'boolean' | 'float64' | 'integer' | 'bytestring' | 'string' | 'selector'} SyrupAtomType */ +/** @typedef {'list' | 'set' | 'dictionary' | 'record'} SyrupStructuredType */ +/** @typedef {SyrupAtomType | SyrupStructuredType} SyrupType */ + +// Structure types, no value provided +/** @typedef {{type: SyrupStructuredType, value: null}} ReadTypeStructuredResult */ +// Simple Atom types, value is read +/** @typedef {{type: 'boolean', value: boolean}} ReadTypeBooleanResult */ +/** @typedef {{type: 'float64', value: number}} ReadTypeFloat64Result */ +// Number-prefixed types, value is read +/** @typedef {{type: 'integer', value: bigint}} ReadTypeIntegerResult */ +/** @typedef {{type: 'bytestring', value: Uint8Array}} ReadTypeBytestringResult */ +/** @typedef {{type: 'string', value: string}} ReadTypeStringResult */ +/** @typedef {{type: 'selector', value: string}} ReadTypeSelectorResult */ +/** @typedef {ReadTypeBooleanResult | ReadTypeFloat64Result | ReadTypeIntegerResult | ReadTypeBytestringResult | ReadTypeStringResult | ReadTypeSelectorResult} ReadTypeAtomResult */ /** - * @param {Uint8Array} bytes + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {ReadTypeStructuredResult | ReadTypeAtomResult} + * Reads until it can determine the type of the next value. */ -function isCanonicalZero64(bytes) { - const [a, b, c, d, e, f, g, h] = bytes; - return ( - a === 0 && - b === 0 && - c === 0 && - d === 0 && - e === 0 && - f === 0 && - g === 0 && - h === 0 +function readTypeAndMaybeValue(bufferReader, name) { + const start = bufferReader.index; + const cc = bufferReader.readByte(); + // Structure types, don't read value + if (cc === LIST_START) { + return { type: 'list', value: null }; + } + if (cc === SET_START) { + return { type: 'set', value: null }; + } + if (cc === DICT_START) { + return { type: 'dictionary', value: null }; + } + if (cc === RECORD_START) { + return { type: 'record', value: null }; + } + // Atom types, read value + if (cc === TRUE) { + return { type: 'boolean', value: true }; + } + if (cc === FALSE) { + return { type: 'boolean', value: false }; + } + if (cc === FLOAT64) { + // eslint-disable-next-line no-use-before-define + const value = readFloat64Body(bufferReader, name); + return { type: 'float64', value }; + } + // Number-prefixed types, read value + if (cc < ZERO || cc > NINE) { + throw Error( + `Unexpected character ${quote(toChar(cc))}, at index ${bufferReader.index} of ${name}`, + ); + } + // Parse number-prefix + let end; + let byte; + for (;;) { + byte = bufferReader.readByte(); + if (byte < ZERO || byte > NINE) { + end = bufferReader.index - 1; + break; + } + } + const typeByte = byte; + const numberBuffer = bufferReader.bytesAt(start, end - start); + const numberString = textDecoder.decode(numberBuffer); + if (typeByte === PLUS) { + const integer = BigInt(numberString); + return { type: 'integer', value: integer }; + } + if (typeByte === MINUS) { + const integer = BigInt(numberString); + return { type: 'integer', value: -integer }; + } + if (typeByte === BYTES_START) { + const number = Number.parseInt(numberString, 10); + const valueBytes = bufferReader.read(number); + return { type: 'bytestring', value: valueBytes }; + } + if (typeByte === STRING_START) { + const number = Number.parseInt(numberString, 10); + const valueBytes = bufferReader.read(number); + return { type: 'string', value: textDecoder.decode(valueBytes) }; + } + if (typeByte === SELECTOR_START) { + const number = Number.parseInt(numberString, 10); + const valueBytes = bufferReader.read(number); + return { type: 'selector', value: textDecoder.decode(valueBytes) }; + } + throw Error( + `Unexpected character ${quote(toChar(typeByte))}, at index ${bufferReader.index} of ${name}`, ); } + /** - * @param {Uint8Array} bytes - * @param {bigint} integer - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader + * @param {'boolean' | 'integer' | 'float64' | 'string' | 'selector' | 'bytestring'} expectedType * @param {string} name + * @returns {any} */ -function decodeAfterInteger(bytes, integer, start, end, name) { - if (start >= end) { - throw Error(`Unexpected end of Syrup, expected integer suffix in ${name}`); - } - const cc = bytes[start]; - if (cc === PLUS) { - return { - start: start + 1, - value: integer, - }; +function readAndAssertType(bufferReader, expectedType, name) { + const start = bufferReader.index; + const { value, type } = readTypeAndMaybeValue(bufferReader, name); + if (type !== expectedType) { + throw Error(`Unexpected type ${quote(type)} at index ${start} of ${name}`); } - if (cc === MINUS) { - if (integer === 0n) { - throw Error(`Unexpected non-canonical -0`); - } - return { - start: start + 1, - value: -integer, - }; + return value; +} + +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {bigint} + */ +function readInteger(bufferReader, name) { + return readAndAssertType(bufferReader, 'integer', name); +} + +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {string} + */ +function readString(bufferReader, name) { + return readAndAssertType(bufferReader, 'string', name); +} + +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {string} + */ +function readSelectorAsString(bufferReader, name) { + return readAndAssertType(bufferReader, 'selector', name); +} + +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {Uint8Array} + */ +function readBytestring(bufferReader, name) { + return readAndAssertType(bufferReader, 'bytestring', name); +} + +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {{value: string, type: 'selector'} | {value: Uint8Array, type: 'bytestring'} | {value: string, type: 'string'}} + * see https://github.com/ocapn/syrup/issues/22 + */ +function readRecordLabel(bufferReader, name) { + const start = bufferReader.index; + const { value, type } = readTypeAndMaybeValue(bufferReader, name); + if (type === 'selector' || type === 'string' || type === 'bytestring') { + // @ts-expect-error type system is not smart enough + return { value, type }; } - if (cc === BYTES_START) { - start += 1; - const subStart = start; - start += Number(integer); - if (start > end) { - throw Error( - `Unexpected end of Syrup, expected ${integer} bytes after Syrup bytestring starting at index ${subStart} in ${name}`, - ); + throw Error( + `Unexpected type ${quote(type)}, Syrup record labels must be strings, selectors, or bytestrings at index ${start} of ${name}`, + ); +} + +/** + * @param {BufferReader} bufferReader + * @param {string} name + */ +function readFloat64Body(bufferReader, name) { + const start = bufferReader.index; + const value = bufferReader.readFloat64(false); // big end + + if (value === 0) { + // @ts-expect-error canonicalZero64 is a frozen array, not a Uint8Array + if (!bufferReader.matchAt(start, canonicalZero64)) { + throw Error(`Non-canonical zero at index ${start} of Syrup ${name}`); } - const value = bytes.subarray(subStart, start); - return { start, value }; - } - if (cc === STRING_START) { - start += 1; - const subStart = start; - start += Number(integer); - if (start > end) { - throw Error( - `Unexpected end of Syrup, expected ${integer} bytes after string starting at index ${subStart} in ${name}`, - ); + } + if (Number.isNaN(value)) { + // @ts-expect-error canonicalNaN64 is a frozen array, not a Uint8Array + if (!bufferReader.matchAt(start, canonicalNaN64)) { + throw Error(`Non-canonical NaN at index ${start} of Syrup ${name}`); } - const value = textDecoder.decode(bytes.subarray(subStart, start)); - return { start, value }; } - throw Error( - `Unexpected character ${JSON.stringify( - String.fromCharCode(cc), - )} at Syrup index ${start} of ${name}`, - ); + + return value; } /** - * @param {Uint8Array} bytes - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader + * @param {string} name */ -function decodeInteger(bytes, start, end) { - let at = start + 1; - // eslint-disable-next-line no-empty - for (; at < end && bytes[at] >= ZERO && bytes[at] <= NINE; at += 1) {} - return { - start: at, - integer: BigInt(textDecoder.decode(bytes.subarray(start, at))), - }; +function readFloat64(bufferReader, name) { + const cc = bufferReader.readByte(); + if (cc !== FLOAT64) { + throw Error( + `Unexpected character ${quote(toChar(cc))}, at index ${bufferReader.index} of ${name}`, + ); + } + return readFloat64Body(bufferReader, name); } /** - * @param {Uint8Array} bytes - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader * @param {string} name + * @returns {any[]} */ -function decodeArray(bytes, start, end, name) { +function readListBody(bufferReader, name) { const list = []; for (;;) { - if (start >= end) { - throw Error( - `Unexpected end of Syrup, expected Syrup value or end of Syrup list marker "]" at index ${start} in ${name}`, - ); - } - const cc = bytes[start]; - if (cc === LIST_END) { - return { - start: start + 1, - value: list, - }; + if (bufferReader.peekByte() === LIST_END) { + bufferReader.skip(1); + return list; } - let value; // eslint-disable-next-line no-use-before-define - ({ start, value } = decodeAny(bytes, start, end, name)); - list.push(value); + list.push(readAny(bufferReader, name)); } } /** - * @param {Uint8Array} bytes - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader * @param {string} name + * @returns {any[]} */ -function decodeString(bytes, start, end, name) { - if (start >= end) { +// Provided for completeness, but not used. +// eslint-disable-next-line no-unused-vars +function readList(bufferReader, name) { + const cc = bufferReader.readByte(); + if (cc !== LIST_START) { throw Error( - `Unexpected end of Syrup, expected Syrup string at end of ${name}`, + `Unexpected byte ${quote(toChar(cc))}, Syrup lists must start with ${quote(toChar(LIST_START))} at index ${bufferReader.index} of ${name}`, ); } - let length; - ({ start, integer: length } = decodeInteger(bytes, start, end)); - - const cc = bytes[start]; - if (cc !== STRING_START) { - throw Error( - `Unexpected byte ${JSON.stringify( - String.fromCharCode(cc), - )}, Syrup dictionary keys must be strings or symbols at index ${start} of ${name}`, - ); - } - start += 1; + return readListBody(bufferReader, name); +} - const subStart = start; - start += Number(length); - if (start > end) { - throw Error( - `Unexpected end of Syrup, expected ${length} bytes after index ${subStart} of ${name}`, - ); +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {{value: any, type: 'string' | 'selector', bytes: Uint8Array}} + */ +function readDictionaryKey(bufferReader, name) { + const start = bufferReader.index; + const { value, type } = readTypeAndMaybeValue(bufferReader, name); + if (type === 'string' || type === 'selector') { + const end = bufferReader.index; + const bytes = bufferReader.bytesAt(start, end - start); + if (type === 'selector') { + return { value: SyrupSelectorFor(value), type, bytes }; + } + return { value, type, bytes }; } - const value = textDecoder.decode(bytes.subarray(subStart, start)); - return { start, value }; + throw Error( + `Unexpected type ${quote(type)}, Syrup dictionary keys must be strings or selectors at index ${start} of ${name}`, + ); } /** - * @param {Uint8Array} bytes - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader * @param {string} name */ -function decodeRecord(bytes, start, end, name) { - const record = {}; - let priorKey = ''; - let priorKeyStart = -1; - let priorKeyEnd = -1; +function readDictionaryBody(bufferReader, name) { + const dict = {}; + let priorKey; + let priorKeyBytes; for (;;) { - if (start >= end) { - throw Error( - `Unexpected end of Syrup, expected Syrup string or end of Syrup dictionary marker "}" at ${start} of ${name}`, - ); - } - const cc = bytes[start]; - if (cc === DICT_END) { - return { - start: start + 1, - value: freeze(record), - }; + // Check for end of dictionary + if (bufferReader.peekByte() === DICT_END) { + bufferReader.skip(1); + return freeze(dict); } - const keyStart = start; - let key; - ({ start, value: key } = decodeString(bytes, start, end, name)); - const keyEnd = start; + // Read key + const start = bufferReader.index; + const { value: newKey, bytes: newKeyBytes } = readDictionaryKey( + bufferReader, + name, + ); // Validate strictly non-descending keys. - if (priorKeyStart !== -1) { + if (priorKeyBytes !== undefined) { const order = compareByteArrays( - bytes, - bytes, - priorKeyStart, - priorKeyEnd, - keyStart, - keyEnd, + priorKeyBytes, + newKeyBytes, + 0, + priorKeyBytes.length, + 0, + newKeyBytes.length, ); if (order === 0) { throw Error( `Syrup dictionary keys must be unique, got repeated ${JSON.stringify( - key, + newKey, )} at index ${start} of ${name}`, ); } else if (order > 0) { throw Error( `Syrup dictionary keys must be in bytewise sorted order, got ${JSON.stringify( - key, + newKey, )} immediately after ${JSON.stringify( priorKey, )} at index ${start} of ${name}`, ); } } - priorKey = key; - priorKeyStart = keyStart; - priorKeyEnd = keyEnd; + priorKey = newKey; + priorKeyBytes = newKeyBytes; - let value; + // Read value and add to dictionary // eslint-disable-next-line no-use-before-define - ({ start, value } = decodeAny(bytes, start, end, name)); - - defineProperty(record, key, { + const value = readAny(bufferReader, name); + defineProperty(dict, newKey, { value, enumerable: true, writable: false, @@ -274,81 +374,273 @@ function decodeRecord(bytes, start, end, name) { } /** - * @param {Uint8Array} bytes - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader * @param {string} name */ -function decodeFloat64(bytes, start, end, name) { - const floatStart = start; - start += 8; - if (start > end) { +// Provided for completeness, but not used. +// eslint-disable-next-line no-unused-vars +function readDictionary(bufferReader, name) { + const start = bufferReader.index; + const cc = bufferReader.readByte(); + if (cc !== DICT_START) { throw Error( - `Unexpected end of Syrup, expected 8 bytes of a 64 bit floating point number at index ${floatStart} of ${name}`, + `Unexpected character ${quote(toChar(cc))}, Syrup dictionaries must start with ${quote(toChar(DICT_START))} at index ${start} of ${name}`, ); } - const subarray = bytes.subarray(floatStart, start); - scratchBytes.set(subarray); - const value = scratchData.getFloat64(0, false); // big end + return readDictionaryBody(bufferReader, name); +} - if (value === 0) { - if (!isCanonicalZero64(subarray)) { - throw Error(`Non-canonical zero at index ${floatStart} of Syrup ${name}`); - } +/** @typedef {'float64' | 'number-prefix' | 'list' | 'set' | 'dictionary' | 'record' | 'boolean'} TypeHintTypes */ + +/** + * @param {BufferReader} bufferReader + * @param {string} name + * @returns {TypeHintTypes} + */ +export function peekTypeHint(bufferReader, name) { + const cc = bufferReader.peekByte(); + if (cc >= ZERO && cc <= NINE) { + return 'number-prefix'; } - if (Number.isNaN(value)) { - if (!isCanonicalNaN64(subarray)) { - throw Error(`Non-canonical NaN at index ${floatStart} of Syrup ${name}`); - } + if (cc === TRUE || cc === FALSE) { + return 'boolean'; } - - return { start, value }; + if (cc === FLOAT64) { + return 'float64'; + } + if (cc === LIST_START) { + return 'list'; + } + if (cc === SET_START) { + return 'set'; + } + if (cc === DICT_START) { + return 'dictionary'; + } + if (cc === RECORD_START) { + return 'record'; + } + const index = bufferReader.index; + throw Error( + `Unexpected character ${quote(toChar(cc))}, at index ${index} of ${name}`, + ); } /** - * @param {Uint8Array} bytes - * @param {number} start - * @param {number} end + * @param {BufferReader} bufferReader * @param {string} name - * @returns {{start: number, value: any}} + * @returns {any} */ -function decodeAny(bytes, start, end, name) { - if (start >= end) { - throw Error( - `Unexpected end of Syrup, expected any value at index ${start} of ${name}`, - ); +function readAny(bufferReader, name) { + const { type, value } = readTypeAndMaybeValue(bufferReader, name); + // Structure types, value has not been read + if (type === 'list') { + return readListBody(bufferReader, name); } - const cc = bytes[start]; - if (cc === DOUBLE) { - return decodeFloat64(bytes, start + 1, end, name); + if (type === 'set') { + throw Error(`readAny for Sets is not yet supported.`); } - if (cc >= ONE && cc <= NINE) { - let integer; - ({ start, integer } = decodeInteger(bytes, start, end)); - return decodeAfterInteger(bytes, integer, start, end, name); + if (type === 'dictionary') { + return readDictionaryBody(bufferReader, name); } - if (cc === ZERO) { - return decodeAfterInteger(bytes, 0n, start + 1, end, name); + if (type === 'record') { + throw Error(`readAny for Records is not yet supported.`); } - if (cc === LIST_START) { - return decodeArray(bytes, start + 1, end, name); + // Atom types, value is already read + // For selectors, we need to convert the string to a selector + if (type === 'selector') { + return SyrupSelectorFor(value); } - if (cc === DICT_START) { - return decodeRecord(bytes, start + 1, end, name); + + return value; +} + +/** @typedef {{type: string, start: number}} SyrupReaderStackEntry */ + +export class SyrupReader { + /** + * @param {BufferReader} bufferReader + * @param {object} options + * @param {string} [options.name] + */ + constructor(bufferReader, options = {}) { + const { name = '' } = options; + this.name = name; + this.bufferReader = bufferReader; + this.state = { + /** @type {SyrupReaderStackEntry[]} */ + stack: [], + }; } - if (cc === TRUE) { - return { start: start + 1, value: true }; + + /** + * @param {number} expectedByte + */ + #readAndAssertByte(expectedByte) { + const start = this.bufferReader.index; + const cc = this.bufferReader.readByte(); + if (cc !== expectedByte) { + throw Error( + `Unexpected character ${quote(toChar(cc))}, expected ${quote(toChar(expectedByte))} at index ${start} of ${this.name}`, + ); + } } - if (cc === FALSE) { - return { start: start + 1, value: false }; + + /** + * @param {string} type + */ + #pushStackEntry(type) { + this.state.stack.push({ type, start: this.bufferReader.index }); + } + + /** + * @param {string} expectedType + */ + #popStackEntry(expectedType) { + const start = this.bufferReader.index; + const stackEntry = this.state.stack.pop(); + if (!stackEntry) { + throw Error( + `Attempted to exit ${expectedType} without entering it at index ${start} of ${this.name}`, + ); + } + if (stackEntry.type !== expectedType) { + throw Error( + `Attempted to exit ${expectedType} while in a ${stackEntry.type} at index ${start} of ${this.name}`, + ); + } + } + + enterRecord() { + this.#readAndAssertByte(RECORD_START); + this.#pushStackEntry('record'); + } + + exitRecord() { + this.#readAndAssertByte(RECORD_END); + this.#popStackEntry('record'); + } + + peekRecordEnd() { + const cc = this.bufferReader.peekByte(); + return cc === RECORD_END; + } + + readRecordLabel() { + return readRecordLabel(this.bufferReader, this.name); + } + + enterDictionary() { + this.#readAndAssertByte(DICT_START); + this.#pushStackEntry('dictionary'); + } + + exitDictionary() { + this.#readAndAssertByte(DICT_END); + this.#popStackEntry('dictionary'); + } + + peekDictionaryEnd() { + const cc = this.bufferReader.peekByte(); + return cc === DICT_END; + } + + readDictionaryKey() { + return readDictionaryKey(this.bufferReader, this.name); + } + + enterList() { + this.#readAndAssertByte(LIST_START); + this.#pushStackEntry('list'); + } + + exitList() { + this.#readAndAssertByte(LIST_END); + this.#popStackEntry('list'); + } + + peekListEnd() { + const cc = this.bufferReader.peekByte(); + return cc === LIST_END; + } + + enterSet() { + this.#readAndAssertByte(SET_START); + this.#pushStackEntry('set'); + } + + exitSet() { + this.#readAndAssertByte(SET_END); + this.#popStackEntry('set'); + } + + peekSetEnd() { + const cc = this.bufferReader.peekByte(); + return cc === SET_END; + } + + readBoolean() { + return readBoolean(this.bufferReader, this.name); + } + + readInteger() { + return readInteger(this.bufferReader, this.name); + } + + readFloat64() { + return readFloat64(this.bufferReader, this.name); + } + + readString() { + return readString(this.bufferReader, this.name); + } + + readBytestring() { + return readBytestring(this.bufferReader, this.name); + } + + readSelectorAsString() { + return readSelectorAsString(this.bufferReader, this.name); + } + + readAny() { + return readAny(this.bufferReader, this.name); + } + + /** + * @param {'boolean' | 'integer' | 'float64' | 'string' | 'bytestring' | 'selector'} type + * @returns {any} + */ + readOfType(type) { + switch (type) { + case 'boolean': + return this.readBoolean(); + case 'integer': + return this.readInteger(); + case 'float64': + return this.readFloat64(); + case 'string': + return this.readString(); + case 'bytestring': + return this.readBytestring(); + case 'selector': + return this.readSelectorAsString(); + default: + throw Error(`Unexpected type ${type}`); + } + } + + peekTypeHint() { + return peekTypeHint(this.bufferReader, this.name); } - throw Error( - `Unexpected character ${JSON.stringify( - String.fromCharCode(cc), - )} at index ${start} of ${name}`, - ); } +export const makeSyrupReader = (bytes, options = {}) => { + const bufferReader = BufferReader.fromBytes(bytes); + const syrupReader = new SyrupReader(bufferReader, options); + return syrupReader; +}; + /** * @param {Uint8Array} bytes * @param {object} options @@ -357,17 +649,21 @@ function decodeAny(bytes, start, end, name) { * @param {number} [options.end] */ export function decodeSyrup(bytes, options = {}) { - const { start = 0, end = bytes.byteLength, name = '' } = options; - if (end > bytes.byteLength) { - throw Error( - `Cannot decode Syrup with with "end" beyond "bytes.byteLength", got ${end}, byteLength ${bytes.byteLength}`, - ); + const { start = 0, name = '' } = options; + const bufferReader = BufferReader.fromBytes(bytes); + if (start !== 0) { + bufferReader.seek(start); } - const { start: next, value } = decodeAny(bytes, start, end, name); - if (next !== end) { - throw Error( - `Unexpected trailing bytes after Syrup, length = ${end - next}`, - ); + try { + return readAny(bufferReader, name); + } catch (err) { + if (err.code === 'EOD') { + const err2 = Error( + `Unexpected end of Syrup at index ${bufferReader.length} of ${name}`, + ); + err2.cause = err; + throw err2; + } + throw err; } - return value; } diff --git a/packages/syrup/src/encode.js b/packages/syrup/src/encode.js index ee69cdff73..f970eb4607 100644 --- a/packages/syrup/src/encode.js +++ b/packages/syrup/src/encode.js @@ -1,114 +1,120 @@ // @ts-check +import { BufferWriter } from './buffer-writer.js'; import { compareByteArrays } from './compare.js'; +import { getSyrupSelectorName } from './selector.js'; -const { freeze, keys } = Object; +const { freeze } = Object; +const { ownKeys } = Reflect; const defaultCapacity = 256; +// const MINUS = '-'.charCodeAt(0); +// const PLUS = '+'.charCodeAt(0); +// const ZERO = '0'.charCodeAt(0); +// const ONE = '1'.charCodeAt(0); +// const NINE = '9'.charCodeAt(0); const LIST_START = '['.charCodeAt(0); const LIST_END = ']'.charCodeAt(0); const DICT_START = '{'.charCodeAt(0); const DICT_END = '}'.charCodeAt(0); -const DOUBLE = 'D'.charCodeAt(0); +const SET_START = '#'.charCodeAt(0); +const SET_END = '$'.charCodeAt(0); +// const BYTES_START = ':'.charCodeAt(0); +// const STRING_START = '"'.charCodeAt(0); +// const SELECTOR_START = "'".charCodeAt(0); +const RECORD_START = '<'.charCodeAt(0); +const RECORD_END = '>'.charCodeAt(0); const TRUE = 't'.charCodeAt(0); const FALSE = 'f'.charCodeAt(0); +// const SINGLE = 'F'.charCodeAt(0); +const FLOAT64 = 'D'.charCodeAt(0); const NAN64 = freeze([0x7f, 0xf8, 0, 0, 0, 0, 0, 0]); const textEncoder = new TextEncoder(); /** - * @typedef {object} Buffer - * @property {Uint8Array} bytes - * @property {DataView} data - * @property {number} length + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {Uint8Array} bytes + * @param {string} typeChar */ +function writeStringlike(bufferWriter, bytes, typeChar) { + // write length prefix as ascii string + const length = bytes.byteLength; + const lengthPrefix = `${length}`; + bufferWriter.writeString(lengthPrefix); + bufferWriter.writeString(typeChar); + bufferWriter.write(bytes); +} /** - * @param {Buffer} buffer - * @param {number} increaseBy - * @returns {number} old length + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {string} value */ -function grow(buffer, increaseBy) { - const cursor = buffer.length; - if (increaseBy === 0) { - return cursor; - } - buffer.length += increaseBy; - let capacity = buffer.bytes.length; - // Expand backing storage, leaving headroom for another similar-size increase. - if (buffer.length + increaseBy > capacity) { - while (buffer.length + increaseBy > capacity) { - capacity *= 2; - } - const bytes = new Uint8Array(capacity); - const data = new DataView(bytes.buffer); - bytes.set(buffer.bytes.subarray(0, buffer.length), 0); - buffer.bytes = bytes; - buffer.data = data; - } - return cursor; +function writeString(bufferWriter, value) { + const bytes = textEncoder.encode(value); + writeStringlike(bufferWriter, bytes, '"'); } /** - * @param {Buffer} buffer + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter * @param {string} value */ -function encodeString(buffer, value) { - const stringLength = value.length; - const likelyPrefixLength = `${stringLength}`.length + 1; - // buffer.length will be incorrect until we fix it before returning. - const start = grow(buffer, likelyPrefixLength + stringLength); - const likelyDataStart = start + likelyPrefixLength; - - for (let remaining = value, read = 0, written = 0; ; ) { - const chunk = textEncoder.encodeInto( - remaining, - buffer.bytes.subarray(likelyDataStart + written), - ); - written += chunk.written || 0; - read += chunk.read || 0; - if (read === stringLength) { - const prefix = `${written}"`; // length prefix quote suffix - const prefixLength = prefix.length; - buffer.length = start; - grow(buffer, prefixLength + written); - if (prefixLength !== likelyPrefixLength) { - buffer.bytes.copyWithin(start + prefixLength, likelyDataStart); // shift right - } - textEncoder.encodeInto( - prefix, - buffer.bytes.subarray(start, start + prefixLength), - ); - return; - } - remaining = remaining.substring(chunk.read || 0); - grow(buffer, buffer.bytes.length); +function writeSelector(bufferWriter, value) { + const bytes = textEncoder.encode(value); + writeStringlike(bufferWriter, bytes, "'"); +} + +/** + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {Uint8Array} value + */ +function writeBytestring(bufferWriter, value) { + writeStringlike(bufferWriter, value, ':'); +} + +/** + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {string | symbol} key + * @param {Array} path + */ +function writeDictionaryKey(bufferWriter, key, path) { + if (typeof key === 'string') { + writeString(bufferWriter, key); + return; } + if (typeof key === 'symbol') { + const syrupSelector = getSyrupSelectorName(key); + writeSelector(bufferWriter, syrupSelector); + return; + } + throw TypeError( + `Dictionary keys must be strings or symbols, got ${typeof key} at ${path.join('/')}`, + ); } /** - * @param {Buffer} buffer - * @param {Record} record - * @param {Array} path + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {Record} record + * @param {Array} path */ -function encodeRecord(buffer, record, path) { - const restart = buffer.length; +function writeDictionary(bufferWriter, record, path) { const indexes = []; - const keyStrings = []; + const keys = []; const keyBytes = []; - for (const key of keys(record)) { - const start = buffer.length; - encodeString(buffer, key); - const end = buffer.length; + // We need to sort the keys, so we write them to a scratch buffer first + const scratchWriter = new BufferWriter(); + for (const key of ownKeys(record)) { + const start = scratchWriter.length; + writeDictionaryKey(scratchWriter, key, path); + const end = scratchWriter.length; - keyStrings.push(key); - keyBytes.push(buffer.bytes.subarray(start, end)); + keys.push(key); + keyBytes.push(scratchWriter.subarray(start, end)); indexes.push(indexes.length); } - indexes.sort((i, j) => compareByteArrays( keyBytes[i], @@ -120,111 +126,120 @@ function encodeRecord(buffer, record, path) { ), ); - buffer.length = restart; - - let cursor = grow(buffer, 1); - buffer.bytes[cursor] = DICT_START; - + // Now we write the dictionary + bufferWriter.writeByte(DICT_START); for (const index of indexes) { - const key = keyStrings[index]; + const key = keys[index]; const value = record[key]; + const bytes = keyBytes[index]; - encodeString(buffer, key); + bufferWriter.write(bytes); // Recursion, it's a thing! // eslint-disable-next-line no-use-before-define - encodeAny(buffer, value, path, key); + writeAny(bufferWriter, value, path, key); } - - cursor = grow(buffer, 1); - buffer.bytes[cursor] = DICT_END; + bufferWriter.writeByte(DICT_END); } /** - * @param {Buffer} buffer + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter * @param {Array} array - * @param {Array} path + * @param {Array} path */ -function encodeArray(buffer, array, path) { - let cursor = grow(buffer, 2 + array.length); - buffer.length = cursor + 1; - buffer.bytes[cursor] = LIST_START; +function writeList(bufferWriter, array, path) { + bufferWriter.writeByte(LIST_START); let index = 0; for (const value of array) { // Recursion, it's a thing! // eslint-disable-next-line no-use-before-define - encodeAny(buffer, value, path, index); + writeAny(bufferWriter, value, path, index); index += 1; } - cursor = grow(buffer, 1); - buffer.bytes[cursor] = LIST_END; + bufferWriter.writeByte(LIST_END); } /** - * @param {Buffer} buffer + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {number} value + */ +function writeFloat64(bufferWriter, value) { + bufferWriter.writeByte(FLOAT64); + if (value === 0) { + // no-op + } else if (Number.isNaN(value)) { + // Canonicalize NaN + // @ts-expect-error using frozen array as Uint8Array + bufferWriter.write(NAN64); + } else { + bufferWriter.writeFloat64(value, false); // big end + } +} + +/** + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {bigint} value + */ +function writeInteger(bufferWriter, value) { + const string = value >= 0 ? `${value}+` : `${-value}-`; + bufferWriter.writeString(string); +} + +/** + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter + * @param {boolean} value + */ +function writeBoolean(bufferWriter, value) { + bufferWriter.writeByte(value ? TRUE : FALSE); +} + +/** + * @param {import('./buffer-writer.js').BufferWriter} bufferWriter * @param {any} value - * @param {Array} path - * @param {string | number} pathSuffix + * @param {Array} path + * @param {string | symbol | number} pathSuffix */ -function encodeAny(buffer, value, path, pathSuffix) { +function writeAny(bufferWriter, value, path, pathSuffix) { + if (typeof value === 'symbol') { + writeSelector(bufferWriter, getSyrupSelectorName(value)); + return; + } + if (typeof value === 'string') { - encodeString(buffer, value); + writeString(bufferWriter, value); return; } if (typeof value === 'number') { - const cursor = grow(buffer, 9); - buffer.bytes[cursor] = DOUBLE; - if (value === 0) { - // no-op - } else if (Number.isNaN(value)) { - // Canonicalize NaN - buffer.bytes.set(NAN64, cursor + 1); - } else { - buffer.data.setFloat64(cursor + 1, value, false); // big end - } + writeFloat64(bufferWriter, value); return; } if (typeof value === 'bigint') { - const string = value >= 0 ? `${value}+` : `${-value}-`; - const cursor = grow(buffer, string.length); - textEncoder.encodeInto(string, buffer.bytes.subarray(cursor)); + writeInteger(bufferWriter, value); return; } if (value instanceof Uint8Array) { - const prefix = `${value.length}:`; // decimal and colon suffix - const cursor = grow(buffer, prefix.length + value.length); - textEncoder.encodeInto(prefix, buffer.bytes.subarray(cursor)); - buffer.bytes.set(value, cursor + prefix.length); + writeBytestring(bufferWriter, value); return; } if (Array.isArray(value)) { - path.push(pathSuffix); - encodeArray(buffer, value, path); - path.pop(); + writeList(bufferWriter, value, path); return; } if (Object(value) === value) { path.push(pathSuffix); - encodeRecord(buffer, value, path); + writeDictionary(bufferWriter, value, path); path.pop(); return; } - if (value === false) { - const cursor = grow(buffer, 1); - buffer.bytes[cursor] = FALSE; - return; - } - - if (value === true) { - const cursor = grow(buffer, 1); - buffer.bytes[cursor] = TRUE; + if (value === true || value === false) { + writeBoolean(bufferWriter, value); return; } @@ -232,6 +247,108 @@ function encodeAny(buffer, value, path, pathSuffix) { throw TypeError(`Cannot encode value ${value} at ${path.join('/')}`); } +export class SyrupWriter { + /** @type {BufferWriter} */ + #bufferWriter; + + constructor(bufferWriter) { + this.#bufferWriter = bufferWriter; + } + + writeAny(value) { + writeAny(this.#bufferWriter, value, [], '/'); + } + + writeSelector(value) { + writeSelector(this.#bufferWriter, value); + } + + writeString(value) { + writeString(this.#bufferWriter, value); + } + + writeBytestring(value) { + writeBytestring(this.#bufferWriter, value); + } + + writeBoolean(value) { + writeBoolean(this.#bufferWriter, value); + } + + writeInteger(value) { + writeInteger(this.#bufferWriter, value); + } + + writeFloat64(value) { + writeFloat64(this.#bufferWriter, value); + } + + enterRecord() { + this.#bufferWriter.writeByte(RECORD_START); + } + + exitRecord() { + this.#bufferWriter.writeByte(RECORD_END); + } + + enterList() { + this.#bufferWriter.writeByte(LIST_START); + } + + exitList() { + this.#bufferWriter.writeByte(LIST_END); + } + + enterDictionary() { + this.#bufferWriter.writeByte(DICT_START); + } + + exitDictionary() { + this.#bufferWriter.writeByte(DICT_END); + } + + enterSet() { + this.#bufferWriter.writeByte(SET_START); + } + + exitSet() { + this.#bufferWriter.writeByte(SET_END); + } + + /** + * @param {'boolean' | 'integer' | 'float64' | 'string' | 'bytestring' | 'selector'} type + * @param {any} value + */ + writeOfType(type, value) { + switch (type) { + case 'selector': + this.writeSelector(value); + break; + case 'bytestring': + this.writeBytestring(value); + break; + case 'string': + this.writeString(value); + break; + case 'float64': + this.writeFloat64(value); + break; + case 'integer': + this.writeInteger(value); + break; + case 'boolean': + this.writeBoolean(value); + break; + default: + throw Error(`writeTypeOf: unknown type ${typeof value}`); + } + } + + getBytes() { + return this.#bufferWriter.subarray(0, this.#bufferWriter.length); + } +} + /** * @param {any} value * @param {object} [options] @@ -241,10 +358,13 @@ function encodeAny(buffer, value, path, pathSuffix) { */ export function encodeSyrup(value, options = {}) { const { length: capacity = defaultCapacity } = options; - const bytes = new Uint8Array(capacity); - const data = new DataView(bytes.buffer); - const length = 0; - const buffer = { bytes, data, length }; - encodeAny(buffer, value, [], '/'); - return buffer.bytes.subarray(0, buffer.length); + const bufferWriter = new BufferWriter(capacity); + writeAny(bufferWriter, value, [], '/'); + return bufferWriter.subarray(0, bufferWriter.length); +} + +export function makeSyrupWriter(options = {}) { + const { length: capacity = defaultCapacity } = options; + const bufferWriter = new BufferWriter(capacity); + return new SyrupWriter(bufferWriter); } diff --git a/packages/syrup/src/ocapn/components.js b/packages/syrup/src/ocapn/components.js new file mode 100644 index 0000000000..2a2b74fd60 --- /dev/null +++ b/packages/syrup/src/ocapn/components.js @@ -0,0 +1,76 @@ +import { makeRecordUnionCodec } from '../codec.js'; +import { makeOCapNRecordCodecFromDefinition } from './util.js'; + +/** @typedef {import('../codec.js').SyrupCodec} SyrupCodec */ + +const { freeze } = Object; + +/* + * OCapN Components are used in both OCapN Messages and Descriptors + */ + +/** + * @param {string} expectedLabel + * @returns {SyrupCodec} + */ +export const makeOCapNSignatureValueComponentCodec = expectedLabel => { + const read = syrupReader => { + const label = syrupReader.readSelectorAsString(); + if (label !== expectedLabel) { + throw Error(`Expected label ${expectedLabel}, got ${label}`); + } + const value = syrupReader.readBytestring(); + return value; + }; + const write = (value, syrupWriter) => { + syrupWriter.writeSelector(expectedLabel); + syrupWriter.writeBytestring(value); + }; + return freeze({ read, write }); +}; + +const OCapNSignatureRValue = makeOCapNSignatureValueComponentCodec('r'); +const OCapNSignatureSValue = makeOCapNSignatureValueComponentCodec('s'); + +export const OCapNSignature = makeOCapNRecordCodecFromDefinition('sig-val', [ + ['scheme', 'selector'], + ['r', OCapNSignatureRValue], + ['s', OCapNSignatureSValue], +]); + +export const OCapNNode = makeOCapNRecordCodecFromDefinition('ocapn-node', [ + ['transport', 'selector'], + ['address', 'bytestring'], + ['hints', 'boolean'], +]); + +export const OCapNSturdyRef = makeOCapNRecordCodecFromDefinition( + 'ocapn-sturdyref', + [ + ['node', OCapNNode], + ['swissNum', 'string'], + ], +); + +export const OCapNPublicKey = makeOCapNRecordCodecFromDefinition('public-key', [ + ['scheme', 'selector'], + ['curve', 'selector'], + ['flags', 'selector'], + ['q', 'bytestring'], +]); + +export const OCapNComponentUnionCodec = makeRecordUnionCodec({ + OCapNNode, + OCapNSturdyRef, + OCapNPublicKey, + OCapNSignature, +}); + +export const readOCapComponent = syrupReader => { + return OCapNComponentUnionCodec.read(syrupReader); +}; + +export const writeOCapComponent = (component, syrupWriter) => { + OCapNComponentUnionCodec.write(component, syrupWriter); + return syrupWriter.getBytes(); +}; diff --git a/packages/syrup/src/ocapn/descriptors.js b/packages/syrup/src/ocapn/descriptors.js new file mode 100644 index 0000000000..2d608cd6f9 --- /dev/null +++ b/packages/syrup/src/ocapn/descriptors.js @@ -0,0 +1,89 @@ +import { makeRecordUnionCodec } from '../codec.js'; +import { makeOCapNRecordCodecFromDefinition } from './util.js'; +import { PositiveIntegerCodec } from './subtypes.js'; +import { OCapNNode, OCapNPublicKey, OCapNSignature } from './components.js'; + +/* + * These are OCapN Descriptors, they are Passables that are used both + * directly in OCapN Messages and as part of Passable structures. + */ + +export const DescImportObject = makeOCapNRecordCodecFromDefinition( + 'desc:import-object', + [['position', PositiveIntegerCodec]], +); + +export const DescImportPromise = makeOCapNRecordCodecFromDefinition( + 'desc:import-promise', + [['position', PositiveIntegerCodec]], +); + +export const DescExport = makeOCapNRecordCodecFromDefinition('desc:export', [ + ['position', PositiveIntegerCodec], +]); + +export const DescAnswer = makeOCapNRecordCodecFromDefinition('desc:answer', [ + ['position', PositiveIntegerCodec], +]); + +export const DescHandoffGive = makeOCapNRecordCodecFromDefinition( + 'desc:handoff-give', + [ + ['receiverKey', OCapNPublicKey], + ['exporterLocation', OCapNNode], + ['session', 'bytestring'], + ['gifterSide', OCapNPublicKey], + ['giftId', 'bytestring'], + ], +); + +export const DescSigGiveEnvelope = makeOCapNRecordCodecFromDefinition( + 'desc:sig-envelope', + [ + ['object', DescHandoffGive], + ['signature', OCapNSignature], + ], +); + +export const DescHandoffReceive = makeOCapNRecordCodecFromDefinition( + 'desc:handoff-receive', + [ + ['receivingSession', 'bytestring'], + ['receivingSide', 'bytestring'], + ['handoffCount', PositiveIntegerCodec], + ['signedGive', DescSigGiveEnvelope], + ], +); + +export const DescSigReceiveEnvelope = makeOCapNRecordCodecFromDefinition( + 'desc:sig-envelope', + [ + ['object', DescHandoffReceive], + ['signature', OCapNSignature], + ], +); + +// Note: this may only be useful for testing +export const OCapNDescriptorUnionCodec = makeRecordUnionCodec({ + OCapNNode, + OCapNPublicKey, + OCapNSignature, + DescSigGiveEnvelope, + // TODO: ambiguous record label for DescSigGiveEnvelope and DescSigReceiveEnvelope + // DescSigReceiveEnvelope, + DescImportObject, + DescImportPromise, + DescExport, + DescAnswer, + DescHandoffGive, + DescHandoffReceive, +}); + +export const readOCapDescriptor = syrupReader => { + return OCapNDescriptorUnionCodec.read(syrupReader); +}; + +export const writeOCapDescriptor = (descriptor, syrupWriter) => { + OCapNDescriptorUnionCodec.write(descriptor, syrupWriter); + return syrupWriter.bufferWriter.subarray(0, syrupWriter.bufferWriter.length); +}; diff --git a/packages/syrup/src/ocapn/operations.js b/packages/syrup/src/ocapn/operations.js new file mode 100644 index 0000000000..edf8397c13 --- /dev/null +++ b/packages/syrup/src/ocapn/operations.js @@ -0,0 +1,146 @@ +import { makeRecordUnionCodec, makeTypeHintUnionCodec } from '../codec.js'; +import { makeOCapNRecordCodecFromDefinition } from './util.js'; +import { PositiveIntegerCodec, FalseCodec } from './subtypes.js'; +import { OCapNNode, OCapNPublicKey, OCapNSignature } from './components.js'; +import { OCapNPassableUnionCodec } from './passable.js'; +import { + DescImportObject, + DescImportPromise, + DescExport, + DescAnswer, +} from './descriptors.js'; + +const { freeze } = Object; + +/* + * These are OCapN Operations, they are messages that are sent between OCapN Nodes + */ + +const OpStartSession = makeOCapNRecordCodecFromDefinition('op:start-session', [ + ['captpVersion', 'string'], + ['sessionPublicKey', OCapNPublicKey], + ['location', OCapNNode], + ['locationSignature', OCapNSignature], +]); + +const OCapNResolveMeDescCodec = makeRecordUnionCodec({ + DescImportObject, + DescImportPromise, +}); + +const OpListen = makeOCapNRecordCodecFromDefinition('op:listen', [ + ['to', DescExport], + ['resolveMeDesc', OCapNResolveMeDescCodec], + ['wantsPartial', 'boolean'], +]); + +const OCapNDeliverTargets = { + DescExport, + DescAnswer, +}; + +const OCapNDeliverTargetCodec = makeRecordUnionCodec(OCapNDeliverTargets); + +/** @typedef {[string, ...any[]]} OpDeliverArgs */ + +// Used by the deliver and deliver-only operations +// First arg is method name, rest are Passables +const OpDeliverArgsCodec = freeze({ + /** + * @param {import('../decode.js').SyrupReader} syrupReader + * @returns {OpDeliverArgs} + */ + read: syrupReader => { + syrupReader.enterList(); + /** @type {OpDeliverArgs} */ + const result = [ + // method name + syrupReader.readSelectorAsString(), + ]; + while (!syrupReader.peekListEnd()) { + result.push(OCapNPassableUnionCodec.read(syrupReader)); + } + syrupReader.exitList(); + return result; + }, + /** + * @param {OpDeliverArgs} args + * @param {import('../encode.js').SyrupWriter} syrupWriter + */ + write: ([methodName, ...args], syrupWriter) => { + syrupWriter.enterList(); + syrupWriter.writeSelector(methodName); + for (const arg of args) { + OCapNPassableUnionCodec.write(arg, syrupWriter); + } + syrupWriter.exitList(); + }, +}); + +const OpDeliverOnly = makeOCapNRecordCodecFromDefinition('op:deliver-only', [ + ['to', OCapNDeliverTargetCodec], + ['args', OpDeliverArgsCodec], +]); + +// The OpDeliver answer is either a positive integer or false +const OpDeliverAnswerCodec = makeTypeHintUnionCodec( + { + 'number-prefix': PositiveIntegerCodec, + boolean: FalseCodec, + }, + { + bigint: PositiveIntegerCodec, + boolean: FalseCodec, + }, +); + +const OpDeliver = makeOCapNRecordCodecFromDefinition('op:deliver', [ + ['to', OCapNDeliverTargetCodec], + ['args', OpDeliverArgsCodec], + ['answerPosition', OpDeliverAnswerCodec], + ['resolveMeDesc', OCapNResolveMeDescCodec], +]); + +const OCapNPromiseRefCodec = makeRecordUnionCodec({ + DescAnswer, + DescImportPromise, +}); + +const OpPick = makeOCapNRecordCodecFromDefinition('op:pick', [ + ['promisePosition', OCapNPromiseRefCodec], + ['selectedValuePosition', 'integer'], + ['newAnswerPosition', 'integer'], +]); + +const OpAbort = makeOCapNRecordCodecFromDefinition('op:abort', [ + ['reason', 'string'], +]); + +const OpGcExport = makeOCapNRecordCodecFromDefinition('op:gc-export', [ + ['exportPosition', 'integer'], + ['wireDelta', 'integer'], +]); + +const OpGcAnswer = makeOCapNRecordCodecFromDefinition('op:gc-answer', [ + ['answerPosition', 'integer'], +]); + +export const OCapNMessageUnionCodec = makeRecordUnionCodec({ + OpStartSession, + OpDeliverOnly, + OpDeliver, + OpPick, + OpAbort, + OpListen, + OpGcExport, + OpGcAnswer, +}); + +export const readOCapNMessage = syrupReader => { + return OCapNMessageUnionCodec.read(syrupReader); +}; + +export const writeOCapNMessage = (message, syrupWriter) => { + OCapNMessageUnionCodec.write(message, syrupWriter); + return syrupWriter.bufferWriter.subarray(0, syrupWriter.bufferWriter.length); +}; diff --git a/packages/syrup/src/ocapn/passable.js b/packages/syrup/src/ocapn/passable.js new file mode 100644 index 0000000000..69e8172aa0 --- /dev/null +++ b/packages/syrup/src/ocapn/passable.js @@ -0,0 +1,214 @@ +import { + BooleanCodec, + IntegerCodec, + Float64Codec, + SelectorCodec, + StringCodec, + BytestringCodec, + ListCodec, + AnyCodec, + makeRecordUnionCodec, + makeTypeHintUnionCodec, +} from '../codec.js'; +import { + makeOCapNRecordCodec, + makeOCapNRecordCodecFromDefinition, +} from './util.js'; +import { + DescImportObject, + DescImportPromise, + DescExport, + DescAnswer, + DescHandoffGive, + DescHandoffReceive, +} from './descriptors.js'; + +/** @typedef {import('../codec.js').SyrupCodec} SyrupCodec */ +/** @typedef {import('../codec.js').SyrupRecordCodec} SyrupRecordCodec */ + +// OCapN Passable Atoms + +const UndefinedCodec = makeOCapNRecordCodec( + 'void', + // readBody + syrupReader => { + return undefined; + }, + // writeBody + (value, syrupWriter) => { + // body is empty + }, +); + +const NullCodec = makeOCapNRecordCodec( + 'null', + // readBody + syrupReader => { + return null; + }, + // writeBody + (value, syrupWriter) => { + // body is empty + }, +); + +const AtomCodecs = { + undefined: UndefinedCodec, + null: NullCodec, + boolean: BooleanCodec, + integer: IntegerCodec, + float64: Float64Codec, + string: StringCodec, + selector: SelectorCodec, + byteArray: BytestringCodec, +}; + +// OCapN Passable Containers + +/** @type {SyrupCodec} */ +export const OCapNStructCodec = { + read(syrupReader) { + syrupReader.enterDictionary(); + const result = {}; + while (!syrupReader.peekDictionaryEnd()) { + // OCapN Structs are always string keys. + const key = syrupReader.readString(); + // Value can be any Passable. + /* eslint-disable-next-line no-use-before-define */ + const value = OCapNPassableUnionCodec.read(syrupReader); + result[key] = value; + } + syrupReader.exitDictionary(); + return result; + }, + write(value, syrupWriter) { + syrupWriter.enterDictionary(); + for (const [key, structValue] of Object.entries(value)) { + syrupWriter.writeString(key); + // Value can be any Passable. + /* eslint-disable-next-line no-use-before-define */ + OCapNPassableUnionCodec.write(structValue, syrupWriter); + } + syrupWriter.exitDictionary(); + }, +}; + +const OCapNTaggedCodec = makeOCapNRecordCodec( + 'desc:tagged', + // readBody + syrupReader => { + const tagName = syrupReader.readSelectorAsString(); + // Value can be any Passable. + /* eslint-disable-next-line no-use-before-define */ + const value = OCapNPassableUnionCodec.read(syrupReader); + return { + [Symbol.for('passStyle')]: 'tagged', + [Symbol.toStringTag]: tagName, + value, + }; + }, + // writeBody + (value, syrupWriter) => { + syrupWriter.writeSelector(value.tagName); + value.value.write(syrupWriter); + }, +); + +const ContainerCodecs = { + list: ListCodec, + struct: OCapNStructCodec, + tagged: OCapNTaggedCodec, +}; + +// OCapN Reference (Capability) + +const OCapNTargetCodec = makeRecordUnionCodec({ + DescExport, + DescImportObject, +}); + +const OCapNPromiseCodec = makeRecordUnionCodec({ + DescImportPromise, + DescAnswer, +}); + +const OCapNReferenceCodecs = { + OCapNTargetCodec, + OCapNPromiseCodec, +}; + +// OCapN Error + +const OCapNErrorCodec = makeOCapNRecordCodecFromDefinition('desc:error', [ + ['message', 'string'], +]); + +// Provided for completeness, but not used. +// eslint-disable-next-line no-unused-vars +const OCapNPassableCodecs = { + ...AtomCodecs, + ...ContainerCodecs, + ...OCapNReferenceCodecs, + ...OCapNErrorCodec, +}; + +// all record based passables +const OCapNPassableRecordUnionCodec = makeRecordUnionCodec({ + UndefinedCodec, + NullCodec, + OCapNTaggedCodec, + DescExport, + DescImportObject, + DescImportPromise, + DescAnswer, + DescHandoffGive, + DescHandoffReceive, + // DescSigGiveEnvelope, + // DescSigReceiveEnvelope, + OCapNErrorCodec, +}); + +export const OCapNPassableUnionCodec = makeTypeHintUnionCodec( + // syrup type hint -> codec + { + boolean: AtomCodecs.boolean, + float64: AtomCodecs.float64, + // "number-prefix" can be string, bytestring, selector, integer + // TODO: should restrict further to only the types that can be passed + 'number-prefix': AnyCodec, + list: ContainerCodecs.list, + record: OCapNPassableRecordUnionCodec, + dictionary: ContainerCodecs.struct, + }, + // javascript typeof value -> codec + { + undefined: AtomCodecs.undefined, + boolean: AtomCodecs.boolean, + number: AtomCodecs.float64, + string: AtomCodecs.string, + symbol: AtomCodecs.selector, + bigint: AtomCodecs.integer, + object: value => { + if (value === null) { + return AtomCodecs.null; + } + if (value instanceof Uint8Array) { + return AtomCodecs.byteArray; + } + if (Array.isArray(value)) { + return ContainerCodecs.list; + } + if (value[Symbol.for('passStyle')] === 'tagged') { + return ContainerCodecs.tagged; + } + if ( + value.type !== undefined && + OCapNPassableRecordUnionCodec.supports(value.type) + ) { + return OCapNPassableRecordUnionCodec; + } + // TODO: need to distinguish OCapNReferenceCodecs and OCapNErrorCodec + return ContainerCodecs.struct; + }, + }, +); diff --git a/packages/syrup/src/ocapn/subtypes.js b/packages/syrup/src/ocapn/subtypes.js new file mode 100644 index 0000000000..88df853559 --- /dev/null +++ b/packages/syrup/src/ocapn/subtypes.js @@ -0,0 +1,40 @@ +const { freeze } = Object; + +/** @typedef {import('../codec.js').SyrupCodec} SyrupCodec */ + +/** @type {SyrupCodec} */ +export const PositiveIntegerCodec = freeze({ + write: (value, syrupWriter) => { + if (typeof value !== 'bigint') { + throw Error('PositiveIntegerCodec: value must be a bigint'); + } + if (value < 0n) { + throw Error('PositiveIntegerCodec: value must be positive'); + } + syrupWriter.writeInteger(value); + }, + read: syrupReader => { + const value = syrupReader.readInteger(); + if (value < 0n) { + throw Error('PositiveIntegerCodec: value must be positive'); + } + return value; + }, +}); + +/** @type {SyrupCodec} */ +export const FalseCodec = freeze({ + write: (value, syrupWriter) => { + if (value) { + throw Error('FalseCodec: value must be false'); + } + syrupWriter.writeBoolean(value); + }, + read: syrupReader => { + const value = syrupReader.readBoolean(); + if (value) { + throw Error('FalseCodec: value must be false'); + } + return value; + }, +}); diff --git a/packages/syrup/src/ocapn/util.js b/packages/syrup/src/ocapn/util.js new file mode 100644 index 0000000000..294ba52d16 --- /dev/null +++ b/packages/syrup/src/ocapn/util.js @@ -0,0 +1,28 @@ +import { makeRecordCodec, makeRecordCodecFromDefinition } from '../codec.js'; + +/** @typedef {import('../decode.js').SyrupReader} SyrupReader */ +/** @typedef {import('../encode.js').SyrupWriter} SyrupWriter */ +/** @typedef {import('../codec.js').SyrupRecordCodec} SyrupRecordCodec */ +/** @typedef {import('../codec.js').SyrupType} SyrupType */ +/** @typedef {import('../codec.js').SyrupCodec} SyrupCodec */ + +/** + * @param {string} label + * @param {Array<[string, SyrupType | SyrupCodec]>} definition + * @returns {SyrupRecordCodec} + */ +export const makeOCapNRecordCodecFromDefinition = (label, definition) => { + // Syrup Records as used in OCapN are always labeled with selectors + return makeRecordCodecFromDefinition(label, 'selector', definition); +}; + +/** + * @param {string} label + * @param {function(SyrupReader): any} readBody + * @param {function(any, SyrupWriter): void} writeBody + * @returns {SyrupRecordCodec} + */ +export const makeOCapNRecordCodec = (label, readBody, writeBody) => { + // Syrup Records as used in OCapN are always labeled with selectors + return makeRecordCodec(label, 'selector', readBody, writeBody); +}; diff --git a/packages/syrup/src/selector.js b/packages/syrup/src/selector.js new file mode 100644 index 0000000000..37bce6b74a --- /dev/null +++ b/packages/syrup/src/selector.js @@ -0,0 +1,23 @@ +export const SYRUP_SELECTOR_PREFIX = 'syrup:'; + +// To be used as keys, syrup selectors must be javascript symbols. +// To avoid an otherwise meaningful symbol name, we prefix it with 'syrup:'. +export const SyrupSelectorFor = name => + Symbol.for(`${SYRUP_SELECTOR_PREFIX}${name}`); + +/** + * @param {symbol} selectorSymbol + * @returns {string} + */ +export const getSyrupSelectorName = selectorSymbol => { + const description = selectorSymbol.description; + if (!description) { + throw TypeError(`Symbol ${String(selectorSymbol)} has no description`); + } + if (!description.startsWith(SYRUP_SELECTOR_PREFIX)) { + throw TypeError( + `Symbol ${String(selectorSymbol)} has a description that does not start with "${SYRUP_SELECTOR_PREFIX}", got "${description}"`, + ); + } + return description.slice(SYRUP_SELECTOR_PREFIX.length); +}; diff --git a/packages/syrup/test/_ocapn.js b/packages/syrup/test/_ocapn.js new file mode 100644 index 0000000000..5fb3d7ce82 --- /dev/null +++ b/packages/syrup/test/_ocapn.js @@ -0,0 +1,516 @@ +const sym = s => `${s.length}'${s}`; +const str = s => `${s.length}"${s}`; +const bts = s => `${s.length}:${s}`; +const bool = b => (b ? 't' : 'f'); +// eslint-disable-next-line @endo/restrict-comparison-operands +const int = i => `${Math.floor(Math.abs(i))}${i < 0 ? '-' : '+'}`; +const list = items => `[${items.join('')}]`; +const makeNode = (transport, address, hints) => { + return `<10'ocapn-node${sym(transport)}${bts(address)}${bool(hints)}>`; +}; + +const makePubKey = (scheme, curve, flags, q) => { + return `<${sym('public-key')}${sym(scheme)}${sym(curve)}${sym(flags)}${bts(q)}>`; +}; + +const makeSigComp = (label, value) => { + return `${sym(label)}${bts(value)}`; +}; + +const makeSig = (scheme, r, s) => { + return `<${sym('sig-val')}${sym(scheme)}${makeSigComp('r', r)}${makeSigComp('s', s)}>`; +}; + +const makeExport = position => { + return `<${sym('desc:export')}${int(position)}>`; +}; + +const makeImportObj = position => { + return `<${sym('desc:import-object')}${int(position)}>`; +}; + +const makeImportPromise = position => { + return `<${sym('desc:import-promise')}${int(position)}>`; +}; + +const makeDescGive = ( + receiverKey, + exporterLocation, + session, + gifterSide, + giftId, +) => { + return `<${sym('desc:handoff-give')}${receiverKey}${exporterLocation}${bts(session)}${gifterSide}${bts(giftId)}>`; +}; + +const makeSigEnvelope = (object, signature) => { + return `<${sym('desc:sig-envelope')}${object}${signature}>`; +}; + +const makeHandoffReceive = ( + recieverSession, + recieverSide, + handoffCount, + descGive, + signature, +) => { + const signedGiveEnvelope = makeSigEnvelope(descGive, signature); + return `<${sym('desc:handoff-receive')}${bts(recieverSession)}${bts(recieverSide)}${int(handoffCount)}${signedGiveEnvelope}>`; +}; + +const strToUint8Array = string => { + return new Uint8Array(string.split('').map(c => c.charCodeAt(0))); +}; + +// I made up these syrup values by hand, they may be wrong, sorry. +// Would like external test data for this. + +export const componentsTable = [ + { + syrup: `${makeSig('eddsa', '1', '2')}`, + value: { + type: 'sig-val', + scheme: 'eddsa', + r: new Uint8Array([0x31]), + s: new Uint8Array([0x32]), + }, + }, + { + syrup: `<10'ocapn-node3'tcp1:0f>`, + value: { + type: 'ocapn-node', + transport: 'tcp', + address: new Uint8Array([0x30]), + hints: false, + }, + }, + { + syrup: `<15'ocapn-sturdyref${makeNode('tcp', '0', false)}${str('1')}>`, + value: { + type: 'ocapn-sturdyref', + node: { + type: 'ocapn-node', + transport: 'tcp', + address: new Uint8Array([0x30]), + hints: false, + }, + swissNum: '1', + }, + }, + { + syrup: makePubKey('ecc', 'Ed25519', 'eddsa', '1'), + value: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: strToUint8Array('1'), + }, + }, +]; + +export const descriptorsTable = [ + { + syrup: `<18'desc:import-object123+>`, + value: { + type: 'desc:import-object', + position: 123n, + }, + }, + { + syrup: `<19'desc:import-promise456+>`, + value: { + type: 'desc:import-promise', + position: 456n, + }, + }, + { + syrup: `<11'desc:export123+>`, + value: { + type: 'desc:export', + position: 123n, + }, + }, + { + syrup: `<11'desc:answer456+>`, + value: { + type: 'desc:answer', + position: 456n, + }, + }, + { + syrup: `<${sym('desc:handoff-give')}${makePubKey('ecc', 'Ed25519', 'eddsa', '1')}${makeNode('tcp', '127.0.0.1', false)}${bts('123')}${makePubKey('ecc', 'Ed25519', 'eddsa', '2')}${bts('456')}>`, + value: { + type: 'desc:handoff-give', + receiverKey: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: new Uint8Array([0x31]), + }, + exporterLocation: { + type: 'ocapn-node', + transport: 'tcp', + address: new Uint8Array([ + 0x31, 0x32, 0x37, 0x2e, 0x30, 0x2e, 0x30, 0x2e, 0x31, + ]), + hints: false, + }, + session: new Uint8Array([0x31, 0x32, 0x33]), + gifterSide: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: new Uint8Array([0x32]), + }, + giftId: new Uint8Array([0x34, 0x35, 0x36]), + }, + }, + { + syrup: `<${sym('desc:sig-envelope')}${makeDescGive( + makePubKey('ed25519', 'ed25519', 'ed25519', '123'), + makeNode('tcp', '127.0.0.1', false), + '123', + makePubKey('ed25519', 'ed25519', 'ed25519', '123'), + '123', + )}${makeSig('eddsa', '1', '2')}>`, + value: { + type: 'desc:sig-envelope', + object: { + type: 'desc:handoff-give', + receiverKey: { + type: 'public-key', + scheme: 'ed25519', + curve: 'ed25519', + flags: 'ed25519', + q: strToUint8Array('123'), + }, + exporterLocation: { + type: 'ocapn-node', + transport: 'tcp', + address: strToUint8Array('127.0.0.1'), + hints: false, + }, + session: strToUint8Array('123'), + gifterSide: { + type: 'public-key', + scheme: 'ed25519', + curve: 'ed25519', + flags: 'ed25519', + q: strToUint8Array('123'), + }, + giftId: strToUint8Array('123'), + }, + signature: { + type: 'sig-val', + scheme: 'eddsa', + r: strToUint8Array('1'), + s: strToUint8Array('2'), + }, + }, + }, + // handoff receive + { + syrup: `<${sym('desc:handoff-receive')}${bts('123')}${bts('456')}${int(1)}${makeSigEnvelope( + makeDescGive( + makePubKey('ecc', 'Ed25519', 'eddsa', '123'), + makeNode('tcp', '456', false), + '789', + makePubKey('ecc', 'Ed25519', 'eddsa', 'abc'), + 'def', + ), + makeSig('eddsa', '1', '2'), + )}>`, + value: { + type: 'desc:handoff-receive', + receivingSession: strToUint8Array('123'), + receivingSide: strToUint8Array('456'), + handoffCount: 1n, + signedGive: { + type: 'desc:sig-envelope', + object: { + type: 'desc:handoff-give', + receiverKey: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: strToUint8Array('123'), + }, + exporterLocation: { + type: 'ocapn-node', + transport: 'tcp', + address: strToUint8Array('456'), + hints: false, + }, + session: strToUint8Array('789'), + gifterSide: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: strToUint8Array('abc'), + }, + giftId: strToUint8Array('def'), + }, + signature: { + type: 'sig-val', + scheme: 'eddsa', + r: strToUint8Array('1'), + s: strToUint8Array('2'), + }, + }, + }, + }, +]; + +export const operationsTable = [ + { + // ; CapTP signature + syrup: `<${sym('op:start-session')}${str('captp-v1')}${makePubKey('ecc', 'Ed25519', 'eddsa', '123')}${makeNode('tcp', '127.0.0.1', false)}${makeSig('eddsa', '1', '2')}>`, + value: { + type: 'op:start-session', + captpVersion: 'captp-v1', + sessionPublicKey: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: strToUint8Array('123'), + }, + location: { + type: 'ocapn-node', + transport: 'tcp', + address: strToUint8Array('127.0.0.1'), + hints: false, + }, + locationSignature: { + type: 'sig-val', + scheme: 'eddsa', + r: strToUint8Array('1'), + s: strToUint8Array('2'), + }, + }, + }, + { + // ['fulfill ]> + syrup: `<${sym('op:deliver-only')}${makeExport(1)}${list([sym('fulfill'), makeImportObj(1)])}>`, + value: { + type: 'op:deliver-only', + to: { + type: 'desc:export', + position: 1n, + }, + args: [ + 'fulfill', + { + type: 'desc:import-object', + position: 1n, + }, + ], + }, + }, + { + // ; Remote bootstrap object + // ['deposit-gift ; Symbol "deposit-gift" + // 42 ; gift-id, a positive integer + // ]> ; remote object being shared + syrup: `<${sym('op:deliver-only')}${makeExport(0)}${list([sym('deposit-gift'), int(42), makeImportObj(1)])}>`, + value: { + type: 'op:deliver-only', + to: { + type: 'desc:export', + position: 0n, + }, + args: ['deposit-gift', 42n, { type: 'desc:import-object', position: 1n }], + }, + }, + { + // ['make-car-factory] 3 > + syrup: `<${sym('op:deliver')}${makeExport(5)}${list([sym('make-car-factory')])}${int(3)}${makeImportObj(15)}>`, + value: { + type: 'op:deliver', + to: { + type: 'desc:export', + position: 5n, + }, + args: ['make-car-factory'], + answerPosition: 3n, + resolveMeDesc: { + type: 'desc:import-object', + position: 15n, + }, + }, + }, + { + // ['beep] false > + syrup: `<${sym('op:deliver')}${makeExport(1)}${list([sym('beep')])}${bool(false)}${makeImportObj(2)}>`, + value: { + type: 'op:deliver', + to: { + type: 'desc:export', + position: 1n, + }, + args: ['beep'], + answerPosition: false, + resolveMeDesc: { + type: 'desc:import-object', + position: 2n, + }, + }, + }, + { + // ; Remote bootstrap object + // ['fetch ; Argument 1: Symbol "fetch" + // swiss-number] ; Argument 2: Binary Data + // 3 ; Answer position: positive integer + // > ; object exported by us at position 5 should provide the answer + syrup: `<${sym('op:deliver')}${makeExport(0)}${list([ + sym('fetch'), + bts('swiss-number'), + ])}${int(3)}${makeImportObj(5)}>`, + value: { + type: 'op:deliver', + to: { type: 'desc:export', position: 0n }, + args: ['fetch', strToUint8Array('swiss-number')], + answerPosition: 3n, + resolveMeDesc: { + type: 'desc:import-object', + position: 5n, + }, + }, + }, + { + // ; Remote bootstrap object + // [withdraw-gift ; Argument 1: Symbol "withdraw-gift" + // ] ; Argument 2: desc:handoff-receive + // 1 ; Answer position: Positive integer or false + // > ; The object exported (by us) at position 3, should receive the gift. + syrup: `<${sym('op:deliver')}${makeExport(0)}${list([ + sym('withdraw-gift'), + makeHandoffReceive( + '123', + '456', + 1, + makeDescGive( + makePubKey('ecc', 'Ed25519', 'eddsa', '123'), + makeNode('tcp', '456', false), + '789', + makePubKey('ecc', 'Ed25519', 'eddsa', 'abc'), + 'def', + ), + makeSig('eddsa', '1', '2'), + ), + ])}${int(1)}${makeImportObj(3)}>`, + value: { + type: 'op:deliver', + to: { type: 'desc:export', position: 0n }, + args: [ + 'withdraw-gift', + { + type: 'desc:handoff-receive', + receivingSession: strToUint8Array('123'), + receivingSide: strToUint8Array('456'), + handoffCount: 1n, + signedGive: { + type: 'desc:sig-envelope', + object: { + type: 'desc:handoff-give', + receiverKey: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: strToUint8Array('123'), + }, + exporterLocation: { + type: 'ocapn-node', + transport: 'tcp', + address: strToUint8Array('456'), + hints: false, + }, + session: strToUint8Array('789'), + gifterSide: { + type: 'public-key', + scheme: 'ecc', + curve: 'Ed25519', + flags: 'eddsa', + q: strToUint8Array('abc'), + }, + giftId: strToUint8Array('def'), + }, + signature: { + type: 'sig-val', + scheme: 'eddsa', + r: strToUint8Array('1'), + s: strToUint8Array('2'), + }, + }, + }, + ], + answerPosition: 1n, + resolveMeDesc: { + type: 'desc:import-object', + position: 3n, + }, + }, + }, + { + // ; + // ; Positive Integer + // > ; Positive Integer + syrup: `<${sym('op:pick')}${makeImportPromise(1)}${int(2)}${int(3)}>`, + value: { + type: 'op:pick', + promisePosition: { + type: 'desc:import-promise', + position: 1n, + }, + selectedValuePosition: 2n, + newAnswerPosition: 3n, + }, + }, + { + // ; reason: String + syrup: `<${sym('op:abort')}${str('explode')}>`, + value: { + type: 'op:abort', + reason: 'explode', + }, + }, + { + // `, + value: { + type: 'op:listen', + to: { type: 'desc:export', position: 1n }, + resolveMeDesc: { type: 'desc:import-object', position: 2n }, + wantsPartial: false, + }, + }, + { + // ; positive integer + syrup: `<${sym('op:gc-export')}${int(1)}${int(2)}>`, + value: { + type: 'op:gc-export', + exportPosition: 1n, + wireDelta: 2n, + }, + }, + { + // ; answer-pos: positive integer + syrup: `<${sym('op:gc-answer')}${int(1)}>`, + value: { + type: 'op:gc-answer', + answerPosition: 1n, + }, + }, +]; diff --git a/packages/syrup/test/_table.js b/packages/syrup/test/_table.js index 8dcff53450..ddda3e2a1a 100644 --- a/packages/syrup/test/_table.js +++ b/packages/syrup/test/_table.js @@ -1,3 +1,5 @@ +import { SyrupSelectorFor } from '../src/selector.js'; + const textEncoder = new TextEncoder(); export const table = [ @@ -7,13 +9,21 @@ export const table = [ { syrup: 't', value: true }, { syrup: 'f', value: false }, { syrup: '5"hello', value: 'hello' }, + { syrup: "5'hello", value: SyrupSelectorFor('hello') }, { syrup: '5:hello', value: textEncoder.encode('hello') }, { syrup: '[1+2+3+]', value: [1n, 2n, 3n] }, { syrup: '[3"abc3"def]', value: ['abc', 'def'] }, { syrup: '{1"a10+1"b20+}', value: { a: 10n, b: 20n } }, - { syrup: '{1"a10+1"b20+}', value: { b: 20n, a: 10n } }, // order canonicalization + { syrup: '{1"a10+1"b20+}', value: { b: 20n, a: 10n } }, + // order canonicalization { syrup: '{0"10+1"i20+}', value: { '': 10n, i: 20n } }, - { syrup: '{0"10+1"i20+}', value: { i: 20n, '': 10n } }, // order canonicalization + // order canonicalization + { syrup: '{0"10+1"i20+}', value: { i: 20n, '': 10n } }, + // dictionary with mixed string and selector keys + { + syrup: '{3"dog20+3\'cat10+}', + value: { dog: 20n, [SyrupSelectorFor('cat')]: 10n }, + }, { syrup: 'D?\xf0\x00\x00\x00\x00\x00\x00', value: 1 }, { syrup: 'D@^\xdd/\x1a\x9f\xbew', value: 123.456 }, { syrup: '[3"foo123+t]', value: ['foo', 123n, true] }, diff --git a/packages/syrup/test/_zoo.bin b/packages/syrup/test/_zoo.bin new file mode 100644 index 0000000000..7c17ef95b4 Binary files /dev/null and b/packages/syrup/test/_zoo.bin differ diff --git a/packages/syrup/test/codec.test.js b/packages/syrup/test/codec.test.js new file mode 100644 index 0000000000..f2a71f9065 --- /dev/null +++ b/packages/syrup/test/codec.test.js @@ -0,0 +1,166 @@ +// @ts-check + +import test from 'ava'; +import path from 'path'; +import fs from 'fs'; +import { makeSyrupReader } from '../src/decode.js'; +import { makeSyrupWriter } from '../src/encode.js'; +import { + makeRecordUnionCodec, + makeRecordCodecFromDefinition, + StringCodec, + makeListCodecFromEntryCodec, +} from '../src/codec.js'; + +/** @typedef {import('../src/codec.js').SyrupCodec} SyrupCodec */ + +const textDecoder = new TextDecoder(); +const textEncoder = new TextEncoder(); +// zoo.bin from https://github.com/ocapn/syrup/tree/2214cbb7c0ee081699fdef64edbc2444af2bb1d2/test-data +// eslint-disable-next-line no-underscore-dangle +const __dirname = path.dirname(new URL(import.meta.url).pathname); +const zooBinRaw = fs.readFileSync(path.resolve(__dirname, '_zoo.bin')); +// nodejs can provide a buffer with a non-zero byteOffset, which confuses the buffer reader +const zooBin = Uint8Array.from(zooBinRaw); + +const testCodecBidirectionally = (t, codec, value) => { + const writer = makeSyrupWriter(); + codec.write(value, writer); + const bytes = writer.getBytes(); + const reader = makeSyrupReader(bytes); + const result = codec.read(reader); + t.deepEqual(result, value); +}; + +test('simple string codec', t => { + const codec = StringCodec; + const value = 'hello'; + testCodecBidirectionally(t, codec, value); +}); + +test('basic record codec cases', t => { + const codec = makeRecordCodecFromDefinition('test', 'selector', [ + ['field1', 'string'], + ['field2', 'integer'], + ]); + const value = { + type: 'test', + field1: 'hello', + field2: 123n, + }; + testCodecBidirectionally(t, codec, value); +}); + +test('record union codec', t => { + const codec = makeRecordUnionCodec({ + testA: makeRecordCodecFromDefinition('testA', 'selector', [ + ['field1', 'string'], + ['field2', 'integer'], + ]), + testB: makeRecordCodecFromDefinition('testB', 'selector', [ + ['field1', 'string'], + ['field2', 'integer'], + ]), + }); + const value = { + type: 'testA', + field1: 'hello', + field2: 123n, + }; + testCodecBidirectionally(t, codec, value); +}); + +test('zoo.bin', t => { + /** @type {SyrupCodec} */ + const inhabitantCodec = { + read: syrupReader => { + const result = {}; + syrupReader.enterDictionary(); + t.is(syrupReader.readSelectorAsString(), 'age'); + result.age = syrupReader.readInteger(); + t.is(syrupReader.readSelectorAsString(), 'eats'); + result.eats = []; + syrupReader.enterSet(); + while (!syrupReader.peekSetEnd()) { + result.eats.push(textDecoder.decode(syrupReader.readBytestring())); + } + syrupReader.exitSet(); + t.is(syrupReader.readSelectorAsString(), 'name'); + result.name = syrupReader.readString(); + t.is(syrupReader.readSelectorAsString(), 'alive?'); + result.alive = syrupReader.readBoolean(); + t.is(syrupReader.readSelectorAsString(), 'weight'); + result.weight = syrupReader.readFloat64(); + t.is(syrupReader.readSelectorAsString(), 'species'); + result.species = textDecoder.decode(syrupReader.readBytestring()); + syrupReader.exitDictionary(); + return result; + }, + write: (value, syrupWriter) => { + syrupWriter.enterDictionary(); + syrupWriter.writeSelector('age'); + syrupWriter.writeInteger(value.age); + syrupWriter.writeSelector('eats'); + syrupWriter.enterSet(); + for (const eat of value.eats) { + syrupWriter.writeBytestring(textEncoder.encode(eat)); + } + syrupWriter.exitSet(); + syrupWriter.writeSelector('name'); + syrupWriter.writeString(value.name); + syrupWriter.writeSelector('alive?'); + syrupWriter.writeBoolean(value.alive); + syrupWriter.writeSelector('weight'); + syrupWriter.writeFloat64(value.weight); + syrupWriter.writeSelector('species'); + syrupWriter.writeBytestring(textEncoder.encode(value.species)); + syrupWriter.exitDictionary(); + }, + }; + + const inhabitantListCodec = makeListCodecFromEntryCodec(inhabitantCodec); + + const zooCodec = makeRecordCodecFromDefinition('zoo', 'bytestring', [ + ['title', 'string'], + ['inhabitants', inhabitantListCodec], + ]); + + const reader = makeSyrupReader(zooBin, { name: 'zoo' }); + const value = zooCodec.read(reader); + t.deepEqual(value, { + type: 'zoo', + title: 'The Grand Menagerie', + inhabitants: [ + { + age: 12n, + eats: ['fish', 'mice', 'kibble'], + name: 'Tabatha', + alive: true, + weight: 8.2, + species: 'cat', + }, + { + age: 6n, + eats: ['bananas', 'insects'], + name: 'George', + alive: false, + weight: 17.24, + species: 'monkey', + }, + { + age: -12n, + eats: [], + name: 'Casper', + alive: false, + weight: -34.5, + species: 'ghost', + }, + ], + }); + const writer = makeSyrupWriter(); + zooCodec.write(value, writer); + const bytes = writer.getBytes(); + const resultSyrup = textDecoder.decode(bytes); + const originalSyrup = textDecoder.decode(zooBin); + t.deepEqual(resultSyrup, originalSyrup); +}); diff --git a/packages/syrup/test/decode.test.js b/packages/syrup/test/decode.test.js index 922f349d54..b2b130b531 100644 --- a/packages/syrup/test/decode.test.js +++ b/packages/syrup/test/decode.test.js @@ -12,8 +12,12 @@ test('affirmative decode cases', t => { for (let i = 0; i < syrup.length; i += 1) { bytes[i] = syrup.charCodeAt(i); } - const actual = decodeSyrup(bytes); - t.deepEqual(actual, value, `for ${JSON.stringify(syrup)}`); + const desc = `for ${String(syrup)}`; + let actual; + t.notThrows(() => { + actual = decodeSyrup(bytes); + }, desc); + t.deepEqual(actual, value, desc); } }); @@ -23,8 +27,7 @@ test('must not be empty', t => { decodeSyrup(new Uint8Array(0), { name: 'known.sup' }); }, { - message: - 'Unexpected end of Syrup, expected any value at index 0 of known.sup', + message: 'Unexpected end of Syrup at index 0 of known.sup', }, ); }); @@ -36,7 +39,7 @@ test('dictionary keys must be unique', t => { }, { message: - 'Syrup dictionary keys must be unique, got repeated "a" at index 10 of ', + 'Syrup dictionary keys must be unique, got repeated "a" at index 7 of ', }, ); }); @@ -48,7 +51,7 @@ test('dictionary keys must be in bytewise order', t => { }, { message: - 'Syrup dictionary keys must be in bytewise sorted order, got "a" immediately after "b" at index 10 of ', + 'Syrup dictionary keys must be in bytewise sorted order, got "a" immediately after "b" at index 7 of ', }, ); }); @@ -56,23 +59,23 @@ test('dictionary keys must be in bytewise order', t => { test('must reject out-of-order prefix key', t => { t.throws( () => { - decodeSyrup(textEncoder.encode('{1"i10+0"')); + decodeSyrup(textEncoder.encode('{1"i10+0"1-}')); }, { message: - 'Syrup dictionary keys must be in bytewise sorted order, got "" immediately after "i" at index 9 of ', + 'Syrup dictionary keys must be in bytewise sorted order, got "" immediately after "i" at index 7 of ', }, ); }); -test('dictionary keys must be strings', t => { +test('dictionary keys must be strings or selectors', t => { t.throws( () => { decodeSyrup(textEncoder.encode('{1+')); }, { message: - 'Unexpected byte "+", Syrup dictionary keys must be strings or symbols at index 2 of ', + 'Unexpected type "integer", Syrup dictionary keys must be strings or selectors at index 1 of ', }, ); }); diff --git a/packages/syrup/test/encode.test.js b/packages/syrup/test/encode.test.js index 79d550cdb5..5d28235963 100644 --- a/packages/syrup/test/encode.test.js +++ b/packages/syrup/test/encode.test.js @@ -13,7 +13,7 @@ test('affirmative encode cases', t => { for (const cc of Array.from(actual)) { string += String.fromCharCode(cc); } - t.deepEqual(syrup, string, `for ${JSON.stringify(syrup)} ${value}`); + t.deepEqual(syrup, string, `for ${JSON.stringify(syrup)} ${String(value)}`); } }); diff --git a/packages/syrup/test/fuzz.test.js b/packages/syrup/test/fuzz.test.js index cc0166aab6..cef0337e9a 100644 --- a/packages/syrup/test/fuzz.test.js +++ b/packages/syrup/test/fuzz.test.js @@ -26,13 +26,13 @@ function fuzzyString(budget, random) { } else if (partition < 0.5) { // string mostly printable return Array(length) - .fill() + .fill(undefined) .map(() => String.fromCharCode(random() * 128)) .join(''); } else { // lower-case strings return Array(length) - .fill() + .fill(undefined) .map(() => String.fromCharCode('a'.charCodeAt(0) + random() * 26)) .join(''); } @@ -49,7 +49,7 @@ function largeFuzzySyrupable(budget, random) { // bigint return BigInt( Array(length) - .fill() + .fill(undefined) .map(() => `${Math.floor(random() * 10)}`) .join(''), ); @@ -60,7 +60,7 @@ function largeFuzzySyrupable(budget, random) { // array return ( new Array(length) - .fill() + .fill(undefined) // Recursion is a thing, yo. // eslint-disable-next-line no-use-before-define .map(() => fuzzySyrupable(budget / length, random)) @@ -68,7 +68,7 @@ function largeFuzzySyrupable(budget, random) { } else { // object return Object.fromEntries( - new Array(length).fill().map(() => [ + new Array(length).fill(undefined).map(() => [ fuzzyString(20, random), // Recursion is a thing, yo. // eslint-disable-next-line no-use-before-define @@ -102,18 +102,20 @@ const defaultSeed = [0xb0b5c0ff, 0xeefacade, 0xb0b5c0ff, 0xeefacade]; const prng = new XorShift(defaultSeed); const random = () => prng.random(); -for (let i = 0; i < 1000; i += 1) { - (index => { - const object1 = fuzzySyrupable(random() * 100, random); - const syrup2 = encodeSyrup(object1); - const desc = JSON.stringify(new TextDecoder().decode(syrup2)); - test(`fuzz ${index}`, t => { - // t.log(random()); - // t.log(object1); - const object3 = decodeSyrup(syrup2); - const syrup4 = encodeSyrup(object3); +test('fuzz', t => { + for (let i = 0; i < 1000; i += 1) { + (index => { + const object1 = fuzzySyrupable(random() * 100, random); + const syrup2 = encodeSyrup(object1); + const desc = JSON.stringify(new TextDecoder().decode(syrup2)); + let object3; + let syrup4; + t.notThrows(() => { + object3 = decodeSyrup(syrup2); + syrup4 = encodeSyrup(object3); + }, `fuzz ${index} for ${desc}`); t.deepEqual(object1, object3, desc); t.deepEqual(syrup2, syrup4, desc); - }); - })(i); -} + })(i); + } +}); diff --git a/packages/syrup/test/ocapn.test.js b/packages/syrup/test/ocapn.test.js new file mode 100644 index 0000000000..c366174faf --- /dev/null +++ b/packages/syrup/test/ocapn.test.js @@ -0,0 +1,87 @@ +// @ts-check + +import test from 'ava'; +import { makeSyrupReader } from '../src/decode.js'; +import { makeSyrupWriter } from '../src/encode.js'; +import { OCapNComponentUnionCodec } from '../src/ocapn/components.js'; +import { OCapNDescriptorUnionCodec } from '../src/ocapn/descriptors.js'; +import { OCapNMessageUnionCodec } from '../src/ocapn/operations.js'; +import { + componentsTable, + descriptorsTable, + operationsTable, +} from './_ocapn.js'; +import { OCapNPassableUnionCodec } from '../src/ocapn/passable.js'; + +const textEncoder = new TextEncoder(); +const sym = s => `${s.length}'${s}`; + +const testBidirectionally = (t, codec, syrup, value, testName) => { + const syrupBytes = textEncoder.encode(syrup); + const syrupReader = makeSyrupReader(syrupBytes, { name: testName }); + let result; + t.notThrows(() => { + result = codec.read(syrupReader); + }, testName); + t.deepEqual(result, value, testName); + const syrupWriter = makeSyrupWriter(); + t.notThrows(() => { + codec.write(value, syrupWriter); + }, testName); + const bytes2 = syrupWriter.getBytes(); + const syrup2 = new TextDecoder().decode(bytes2); + t.deepEqual(syrup2, syrup, testName); +}; + +test('affirmative component cases', t => { + const codec = OCapNComponentUnionCodec; + for (const { syrup, value } of componentsTable) { + testBidirectionally(t, codec, syrup, value, `for ${JSON.stringify(syrup)}`); + } +}); + +test('affirmative descriptor cases', t => { + const codec = OCapNDescriptorUnionCodec; + for (const { syrup, value } of descriptorsTable) { + testBidirectionally(t, codec, syrup, value, `for ${JSON.stringify(syrup)}`); + } +}); + +test('affirmative operation cases', t => { + const codec = OCapNMessageUnionCodec; + for (const { syrup, value } of operationsTable) { + testBidirectionally(t, codec, syrup, value, `for ${JSON.stringify(syrup)}`); + } +}); + +test('error on unknown record type in passable', t => { + const codec = OCapNPassableUnionCodec; + const syrup = `<${sym('unknown-record-type')}>`; + const syrupBytes = textEncoder.encode(syrup); + const syrupReader = makeSyrupReader(syrupBytes, { + name: 'unknown record type', + }); + t.throws( + () => { + codec.read(syrupReader); + }, + { message: 'Unexpected record type: "unknown-record-type"' }, + ); +}); + +test('descriptor fails with negative integer', t => { + const codec = OCapNDescriptorUnionCodec; + const syrup = `<${sym('desc:import-object')}1-}>`; + const syrupBytes = textEncoder.encode(syrup); + const syrupReader = makeSyrupReader(syrupBytes, { + name: 'import-object with negative integer', + }); + t.throws( + () => { + codec.read(syrupReader); + }, + { + message: 'PositiveIntegerCodec: value must be positive', + }, + ); +}); diff --git a/packages/syrup/test/reader.test.js b/packages/syrup/test/reader.test.js new file mode 100644 index 0000000000..82289bb6ed Binary files /dev/null and b/packages/syrup/test/reader.test.js differ