diff --git a/modules/deser-lib/src/index.ts b/modules/deser-lib/src/index.ts index 975d53b43c..2e8d812ac8 100644 --- a/modules/deser-lib/src/index.ts +++ b/modules/deser-lib/src/index.ts @@ -1,15 +1,22 @@ import { decodeFirstSync, encodeCanonical } from 'cbor'; -/** Return a string describing value as a type. */ -function getType(value): string { - if (Array.isArray(value)) { +/** + * Return a string describing value as a type. + * @param value - Any javascript value to type. + * @returns String describing value type. + */ +function getType(value: unknown): string { + if (value === null || value === undefined) { + return 'null'; + } + if (value instanceof Array) { const types = value.map(getType); if (!types.slice(1).every((value) => value === types[0])) { throw new Error('Array elements are not of the same type'); } return JSON.stringify([types[0]]); } - if (typeof value === 'object') { + if (value instanceof Object) { const properties = Object.getOwnPropertyNames(value); properties.sort(); return JSON.stringify( @@ -28,8 +35,13 @@ function getType(value): string { return JSON.stringify(typeof value); } -/** Compare two buffers for sorting. */ -function bufferCompare(a: Buffer, b: Buffer) { +/** + * Compare two buffers for sorting. + * @param a - left buffer to compare to right buffer. + * @param b - right buffer to compare to left buffer. + * @returns Negative if a < b, positive if b > a, 0 if equal. + */ +function bufferCompare(a: Buffer, b: Buffer): number { let i = 0; while (i < a.length && i < b.length && a[i] == b[i]) { i++; @@ -43,44 +55,68 @@ function bufferCompare(a: Buffer, b: Buffer) { return a[i] - b[i]; } -/** Compare two array elements for sorting. */ -function elementCompare(a: any, b: any) { - if (!('weight' in a) || !('weight' in b)) { - throw new Error('Array elements lack weight property'); +/** A sortable array element. */ +interface Sortable { + weight: number; + value?: unknown; +} + +/** + * Type check for sortable array element. + * @param value - Value to type check. + * @returns True if value is a sortable array element. + */ +function isSortable(value: unknown): value is Sortable { + return value instanceof Object && 'weight' in value; +} + +/** + * Compare two array elements for sorting. + * @param a - left element to compare to right element. + * @param b - right element to compare to left element. + * @returns Negative if a < b, positive if b > a, 0 if equal. + */ +function elementCompare(a: unknown, b: unknown): number { + if (!isSortable(a) || !isSortable(b)) { + throw new Error('Array elements must be sortable'); } if (a.weight === b.weight) { - if (!('value' in a) || !('value' in b)) { - throw new Error('Array elements lack value property'); - } - const aVal = transform(a.value); - const bVal = transform(b.value); - if (!Buffer.isBuffer(aVal) && typeof aVal !== 'string' && typeof aVal !== 'number') { - throw new Error('Array element value cannot be compared'); - } - if (!Buffer.isBuffer(bVal) && typeof bVal !== 'string' && typeof bVal !== 'number') { - throw new Error('Array element value cannot be compared'); - } - if (typeof aVal === 'number' && typeof bVal === 'number') { - return aVal - bVal; - } - let aBuf, bBuf; - if (typeof aVal === 'number') { - aBuf = Buffer.from([aVal]); - } else { - aBuf = Buffer.from(aVal); - } - if (typeof bVal === 'number') { - bBuf = Buffer.from([bVal]); - } else { - bBuf = Buffer.from(bVal); + if ('value' in a && 'value' in b) { + const aVal = transform(a.value); + const bVal = transform(b.value); + if ( + (!Buffer.isBuffer(aVal) && typeof aVal !== 'string' && typeof aVal !== 'number') || + (!Buffer.isBuffer(bVal) && typeof bVal !== 'string' && typeof bVal !== 'number') + ) { + throw new Error('Array element value cannot be compared'); + } + let aBuf, bBuf; + if (typeof aVal === 'number') { + aBuf = Buffer.from([aVal]); + } else { + aBuf = Buffer.from(aVal); + } + if (typeof bVal === 'number') { + bBuf = Buffer.from([bVal]); + } else { + bBuf = Buffer.from(bVal); + } + return bufferCompare(aBuf, bBuf); } - return bufferCompare(aBuf, bBuf); + throw new Error('Array elements must be sortable'); } return a.weight - b.weight; } -/** Transform value into its canonical, serializable form. */ -export function transform(value: any) { +/** + * Transform value into its canonical, serializable form. + * @param value - Value to transform. + * @returns Canonical, serializable form of value. + */ +export function transform(value: T): T | Buffer { + if (value === null || value === undefined) { + return value; + } if (typeof value === 'string') { // Transform hex strings to buffers. if (value.startsWith('0x')) { @@ -89,36 +125,40 @@ export function transform(value: any) { } return Buffer.from(value.slice(2), 'hex'); } - } else if (Array.isArray(value)) { - // Enforce array elemenst are same type. + } else if (value instanceof Array) { + // Enforce array elements are same type. getType(value); - value = value.slice(0); - value.sort(elementCompare).map(transform); - return value.map(transform); - } else if (typeof value === 'object') { + value = [...value] as unknown as T; + (value as unknown as Array).sort(elementCompare); + return (value as unknown as Array).map(transform) as unknown as T; + } else if (value instanceof Object) { const properties = Object.getOwnPropertyNames(value); properties.sort(); return properties.reduce((acc, name) => { acc[name] = transform(value[name]); return acc; - }, {}); + }, {}) as unknown as T; } return value; } -/** Untransform value into its human readable form. */ -export function untransform(value: any) { +/** + * Untransform value into its human readable form. + * @param value - Value to untransform. + * @returns Untransformed, human readable form of value. + */ +export function untransform(value: T): T | string { if (Buffer.isBuffer(value)) { return '0x' + value.toString('hex'); } - if (Array.isArray(value) && value.length > 1) { + if (value instanceof Array && value.length > 1) { for (let i = 1; i < value.length; i++) { if (value[i - 1].weight > value[i].weight) { throw new Error('Array elements are not in canonical order'); } } - return value.map(untransform); - } else if (typeof value === 'object') { + return value.map(untransform) as unknown as T; + } else if (value instanceof Object) { const properties = Object.getOwnPropertyNames(value); for (let i = 1; i < properties.length; i++) { if (properties[i - 1].localeCompare(properties[i]) > 0) { @@ -128,17 +168,25 @@ export function untransform(value: any) { return properties.reduce((acc, name) => { acc[name] = untransform(value[name]); return acc; - }, {}); + }, {}) as unknown as T; } return value; } -/** Serialize a value. */ -export function serialize(value: any): Buffer { +/** + * Serialize a value. + * @param value - Value to serialize. + * @returns Buffer representing serialized value. + */ +export function serialize(value: T): Buffer { return encodeCanonical(transform(value)); } -/** Deserialize a value. */ -export function deserialize(value: Buffer) { +/** + * Deserialize a value. + * @param value - Buffer to deserialize. + * @returns Deserialized value. + */ +export function deserialize(value: Buffer): unknown { return untransform(decodeFirstSync(value)); } diff --git a/modules/deser-lib/test/unit/deser-lib.ts b/modules/deser-lib/test/unit/deser-lib.ts index 611f0e04f0..e6839d3c0d 100644 --- a/modules/deser-lib/test/unit/deser-lib.ts +++ b/modules/deser-lib/test/unit/deser-lib.ts @@ -4,7 +4,7 @@ import * as fixtures from '../fixtures.json'; describe('deser-lib', function () { describe('transform', function () { it('orders object properties canonically', function () { - const res = transform({ b: 'second', a: 'first' }); + const res = transform({ b: 'second', a: 'first' }) as any; const properties = Object.getOwnPropertyNames(res); properties[0].should.equal('a'); properties[1].should.equal('b'); @@ -14,48 +14,94 @@ describe('deser-lib', function () { describe('canonical ordering', function () { it('orders by weight', function () { - const res = transform([{ weight: 2 }, { weight: 1 }]); + const res = transform([ + { weight: 2, value: null }, + { weight: 1, value: null }, + ]) as any; + res[0].weight.should.equal(1); + res[1].weight.should.equal(2); + }); + + it('groups equal elements', function () { + const res = transform([ + { + weight: 2, + value: 'b', + }, + { + weight: 1, + value: 'a', + }, + { + weight: 3, + value: 'c', + }, + { + weight: 2, + value: 'b', + }, + ]) as any; res[0].weight.should.equal(1); res[1].weight.should.equal(2); + res[2].weight.should.equal(2); + res[3].weight.should.equal(3); }); it('orders number values', function () { const res = transform([ { weight: 1, value: 2 }, { weight: 1, value: 1 }, - ]); + ]) as any; res[0].value.should.equal(1); res[1].value.should.equal(2); }); it('orders string values', function () { const res = transform([ - { weight: 1, value: 'b' }, - { weight: 1, value: 'a' }, - ]); - res[0].value.should.equal('a'); - res[1].value.should.equal('b'); + { weight: 1, value: 'ab' }, + { weight: 1, value: 'aa' }, + ]) as any; + res[0].value.should.equal('aa'); + res[1].value.should.equal('ab'); }); it('orders byte values', function () { const res = transform([ { weight: 1, value: '0x0b' }, { weight: 1, value: '0x0a' }, - ]); + ]) as any; res[0].value.equals(Buffer.from([0x0a])).should.equal(true); res[1].value.equals(Buffer.from([0x0b])).should.equal(true); }); + it('orders string values of different lengths', function () { + const res = transform([ + { weight: 1, value: 'ab' }, + { weight: 1, value: 'a' }, + ]) as any; + res[0].value.should.equal('a'); + res[1].value.should.equal('ab'); + }); + it('throws for elements without weight', function () { (() => transform([{}, {}])).should.throw(); }); + it('throws for elements without value', function () { + (() => transform([{ weight: 1 }, { weight: 1 }])).should.throw(); + }); + it('throws for values that cannot be compared', function () { (() => transform([ { weight: 1, value: {} }, { weight: 1, value: 1 }, ])).should.throw(); + (() => + transform([ + { weight: 1, value: undefined }, + { weight: 1, value: null }, + ])).should.throw(); }); it('throws for elements of mixed type', function () { @@ -67,21 +113,26 @@ describe('deser-lib', function () { }); }); + it('preserves null values', function () { + const res = transform({ value: null }) as any; + res.should.have.property('value').which.is.null(); + }); + it('replaces prefixed hex strings with Buffers', function () { const hex = '00010203'; - const res = transform({ value: '0x' + hex }); + const res = transform({ value: '0x' + hex }) as any; Buffer.isBuffer(res.value).should.equal(true); res.value.equals(Buffer.from(hex, 'hex')).should.equal(true); }); it('preserves non-prefixed hex strings', function () { const string = '00010203'; - const res = transform({ value: string }); + const res = transform({ value: string }) as any; res.value.should.equal(string); }); it('transforms object recursively', function () { - const res = transform({ value: { b: 'second', a: 'first' } }); + const res = transform({ value: { b: 'second', a: 'first' } }) as any; const properties = Object.getOwnPropertyNames(res.value); properties[0].should.equal('a'); properties[1].should.equal('b'); @@ -90,18 +141,22 @@ describe('deser-lib', function () { }); it('transforms array recursively', function () { - const res = transform([{ weight: 0, value: { b: 'second', a: 'first' } }]); + const res = transform([{ weight: 0, value: { b: 'second', a: 'first' } }]) as any; const properties = Object.getOwnPropertyNames(res[0].value); properties[0].should.equal('a'); properties[1].should.equal('b'); res[0].value.a.should.equal('first'); res[0].value.b.should.equal('second'); }); + + it('throws for invalid hex strings', function () { + (() => transform('0x0g')).should.throw(); + }); }); describe('untransform', function () { it('untransforms object', function () { - const res = untransform({ a: 'first', b: 'second' }); + const res = untransform({ a: 'first', b: 'second' }) as any; const properties = Object.getOwnPropertyNames(res); properties[0].should.equal('a'); properties[1].should.equal('b'); @@ -119,25 +174,25 @@ describe('deser-lib', function () { it('replaces Buffers with prefixed hex strings', function () { const hex = '00010203'; - const res = untransform({ value: Buffer.from(hex, 'hex') }); + const res = untransform({ value: Buffer.from(hex, 'hex') }) as any; res.value.should.equal('0x' + hex); }); it('preserves non-prefixed hex strings', function () { const string = '00010203'; - const res = untransform({ value: string }); + const res = untransform({ value: string }) as any; res.value.should.equal(string); }); it('untransforms object recursively', function () { const hex = '00010203'; - const res = untransform({ value: { value: Buffer.from(hex, 'hex') } }); + const res = untransform({ value: { value: Buffer.from(hex, 'hex') } }) as any; res.value.value.should.equal('0x' + hex); }); it('untransforms array recursively', function () { const hex = '00010203'; - const res = untransform([{ value: Buffer.from(hex, 'hex'), weight: 0 }]); + const res = untransform([{ value: Buffer.from(hex, 'hex'), weight: 0 }]) as any; res[0].value.should.equal('0x' + hex); }); });