diff --git a/README.md b/README.md index d9217df..8eccbca 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ NodeJS `Buffer` is also acceptable because it is a subclass of `Uint8Array`. | intMode | IntMode | `IntMode.AS_ENCODED` if `useBigInt64` is `true` or `IntMode.UNSAFE_NUMBER` otherwise | | rawBinaryStringKeys | boolean | false | | rawBinaryStringValues | boolean | false | +| useRawBinaryStringClass | boolean | false | | useMap | boolean | false | | supportObjectNumberKeys | boolean | false | | maxStrLength | number | `4_294_967_295` (UINT32_MAX) | @@ -174,7 +175,7 @@ You can use `max${Type}Length` to limit the length of each type decoded. `intMode` determines whether decoded integers should be returned as numbers or bigints in different circumstances. The possible values are [described below](#intmode). -To skip UTF-8 decoding of strings, one or both of `rawBinaryStringKeys` and `rawBinaryStringValues` can be set to `true`. If enabled, strings are decoded into `Uint8Array`. `rawBinaryStringKeys` affects only map keys, while `rawBinaryStringValues` affect all other string values. +To skip UTF-8 decoding of strings, one or both of `rawBinaryStringKeys` and `rawBinaryStringValues` can be set to `true`. If enabled, strings are decoded into `Uint8Array`, or a `RawBinaryString` which wraps a `Uint8Array` if `useRawBinaryStringClass` is true. `rawBinaryStringKeys` affects only map keys, while `rawBinaryStringValues` affect all other string values. You may want to enable `useRawBinaryStringClass` if you want to distinguish between regular strings and binary strings, or if you wish to re-encode the object, since `RawBinaryString` instances will be encoded as regular strings. If `useMap` is enabled, maps are decoded into the `Map` container instead of plain objects. `Map` objects support a wider range of key types. Plain objects only support string keys (though you can enable `supportObjectNumberKeys` to coerce number keys to strings), while `Map` objects support strings, numbers, bigints, and Uint8Arrays. @@ -549,7 +550,7 @@ The mapping of integers varies on the setting of `intMode`. - \*1 Both `null` and `undefined` are mapped to `nil` (`0xC0`) type, and are decoded into `null` - \*2 MessagePack ints are decoded as either numbers or bigints depending on the [IntMode](#intmode) used during decoding. -- \*3 If you'd like to skip UTF-8 decoding of strings, enable one of `rawBinaryStringKeys` or `rawBinaryStringValues`. In that case, strings are decoded into `Uint8Array`. +- \*3 If you'd like to skip UTF-8 decoding of strings, enable one of `rawBinaryStringKeys` or `rawBinaryStringValues`. In that case, strings are decoded into a `Uint8Array` or a `RawBinaryString`, depending on the value of `useRawBinaryStringClass`. - \*4 Any `ArrayBufferView`s including NodeJS's `Buffer` are mapped to `bin` family, and are decoded into `Uint8Array` - \*5 In handling `Object`, it is regarded as `Record` in terms of TypeScript - \*6 MessagePack timestamps may have nanoseconds, which will lost when it is decoded into JavaScript `Date`. This behavior can be overridden by registering `-1` for the extension codec. diff --git a/src/Decoder.ts b/src/Decoder.ts index f962776..38b5cd0 100644 --- a/src/Decoder.ts +++ b/src/Decoder.ts @@ -2,7 +2,7 @@ import { prettyByte } from "./utils/prettyByte"; import { ExtensionCodec, ExtensionCodecType } from "./ExtensionCodec"; import { IntMode, getInt64, getUint64, convertSafeIntegerToMode, UINT32_MAX } from "./utils/int"; import { utf8Decode } from "./utils/utf8"; -import { createDataView, ensureUint8Array } from "./utils/typedArrays"; +import { createDataView, ensureUint8Array, RawBinaryString } from "./utils/typedArrays"; import { CachedKeyDecoder, KeyDecoder } from "./CachedKeyDecoder"; import { DecodeError } from "./DecodeError"; import type { ContextOf } from "./context"; @@ -53,6 +53,17 @@ export type DecoderOptions = Readonly< */ rawBinaryStringKeys: boolean; + /** + * If true, the decoder will use the RawBinaryString class to store raw binary strings created + * during decoding from the rawBinaryStringValues and rawBinaryStringKeys options. If false, it + * will use Uint8Arrays. + * + * Defaults to false. + * + * Has no effect if rawBinaryStringValues and rawBinaryStringKeys are both false. + */ + useRawBinaryStringClass: boolean; + /** * If true, the decoder will use the Map object to store map values. If false, it will use plain * objects. Defaults to false. @@ -126,7 +137,13 @@ type MapKeyType = string | number | bigint | Uint8Array; 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; + return ( + typeof key === "string" || + typeof key === "number" || + typeof key === "bigint" || + key instanceof Uint8Array || + key instanceof RawBinaryString + ); } // Plain objects support a more limited set of key types return typeof key === "string" || (supportObjectNumberKeys && typeof key === "number"); @@ -261,6 +278,7 @@ export class Decoder { private readonly intMode: IntMode; private readonly rawBinaryStringValues: boolean; private readonly rawBinaryStringKeys: boolean; + private readonly useRawBinaryStringClass: boolean; private readonly useMap: boolean; private readonly supportObjectNumberKeys: boolean; private readonly maxStrLength: number; @@ -285,6 +303,7 @@ export class Decoder { this.intMode = options?.intMode ?? (options?.useBigInt64 ? IntMode.AS_ENCODED : IntMode.UNSAFE_NUMBER); this.rawBinaryStringValues = options?.rawBinaryStringValues ?? false; this.rawBinaryStringKeys = options?.rawBinaryStringKeys ?? false; + this.useRawBinaryStringClass = options?.useRawBinaryStringClass ?? false; this.useMap = options?.useMap ?? false; this.supportObjectNumberKeys = options?.supportObjectNumberKeys ?? false; this.maxStrLength = options?.maxStrLength ?? UINT32_MAX; @@ -716,9 +735,13 @@ export class Decoder { this.stack.pushArrayState(size); } - private decodeString(byteLength: number, headerOffset: number): string | Uint8Array { + private decodeString(byteLength: number, headerOffset: number): string | Uint8Array | RawBinaryString { if (this.stateIsMapKey() ? this.rawBinaryStringKeys : this.rawBinaryStringValues) { - return this.decodeBinary(byteLength, headerOffset); + const decoded = this.decodeBinary(byteLength, headerOffset); + if (this.useRawBinaryStringClass) { + return new RawBinaryString(decoded); + } + return decoded; } return this.decodeUtf8String(byteLength, headerOffset); } diff --git a/src/Encoder.ts b/src/Encoder.ts index 5b4399e..f781d6d 100644 --- a/src/Encoder.ts +++ b/src/Encoder.ts @@ -1,7 +1,7 @@ import { utf8Count, utf8Encode } from "./utils/utf8"; import { ExtensionCodec, ExtensionCodecType } from "./ExtensionCodec"; import { setInt64, setUint64 } from "./utils/int"; -import { ensureUint8Array, compareUint8Arrays } from "./utils/typedArrays"; +import { ensureUint8Array, compareUint8Arrays, RawBinaryString } from "./utils/typedArrays"; import type { ExtData } from "./ExtData"; import type { ContextOf } from "./context"; @@ -326,6 +326,8 @@ export class Encoder { this.encodeArray(object, depth); } else if (ArrayBuffer.isView(object)) { this.encodeBinary(object); + } else if (object instanceof RawBinaryString) { + this.encodeBinaryAsString(object); } else if (typeof object === "bigint") { // this is here instead of in doEncode so that we can try encoding with an extension first, // otherwise we would break existing extensions for bigints @@ -361,6 +363,13 @@ export class Encoder { this.writeU8a(bytes); } + private encodeBinaryAsString(binaryString: RawBinaryString) { + const object = binaryString.rawBinaryValue; + this.writeStringHeader(object.byteLength); + const bytes = ensureUint8Array(object); + this.writeU8a(bytes); + } + private encodeArray(object: Array, depth: number) { const size = object.length; if (size < 16) { @@ -397,6 +406,7 @@ export class Encoder { private sortMapKeys(keys: Array): Array { const numericKeys: Array = []; const stringKeys: Array = []; + const rawStringKeys: Array = []; const binaryKeys: Array = []; for (const key of keys) { if (typeof key === "number") { @@ -410,15 +420,20 @@ export class Encoder { stringKeys.push(key); } else if (ArrayBuffer.isView(key)) { binaryKeys.push(ensureUint8Array(key)); + } else if (key instanceof RawBinaryString) { + rawStringKeys.push(key); } else { throw new Error(`Unsupported map key type: ${Object.prototype.toString.apply(key)}`); } } numericKeys.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); // Avoid using === to compare numbers and bigints stringKeys.sort(); + rawStringKeys.sort((a, b) => + compareUint8Arrays(ensureUint8Array(a.rawBinaryValue), ensureUint8Array(b.rawBinaryValue)), + ); binaryKeys.sort(compareUint8Arrays); - // At the moment this arbitrarily orders the keys as numeric, string, binary - return ([] as Array).concat(numericKeys, stringKeys, binaryKeys); + // At the moment this arbitrarily orders the keys as numeric, string, raw string, binary + return ([] as Array).concat(numericKeys, stringKeys, rawStringKeys, binaryKeys); } private encodeMapObject(object: Record, depth: number) { @@ -464,6 +479,8 @@ export class Encoder { this.encodeBigInt(key); } else if (ArrayBuffer.isView(key)) { this.encodeBinary(key); + } else if (key instanceof RawBinaryString) { + this.encodeBinaryAsString(key); } else { throw new Error(`Unsupported map key type: ${Object.prototype.toString.apply(key)}`); } diff --git a/src/index.ts b/src/index.ts index f0be590..998c9e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,8 @@ import { Encoder } from "./Encoder"; export { Encoder }; import type { EncoderOptions } from "./Encoder"; export type { EncoderOptions }; +import { RawBinaryString } from "./utils/typedArrays"; +export { RawBinaryString }; // Utilities for Extension Types: diff --git a/src/utils/typedArrays.ts b/src/utils/typedArrays.ts index 19a3e47..d0f9975 100644 --- a/src/utils/typedArrays.ts +++ b/src/utils/typedArrays.ts @@ -30,3 +30,22 @@ export function compareUint8Arrays(a: Uint8Array, b: Uint8Array): number { } return a.length - b.length; } + +/** + * Represents a binary value that should be encoded as if it were a string. + * + * Effectively, this is a string that has already been UTF-8 encoded to a binary string. This is + * useful if you need to encode a value as a string, but that value contains invalid UTF-8 sequences; + * ideally this situation should be avoided and the value should be encoded as binary, not string, + * but this may be necessary for compatibility with non-ideal systems. + */ +export class RawBinaryString { + /** + * Create a new RawBinaryString from an ArrayBufferView. + */ + public constructor(public readonly rawBinaryValue: ArrayBufferView) { + if (!ArrayBuffer.isView(rawBinaryValue)) { + throw new TypeError("RawBinaryString: rawBinaryValue must be an ArrayBufferView"); + } + } +} diff --git a/test/decode-raw-strings.test.ts b/test/decode-raw-strings.test.ts deleted file mode 100644 index 9627a08..0000000 --- a/test/decode-raw-strings.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import assert from "assert"; -import { encode, decode } from "../src"; -import type { DecoderOptions } from "../src"; - -describe("decode with rawBinaryStringValues specified", () => { - const options = { rawBinaryStringValues: true } satisfies DecoderOptions; - - 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 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 encoded = Uint8Array.from([ - 196, 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(encoded, options); - assert.deepStrictEqual(actual, invalidUtf8String); - }); - - 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("ignores maxStrLength", () => { - const lengthLimitedOptions = { ...options, maxStrLength: 1 } satisfies DecoderOptions; - - const actual = decode(encode("foo"), lengthLimitedOptions); - const expected = Uint8Array.from([0x66, 0x6f, 0x6f]); - assert.deepStrictEqual(actual, expected); - }); - - it("respects maxBinLength", () => { - const lengthLimitedOptions = { ...options, maxBinLength: 1 } satisfies DecoderOptions; - - assert.throws(() => { - decode(encode("foo"), lengthLimitedOptions); - }, /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); - }); -}); diff --git a/test/encode.test.ts b/test/encode.test.ts index 42db030..9f01056 100644 --- a/test/encode.test.ts +++ b/test/encode.test.ts @@ -1,5 +1,5 @@ import assert from "assert"; -import { encode, decode } from "@msgpack/msgpack"; +import { encode, decode, RawBinaryString } from "@msgpack/msgpack"; describe("encode", () => { context("sortKeys", () => { @@ -147,6 +147,16 @@ describe("encode", () => { assert.deepStrictEqual(encoded, expected); }); + it("encodes raw string keys", () => { + const m = new Map([ + [new RawBinaryString(Uint8Array.from([97])), 1], + [new RawBinaryString(Uint8Array.from([98])), 2], + ]); + const encoded = encode(m); + const expected = Uint8Array.from([130, 161, 97, 1, 161, 98, 2]); + assert.deepStrictEqual(encoded, expected); + }); + it("encodes number keys", () => { const m = new Map([ [-9, 1], @@ -327,28 +337,51 @@ describe("encode", () => { assert.deepStrictEqual(m1Encoded, expected); }); + it("cannonicalizes encoded raw string keys", () => { + const m1 = new Map([ + [new RawBinaryString(Uint8Array.from([1])), 1], + [new RawBinaryString(Uint8Array.from([2])), 2], + ]); + const m1Encoded = encode(m1, { sortKeys: true }); + const m2 = new Map([ + [new RawBinaryString(Uint8Array.from([2])), 2], + [new RawBinaryString(Uint8Array.from([1])), 1], + ]); + const m2Encoded = encode(m2, { sortKeys: true }); + assert.deepStrictEqual(m1Encoded, m2Encoded); + + const expected = Uint8Array.from([130, 161, 1, 1, 161, 2, 2]); + assert.deepStrictEqual(m1Encoded, expected); + }); + it("cannonicalizes encoded mixed keys", () => { - const m1 = new Map([ + const m1 = new Map([ [1, 1], [2, 2], ["a", 3], ["b", 4], - [Uint8Array.from([1]), 5], - [Uint8Array.from([2]), 6], + [new RawBinaryString(Uint8Array.from([0])), 5], + [new RawBinaryString(Uint8Array.from([100])), 6], + [Uint8Array.from([1]), 7], + [Uint8Array.from([2]), 8], ]); const m1Encoded = encode(m1, { sortKeys: true }); - const m2 = new Map([ + const m2 = new Map([ + [new RawBinaryString(Uint8Array.from([0])), 5], ["b", 4], - [Uint8Array.from([2]), 6], + [Uint8Array.from([2]), 8], ["a", 3], [1, 1], - [Uint8Array.from([1]), 5], + [Uint8Array.from([1]), 7], + [new RawBinaryString(Uint8Array.from([100])), 6], [2, 2], ]); const m2Encoded = encode(m2, { sortKeys: true }); assert.deepStrictEqual(m1Encoded, m2Encoded); - const expected = Uint8Array.from([134, 1, 1, 2, 2, 161, 97, 3, 161, 98, 4, 196, 1, 1, 5, 196, 1, 2, 6]); + const expected = Uint8Array.from([ + 136, 1, 1, 2, 2, 161, 97, 3, 161, 98, 4, 161, 0, 5, 161, 100, 6, 196, 1, 1, 7, 196, 1, 2, 8, + ]); assert.deepStrictEqual(m1Encoded, expected); }); }); diff --git a/test/raw-strings.test.ts b/test/raw-strings.test.ts new file mode 100644 index 0000000..03a896a --- /dev/null +++ b/test/raw-strings.test.ts @@ -0,0 +1,227 @@ +import assert from "assert"; +import { encode, decode, RawBinaryString } from "../src"; +import type { DecoderOptions } from "../src"; + +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, +]); + +describe("encode with RawBinaryString", () => { + it("encodes a RawBinaryString value as a string", () => { + const actual = encode(new RawBinaryString(Uint8Array.from([0x66, 0x6f, 0x6f]))); + const expected = encode("foo"); + assert.deepStrictEqual(actual, expected); + }); + + it("encodes an invalid UTF-8 RawBinaryString value as a string", () => { + const actual = encode(new RawBinaryString(invalidUtf8String)); + const expected = Uint8Array.from([ + 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, + ]); + assert.deepStrictEqual(actual, expected); + }); + + it("encodes a RawBinaryString map key as a string", () => { + const actual = encode(new Map([[new RawBinaryString(Uint8Array.from([0x6b, 0x65, 0x79])), "foo"]])); + const expected = encode({ key: "foo" }); + assert.deepStrictEqual(actual, expected); + }); + + it("encodes an invalid UTF-8 RawBinaryString map key as a string", () => { + const actual = encode(new Map([[new RawBinaryString(invalidUtf8String), "abc"]])); + const expected = 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, + ]); + assert.deepStrictEqual(actual, expected); + }); + + it("encodes a RawBinaryString map key and value as a string", () => { + const actual = encode( + new Map([ + [ + new RawBinaryString(Uint8Array.from([0x6b, 0x65, 0x79])), + new RawBinaryString(Uint8Array.from([0x66, 0x6f, 0x6f])), + ], + ]), + ); + const expected = encode({ key: "foo" }); + assert.deepStrictEqual(actual, expected); + }); + + it("encodes an invalid UTF-8 RawBinaryString map key and value as a string", () => { + const actual = encode(new Map([[new RawBinaryString(invalidUtf8String), new RawBinaryString(invalidUtf8String)]])); + const expected = 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, + ]); + assert.deepStrictEqual(actual, expected); + }); +}); + +describe("decode with rawBinaryStringValues specified", () => { + for (const useRawBinaryStringClass of [true, false, undefined]) { + const options = { rawBinaryStringValues: true, useRawBinaryStringClass } satisfies DecoderOptions; + + const prepareExpectedBytes = (expected: Uint8Array): Uint8Array | RawBinaryString => { + if (useRawBinaryStringClass) { + return new RawBinaryString(expected); + } + return expected; + }; + + context(`useRawBinaryStringClass=${useRawBinaryStringClass}`, () => { + it("decodes string values as binary", () => { + const actual = decode(encode("foo"), options); + const expected = prepareExpectedBytes(Uint8Array.from([0x66, 0x6f, 0x6f])); + assert.deepStrictEqual(actual, expected); + }); + + it("decodes invalid UTF-8 string values as binary", () => { + const encoded = Uint8Array.from([ + 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(encoded, options); + assert.deepStrictEqual(actual, prepareExpectedBytes(invalidUtf8String)); + }); + + it("decodes map string keys as strings", () => { + const actual = decode(encode({ key: "foo" }), options); + const expected = { key: prepareExpectedBytes(Uint8Array.from([0x66, 0x6f, 0x6f])) }; + assert.deepStrictEqual(actual, expected); + }); + + it("ignores maxStrLength", () => { + const lengthLimitedOptions = { ...options, maxStrLength: 1 } satisfies DecoderOptions; + + const actual = decode(encode("foo"), lengthLimitedOptions); + const expected = prepareExpectedBytes(Uint8Array.from([0x66, 0x6f, 0x6f])); + assert.deepStrictEqual(actual, expected); + }); + + it("respects maxBinLength", () => { + const lengthLimitedOptions = { ...options, maxBinLength: 1 } satisfies DecoderOptions; + + assert.throws(() => { + decode(encode("foo"), lengthLimitedOptions); + }, /max length exceeded/i); + }); + }); + } +}); + +describe("decode with rawBinaryStringKeys specified", () => { + for (const useRawBinaryStringClass of [true, false, undefined]) { + const options = { rawBinaryStringKeys: true, useMap: true, useRawBinaryStringClass } satisfies DecoderOptions; + + const prepareExpectedBytes = (expected: Uint8Array): Uint8Array | RawBinaryString => { + if (useRawBinaryStringClass) { + return new RawBinaryString(expected); + } + return expected; + }; + + context(`useRawBinaryStringClass=${useRawBinaryStringClass}`, () => { + it("errors if useMap is not enabled", () => { + assert.throws(() => { + decode(encode({ key: "foo" }), { rawBinaryStringKeys: true, useRawBinaryStringClass }); + }, 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([[prepareExpectedBytes(Uint8Array.from([0x6b, 0x65, 0x79])), "foo"]]); + assert.deepStrictEqual(actual, expected); + }); + + it("decodes invalid UTF-8 string keys as binary", () => { + 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([[prepareExpectedBytes(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([[prepareExpectedBytes(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", () => { + for (const useRawBinaryStringClass of [true, false, undefined]) { + const options = { + rawBinaryStringValues: true, + rawBinaryStringKeys: true, + useMap: true, + useRawBinaryStringClass, + } satisfies DecoderOptions; + + const prepareExpectedBytes = (expected: Uint8Array): Uint8Array | RawBinaryString => { + if (useRawBinaryStringClass) { + return new RawBinaryString(expected); + } + return expected; + }; + + context(`useRawBinaryStringClass=${useRawBinaryStringClass}`, () => { + it("errors if useMap is not enabled", () => { + assert.throws(() => { + decode(encode({ key: "foo" }), { + rawBinaryStringKeys: true, + rawBinaryStringValues: true, + useRawBinaryStringClass, + }); + }, 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([ + [ + prepareExpectedBytes(Uint8Array.from([0x6b, 0x65, 0x79])), + prepareExpectedBytes(Uint8Array.from([0x66, 0x6f, 0x6f])), + ], + ]); + assert.deepStrictEqual(actual, expected); + }); + + it("decodes invalid UTF-8 string keys and values as binary", () => { + 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([[prepareExpectedBytes(invalidUtf8String), prepareExpectedBytes(invalidUtf8String)]]); + assert.deepStrictEqual(actual, expected); + }); + }); + } +});