Skip to content

Commit

Permalink
Add supportObjectNumberKeys decoding option, and split options for de…
Browse files Browse the repository at this point in the history
…coding raw string keys/values
  • Loading branch information
jasonpaulos committed Mar 5, 2024
1 parent a1addc5 commit 7342b02
Show file tree
Hide file tree
Showing 3 changed files with 158 additions and 24 deletions.
65 changes: 53 additions & 12 deletions src/Decoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,25 @@ export type DecoderOptions<ContextType = undefined> = Readonly<
*
* This is useful if the strings may contain invalid UTF-8 sequences.
*
* Note that this option only applies to string values, not map keys. Additionally, when
* enabled, raw string length is limited by the maxBinLength option.
* When enabled, raw string length is limited by the maxBinLength option.
*
* Note that this option only applies to string values, not map keys. See `rawBinaryStringKeys`
* for map keys.
*/
rawBinaryStringValues: boolean;

/**
* By default, map keys will be decoded as UTF-8 strings. However, if this option is true, map
* keys will be returned as Uint8Arrays without additional decoding.
*
* Requires `useMap` to be true, since plain objects do not support binary keys.
*
* When enabled, raw string length is limited by the maxBinLength option.
*
* Note that this option only applies to map keys, not string values. See `rawBinaryStringValues`
* for string values.
*/
useRawBinaryStrings: boolean;
rawBinaryStringKeys: boolean;

/**
* If true, the decoder will use the Map object to store map values. If false, it will use plain
Expand All @@ -48,6 +63,20 @@ export type DecoderOptions<ContextType = undefined> = Readonly<
*/
useMap: boolean;

/**
* If true, the decoder will support decoding numbers as map keys on plain objects. Defaults to
* false.
*
* Note that any numbers used as object keys will be converted to strings, so there is a risk of
* key collision as well as the inability to re-encode the object to the same representation.
*
* This option is ignored if `useMap` is true.
*
* This is useful for backwards compatibility before `useMap` was introduced. Consider instead
* using `useMap` for new code.
*/
supportObjectNumberKeys: boolean;

/**
* Maximum string length.
*
Expand Down Expand Up @@ -94,12 +123,12 @@ const STATE_MAP_VALUE = "map_value";

type MapKeyType = string | number | bigint | Uint8Array;

function isValidMapKeyType(key: unknown, useMap: boolean): key is MapKeyType {
function isValidMapKeyType(key: unknown, useMap: boolean, supportObjectNumberKeys: boolean): key is MapKeyType {
if (useMap) {
return typeof key === "string" || typeof key === "number" || typeof key === "bigint" || key instanceof Uint8Array;
}
// Plain objects support a more limited set of key types
return typeof key === "string";
return typeof key === "string" || (supportObjectNumberKeys && typeof key === "number");
}

type StackMapState = {
Expand Down Expand Up @@ -229,8 +258,10 @@ export class Decoder<ContextType = undefined> {
private readonly extensionCodec: ExtensionCodecType<ContextType>;
private readonly context: ContextType;
private readonly intMode: IntMode;
private readonly useRawBinaryStrings: boolean;
private readonly rawBinaryStringValues: boolean;
private readonly rawBinaryStringKeys: boolean;
private readonly useMap: boolean;
private readonly supportObjectNumberKeys: boolean;
private readonly maxStrLength: number;
private readonly maxBinLength: number;
private readonly maxArrayLength: number;
Expand All @@ -251,15 +282,21 @@ export class Decoder<ContextType = undefined> {
this.context = (options as { context: ContextType } | undefined)?.context as ContextType; // needs a type assertion because EncoderOptions has no context property when ContextType is undefined

this.intMode = options?.intMode ?? (options?.useBigInt64 ? IntMode.AS_ENCODED : IntMode.UNSAFE_NUMBER);
this.useRawBinaryStrings = options?.useRawBinaryStrings ?? false;
this.rawBinaryStringValues = options?.rawBinaryStringValues ?? false;
this.rawBinaryStringKeys = options?.rawBinaryStringKeys ?? false;
this.useMap = options?.useMap ?? false;
this.supportObjectNumberKeys = options?.supportObjectNumberKeys ?? false;
this.maxStrLength = options?.maxStrLength ?? UINT32_MAX;
this.maxBinLength = options?.maxBinLength ?? UINT32_MAX;
this.maxArrayLength = options?.maxArrayLength ?? UINT32_MAX;
this.maxMapLength = options?.maxMapLength ?? UINT32_MAX;
this.maxExtLength = options?.maxExtLength ?? UINT32_MAX;
this.keyDecoder = options?.keyDecoder !== undefined ? options.keyDecoder : sharedCachedKeyDecoder;

if (this.rawBinaryStringKeys && !this.useMap) {
throw new Error("rawBinaryStringKeys is only supported when useMap is true");
}

this.stack = new StackPool(this.useMap);
}

Expand Down Expand Up @@ -591,8 +628,12 @@ export class Decoder<ContextType = undefined> {
continue DECODE;
}
} else if (state.type === STATE_MAP_KEY) {
if (!isValidMapKeyType(object, this.useMap)) {
const acceptableTypes = this.useMap ? "string, number, bigint, or Uint8Array" : "string";
if (!isValidMapKeyType(object, this.useMap, this.supportObjectNumberKeys)) {
const acceptableTypes = this.useMap
? "string, number, bigint, or Uint8Array"
: this.supportObjectNumberKeys
? "string or number"
: "string";
throw new DecodeError(`The type of key must be ${acceptableTypes} but got ${typeof object}`);
}
if (!this.useMap && object === "__proto__") {
Expand Down Expand Up @@ -675,10 +716,10 @@ export class Decoder<ContextType = undefined> {
}

private decodeString(byteLength: number, headerOffset: number): string | Uint8Array {
if (!this.useRawBinaryStrings || (!this.useMap && this.stateIsMapKey())) {
return this.decodeUtf8String(byteLength, headerOffset);
if (this.stateIsMapKey() ? this.rawBinaryStringKeys : this.rawBinaryStringValues) {
return this.decodeBinary(byteLength, headerOffset);
}
return this.decodeBinary(byteLength, headerOffset);
return this.decodeUtf8String(byteLength, headerOffset);
}

private decodeUtf8String(byteLength: number, headerOffset: number): string {
Expand Down
99 changes: 88 additions & 11 deletions test/decode-raw-strings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ import assert from "assert";
import { encode, decode } from "../src";
import type { DecoderOptions } from "../src";

describe("decode with useRawBinaryStrings specified", () => {
const options = { useRawBinaryStrings: true } satisfies DecoderOptions;
describe("decode with rawBinaryStringValues specified", () => {
const options = { rawBinaryStringValues: true } satisfies DecoderOptions;

it("decodes string as binary", () => {
it("decodes string values as binary", () => {
const actual = decode(encode("foo"), options);
const expected = Uint8Array.from([0x66, 0x6f, 0x6f]);
assert.deepStrictEqual(actual, expected);
});

it("decodes invalid UTF-8 string as binary", () => {
it("decodes invalid UTF-8 string values as binary", () => {
const invalidUtf8String = Uint8Array.from([
61, 180, 118, 220, 39, 166, 43, 68, 219, 116, 105, 84, 121, 46, 122, 136, 233, 221, 15, 174, 247, 19, 50, 176,
184, 221, 66, 188, 171, 36, 135, 121,
Expand All @@ -25,18 +25,12 @@ describe("decode with useRawBinaryStrings specified", () => {
assert.deepStrictEqual(actual, invalidUtf8String);
});

it("decodes object keys as strings", () => {
it("decodes map string keys as strings", () => {
const actual = decode(encode({ key: "foo" }), options);
const expected = { key: Uint8Array.from([0x66, 0x6f, 0x6f]) };
assert.deepStrictEqual(actual, expected);
});

it("decodes map keys as binary when useMap is enabled", () => {
const actual = decode(encode({ key: "foo" }), { ...options, useMap: true });
const expected = new Map([[Uint8Array.from([0x6b, 0x65, 0x79]), Uint8Array.from([0x66, 0x6f, 0x6f])]]);
assert.deepStrictEqual(actual, expected);
});

it("ignores maxStrLength", () => {
const lengthLimitedOptions = { ...options, maxStrLength: 1 } satisfies DecoderOptions;

Expand All @@ -53,3 +47,86 @@ describe("decode with useRawBinaryStrings specified", () => {
}, /max length exceeded/i);
});
});

describe("decode with rawBinaryStringKeys specified", () => {
const options = { rawBinaryStringKeys: true, useMap: true } satisfies DecoderOptions;

it("errors if useMap is not enabled", () => {
assert.throws(() => {
decode(encode({ key: "foo" }), { rawBinaryStringKeys: true });
}, new Error("rawBinaryStringKeys is only supported when useMap is true"));
});

it("decodes map string keys as binary", () => {
const actual = decode(encode({ key: "foo" }), options);
const expected = new Map([[Uint8Array.from([0x6b, 0x65, 0x79]), "foo"]]);
assert.deepStrictEqual(actual, expected);
});

it("decodes invalid UTF-8 string keys as binary", () => {
const invalidUtf8String = Uint8Array.from([
61, 180, 118, 220, 39, 166, 43, 68, 219, 116, 105, 84, 121, 46, 122, 136, 233, 221, 15, 174, 247, 19, 50, 176,
184, 221, 66, 188, 171, 36, 135, 121,
]);
const encodedMap = Uint8Array.from([
129, 217, 32, 61, 180, 118, 220, 39, 166, 43, 68, 219, 116, 105, 84, 121, 46, 122, 136, 233, 221, 15, 174, 247,
19, 50, 176, 184, 221, 66, 188, 171, 36, 135, 121, 163, 97, 98, 99,
]);
const actual = decode(encodedMap, options);
const expected = new Map([[invalidUtf8String, "abc"]]);
assert.deepStrictEqual(actual, expected);
});

it("decodes string values as strings", () => {
const actual = decode(encode("foo"), options);
const expected = "foo";
assert.deepStrictEqual(actual, expected);
});

it("ignores maxStrLength", () => {
const lengthLimitedOptions = { ...options, maxStrLength: 1 } satisfies DecoderOptions;

const actual = decode(encode({ foo: 1 }), lengthLimitedOptions);
const expected = new Map([[Uint8Array.from([0x66, 0x6f, 0x6f]), 1]]);
assert.deepStrictEqual(actual, expected);
});

it("respects maxBinLength", () => {
const lengthLimitedOptions = { ...options, maxBinLength: 1 } satisfies DecoderOptions;

assert.throws(() => {
decode(encode({ foo: 1 }), lengthLimitedOptions);
}, /max length exceeded/i);
});
});

describe("decode with rawBinaryStringKeys and rawBinaryStringValues", () => {
const options = { rawBinaryStringValues: true, rawBinaryStringKeys: true, useMap: true } satisfies DecoderOptions;

it("errors if useMap is not enabled", () => {
assert.throws(() => {
decode(encode({ key: "foo" }), { rawBinaryStringKeys: true, rawBinaryStringValues: true });
}, new Error("rawBinaryStringKeys is only supported when useMap is true"));
});

it("decodes map string keys and values as binary", () => {
const actual = decode(encode({ key: "foo" }), options);
const expected = new Map([[Uint8Array.from([0x6b, 0x65, 0x79]), Uint8Array.from([0x66, 0x6f, 0x6f])]]);
assert.deepStrictEqual(actual, expected);
});

it("decodes invalid UTF-8 string keys and values as binary", () => {
const invalidUtf8String = Uint8Array.from([
61, 180, 118, 220, 39, 166, 43, 68, 219, 116, 105, 84, 121, 46, 122, 136, 233, 221, 15, 174, 247, 19, 50, 176,
184, 221, 66, 188, 171, 36, 135, 121,
]);
const encodedMap = Uint8Array.from([
129, 217, 32, 61, 180, 118, 220, 39, 166, 43, 68, 219, 116, 105, 84, 121, 46, 122, 136, 233, 221, 15, 174, 247,
19, 50, 176, 184, 221, 66, 188, 171, 36, 135, 121, 217, 32, 61, 180, 118, 220, 39, 166, 43, 68, 219, 116, 105, 84,
121, 46, 122, 136, 233, 221, 15, 174, 247, 19, 50, 176, 184, 221, 66, 188, 171, 36, 135, 121,
]);
const actual = decode(encodedMap, options);
const expected = new Map([[invalidUtf8String, invalidUtf8String]]);
assert.deepStrictEqual(actual, expected);
});
});
18 changes: 17 additions & 1 deletion test/edge-cases.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// kind of hand-written fuzzing data
// any errors should not break Encoder/Decoder instance states
import assert from "assert";
import { encode, decodeAsync, decode, Encoder, Decoder, decodeMulti, decodeMultiStream } from "../src";
import { encode, decodeAsync, decode, Encoder, Decoder, decodeMulti, decodeMultiStream, DecodeError } from "../src";
import { DataViewIndexOutOfBoundsError } from "../src/Decoder";

function testEncoder(encoder: Encoder): void {
Expand Down Expand Up @@ -55,6 +55,22 @@ describe("edge cases", () => {
});
});

context("numeric map keys", () => {
const input = encode(new Map([[0, 1]]));

it("throws error by default", () => {
assert.throws(() => decode(input), new DecodeError("The type of key must be string but got number"));
});

it("succeeds with supportObjectNumberKeys", () => {
// note: useMap is the preferred way to decode maps with non-string keys.
// supportObjectNumberKeys is only for backward compatibility
const actual = decode(input, { supportObjectNumberKeys: true });
const expected = { "0": 1 };
assert.deepStrictEqual(actual, expected);
});
});

context("try to decode a map with non-string keys (asynchronous)", () => {
it("throws errors", async () => {
const decoder = new Decoder();
Expand Down

0 comments on commit 7342b02

Please sign in to comment.