diff --git a/src/decode.js b/src/decode.js index 2ca712a..0a5b567 100644 --- a/src/decode.js +++ b/src/decode.js @@ -23,6 +23,11 @@ import { fromLength } from './utils/length.js'; /** @typedef {Map} Cache */ +/** + * @typedef {object} Options + * @prop {'all' | 'some' | 'none'} recursion With `all`, the default, everything recursive will be tracked. With `some`, all primitives get ignored or fail if found as recursive. With `none`, no recursion is ever tracked and an error is thrown when any recursive data is found. + */ + /** * @typedef {Object} Position * @property {number} i @@ -30,29 +35,40 @@ import { fromLength } from './utils/length.js'; const decoder = new TextDecoder; +/** + * @param {number} i + */ +const throwOnRecursiveValue = i => { + throw new SyntaxError('Unexpected recursive value @ ' + i); +}; + /** * @param {Uint8Array} ui8a * @param {Position} at - * @param {Cache} map + * @param {Cache|Loophole} m + * @param {boolean} p * @returns */ -const decode = (ui8a, at, map) => { +const decode = (ui8a, at, m, p) => { const i = at.i++; const type = ui8a[i]; switch (type) { - case RECURSIVE: return map.get(fromLength(ui8a, at)); + case RECURSIVE: { + const i = fromLength(ui8a, at); + return m.get(i) ?? throwOnRecursiveValue(i); + } case ARRAY: { const value = []; - map.set(i, value); + m.set(i, value); for (let i = 0, length = fromLength(ui8a, at); i < length; i++) - value.push(decode(ui8a, at, map)); + value.push(decode(ui8a, at, m, p)); return value; } case OBJECT: { const value = {}; - map.set(i, value); + m.set(i, value); for (let i = 0, length = fromLength(ui8a, at); i < length; i += 2) - value[decode(ui8a, at, map)] = decode(ui8a, at, map); + value[decode(ui8a, at, m, p)] = decode(ui8a, at, m, p); return value; } case STRING: { @@ -61,7 +77,7 @@ const decode = (ui8a, at, map) => { const start = at.i; const end = (at.i += length); const value = decoder.decode(ui8a.slice(start, end)); - map.set(i, value); + if (p) m.set(i, value); return value; } return ''; @@ -70,7 +86,7 @@ const decode = (ui8a, at, map) => { case BIGINT: { const string = fromASCII(ui8a, at); const value = type === BIGINT ? BigInt(string) : parseFloat(string); - map.set(i, value); + if (p) m.set(i, value); return value; } case BOOLEAN: return ui8a[at.i++] === 1; @@ -80,43 +96,43 @@ const decode = (ui8a, at, map) => { const start = at.i; const end = (at.i += length); const { buffer } = ui8a.slice(start, end); - map.set(i, buffer); + m.set(i, buffer); return buffer; } case DATE: { const value = new Date(fromASCII(ui8a, at)); - map.set(i, value); + m.set(i, value); return value; } case MAP: { const value = new Map; - map.set(i, value); + m.set(i, value); for (let i = 0, length = fromLength(ui8a, at); i < length; i += 2) - value.set(decode(ui8a, at, map), decode(ui8a, at, map)); + value.set(decode(ui8a, at, m, p), decode(ui8a, at, m, p)); return value; } case SET: { const value = new Set; - map.set(i, value); + m.set(i, value); for (let i = 0, length = fromLength(ui8a, at); i < length; i++) - value.add(decode(ui8a, at, map)); + value.add(decode(ui8a, at, m, p)); return value; } case ERROR: { - const Class = globalThis[decode(ui8a, at, map)]; - const value = new Class(decode(ui8a, at, map)); - map.set(i, value); + const Class = globalThis[decode(ui8a, at, m, p)]; + const value = new Class(decode(ui8a, at, m, p)); + m.set(i, value); return value; } case REGEXP: { - const value = new RegExp(decode(ui8a, at, map), decode(ui8a, at, map)); - map.set(i, value); + const value = new RegExp(decode(ui8a, at, m, p), decode(ui8a, at, m, p)); + m.set(i, value); return value; } case TYPED: { - const Class = globalThis[decode(ui8a, at, map)]; - const value = new Class(decode(ui8a, at, map)); - map.set(i, value); + const Class = globalThis[decode(ui8a, at, m, p)]; + const value = new Class(decode(ui8a, at, m, p)); + m.set(i, value); return value; } default: { @@ -125,11 +141,30 @@ const decode = (ui8a, at, map) => { } }; +class Loophole { + /** + * @param {number} i + */ + get(i) { + throwOnRecursiveValue(i); + } + + /** + * @param {number} i + * @param {any} value + */ + set(i, value) { + // do nothing + } +} + /** * @param {Uint8Array} ui8a + * @param {Options?} options * @returns */ -export default ui8a => { +export default (ui8a, options) => { const at = /** @type {Position} */({ i: 0 }); - return decode(ui8a, at, new Map); + const r = options?.recursion; + return decode(ui8a, at, r === 'none' ? new Loophole : new Map, r !== 'some'); }; diff --git a/src/encode.js b/src/encode.js index 2e92c05..31bbadf 100644 --- a/src/encode.js +++ b/src/encode.js @@ -23,12 +23,18 @@ import { toLength } from './utils/length.js'; /** @typedef {Map} Cache */ +/** + * @typedef {object} Options + * @prop {'all' | 'some' | 'none'} recursion With `all`, the default, everything but `null`, `boolean` and empty `string` will be tracked recursively. With `some`, all primitives get ignored. With `none`, no recursion is ever tracked, leading to *maximum callstack* if present in the encoded data. + */ + const { isArray } = Array; const { isFinite } = Number; const { toStringTag } = Symbol; const { entries, getPrototypeOf } = Object; const TypedArray = getPrototypeOf(Uint8Array); +const encoder = new TextEncoder; /** * @param {any} value @@ -96,182 +102,183 @@ const asValid = value => { } }; -/** - * @param {number[]} ui8 - * @param {any} value - * @param {Cache} map - */ -const array = (ui8, value, map) => { - const { length } = value; - toLength(ui8, ARRAY, length); - for (let i = 0; i < length; i++) - encode(ui8, value[i], map, true); -}; - -/** - * @param {number[]} ui8 - * @param {ArrayBuffer} value - * @returns - */ -const buffer = (ui8, value) => { - asUint8Array(ui8, BUFFER, new Uint8Array(value)); -}; - -/** - * @param {number[]} ui8 - * @param {number} type - * @param {any[]} values - * @param {Cache} map - */ -const object = (ui8, type, values, map) => { - const { length } = values; - if (length) { - toLength(ui8, type, length); - for (let i = 0; i < length; i++) - encode(ui8, values[i], map); +class Is { + /** + * @param {Options} options + */ + constructor(options) { + const r = options.recursion; + /** @type {0 | 1 | 2} */ + this.r = r === 'all' ? 2 : (r === 'none' ? 0 : 1); + /** @type {number[]} */ + this.a = []; + /** @type {Cache?} */ + this.m = this.r > 0 ? new Map : null; } - else - ui8.push(type, 0); -}; - -/** - * @param {number[]} ui8 - * @param {any} value - * @param {Cache} map - */ -const recursive = (ui8, value, map) => { - const r = []; - toLength(r, RECURSIVE, ui8.length); - map.set(value, r); -}; -/** - * @param {number[]} ui8 - * @param {number} type - * @param {any} key - * @param {any} value - * @param {Cache} map - */ -const simple = (ui8, type, key, value, map) => { - ui8.push(type); - encode(ui8, key, map); - encode(ui8, value, map); -}; - -const encoder = new TextEncoder; - -/** - * @param {number[]} ui8 - * @param {any} value - * @param {Cache} map - * @param {boolean} [asNull=false] - */ -const encode = (ui8, value, map, asNull = false) => { - const known = map.get(value); - if (known) { - ui8.push(...known); - return; - } + /** + * @param {any} value + * @param {boolean} asNull + */ + encode(value, asNull) { + const known = this.r > 0 && /** @type {Cache} */(this.m).get(value); + if (known) { + this.a.push(...known); + return; + } - const [OK, type] = asSerialized(value, asNull); - if (OK) { - switch (type) { - case ARRAY: { - recursive(ui8, value, map); - array(ui8, value, map); - break; - } - case OBJECT: { - recursive(ui8, value, map); - const values = []; - for (const [k, v] of entries(value)) { - if (asValid(v)) values.push(k, v); + const [OK, type] = asSerialized(value, asNull); + if (OK) { + switch (type) { + case ARRAY: { + this.array(value); + break; } - object(ui8, OBJECT, values, map); - break; - } - case STRING: { - if (value.length) { - recursive(ui8, value, map); - asUint8Array(ui8, STRING, encoder.encode(value)); + case OBJECT: { + this.track(0, value); + const values = []; + for (const [k, v] of entries(value)) { + if (asValid(v)) values.push(k, v); + } + this.object(OBJECT, values); + break; } - else ui8.push(STRING, 0); - break; - } - case NUMBER: - case BIGINT: { - recursive(ui8, value, map); - toASCII(ui8, type, String(value)); - break; - } - case BOOLEAN: { - ui8.push(BOOLEAN, value ? 1 : 0); - break; - } - case NULL: { - ui8.push(NULL); - break; - } - case BUFFER: { - recursive(ui8, value, map); - buffer(ui8, value); - break; - } - case DATE: { - recursive(ui8, value, map); - toASCII(ui8, DATE, value.toISOString()); - break; - } - case MAP: { - recursive(ui8, value, map); - const values = []; - for (const [k, v] of value) { - if (asValid(k) && asValid(v)) values.push(k, v); + case STRING: { + if (value.length) { + this.track(1, value); + asUint8Array(this.a, STRING, encoder.encode(value)); + } + else this.a.push(STRING, 0); + break; } - object(ui8, MAP, values, map); - break; - } - case SET: { - recursive(ui8, value, map); - const values = []; - for (const v of value) { - if (asValid(v)) values.push(v); + case NUMBER: + case BIGINT: { + this.track(1, value); + toASCII(this.a, type, String(value)); + break; } - object(ui8, SET, values, map); - break; - } - case ERROR: { - const { name, message } = value; - if (name in globalThis) { - recursive(ui8, value, map); - simple(ui8, ERROR, name, message, map); + case BOOLEAN: { + this.a.push(BOOLEAN, value ? 1 : 0); + break; } - break; - } - case REGEXP: { - recursive(ui8, value, map); - simple(ui8, REGEXP, value.source, value.flags, map); - break; - } - case TYPED: { - const Class = value[toStringTag]; - if (Class in globalThis) { - recursive(ui8, value, map); - simple(ui8, TYPED, Class, value.buffer, map); + case NULL: { + this.a.push(NULL); + break; + } + case BUFFER: { + this.buffer(value); + break; + } + case DATE: { + this.track(0, value); + toASCII(this.a, DATE, value.toISOString()); + break; + } + case MAP: { + this.track(0, value); + const values = []; + for (const [k, v] of value) { + if (asValid(k) && asValid(v)) values.push(k, v); + } + this.object(MAP, values); + break; + } + case SET: { + this.track(0, value); + const values = []; + for (const v of value) { + if (asValid(v)) values.push(v); + } + this.object(SET, values); + break; + } + case ERROR: { + this.track(0, value); + this.simple(ERROR, value.name, value.message); + break; + } + case REGEXP: { + this.track(0, value); + this.simple(REGEXP, value.source, value.flags); + break; + } + case TYPED: { + this.track(0, value); + this.simple(TYPED, value[toStringTag], value.buffer); + break; } - break; } } } -}; + + /** + * @param {0 | 1 | 2} level + * @param {any} value + */ + track(level, value) { + if (this.r > level) { + const r = []; + toLength(r, RECURSIVE, this.a.length); + /** @type {Cache} */(this.m).set(value, r); + } + } + + /** + * @param {any[]} value + */ + array(value) { + this.track(0, value); + this.null = true; + const { length } = value; + toLength(this.a, ARRAY, length); + for (let i = 0; i < length; i++) + this.encode(value[i], true); + this.null = false; + } + + /** + * @param {ArrayBuffer} value + */ + buffer(value) { + this.track(0, value); + asUint8Array(this.a, BUFFER, new Uint8Array(value)); + } + + /** + * @param {number} type + * @param {any[]} values + */ + object(type, values) { + const { length } = values; + if (length) { + toLength(this.a, type, length); + for (let i = 0; i < length; i++) + this.encode(values[i], false); + } + else + this.a.push(type, 0); + } + + /** + * @param {number} type + * @param {any} key + * @param {any} value + */ + simple(type, key, value) { + this.a.push(type); + this.encode(key, false); + this.encode(value, false); + } +} /** * @template T * @param {T extends undefined ? never : T extends Function ? never : T extends symbol ? never : T} value + * @param {Options?} options * @returns */ -export default value => { - const ui8 = []; - encode(ui8, value, new Map); - return new Uint8Array(ui8); +export default (value, options = null) => { + const is = new Is({ recursion: 'all', ...options }); + is.encode(value, false); + return new Uint8Array(is.a); }; diff --git a/test/cover.js b/test/cover.js index 61a354d..b19bdd6 100644 --- a/test/cover.js +++ b/test/cover.js @@ -4,11 +4,16 @@ import { encode, decode } from '../src/index.js'; const convert = value => decode(encode(value)); const assert = (result, expected) => { - if (!Object.is(result, expected)) + if (!Object.is(result, expected)) { + console.log({ result, expected }); throw new Error(`Unexpected result`); + } }; -verify(convert(data)); +console.time('complex data'); +const converted = convert(data); +console.timeEnd('complex data'); +verify(converted); const length3 = 'a'.repeat(1 << 16); assert(convert(length3), length3); @@ -43,3 +48,35 @@ assert( assert(convert(true), true); assert(convert(false), false); + +// Options +const source = ['a', 'a']; +source.unshift(source); +let all = encode(source, { recursion: 'all' }); +let some = encode(source, { recursion: 'some' }); + +try { + encode(source, { recursion: 'none' }); + throw new Error('Unexpected encoding'); +} +catch (OK) {} + +assert(all.join(',') !== some.join(','), true); + +try { + decode(all, { recursion: 'none' }); + throw new Error('recursion should fail'); +} +catch ({ message }) { + assert(message, 'Unexpected recursive value @ 0'); +} + +try { + decode(all, { recursion: 'some' }); + throw new Error('recursion should fail'); +} +catch ({ message }) { + assert(message, 'Unexpected recursive value @ 5'); +} + +assert(decode(some, { recursion: 'some' }).join(','), [[],'a', 'a'].join(','));