From 357df4e34f2fc3ee77aa3a26fe25f6285564cd5d Mon Sep 17 00:00:00 2001 From: Samuel Laycock Date: Tue, 19 May 2026 21:12:16 +0100 Subject: [PATCH] fix: validate sparse array metadata Reject sparse array metadata when the declared length would truncate already decoded entries. Add regression coverage for small and maximum-size malformed sparse array metadata from issue #67. --- .changeset/validate-sparse-array-metadata.md | 5 ++++ src/decode.ts | 30 +++++++++++++++++--- test/roundtrip.test.ts | 22 ++++++++++++++ 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 .changeset/validate-sparse-array-metadata.md diff --git a/.changeset/validate-sparse-array-metadata.md b/.changeset/validate-sparse-array-metadata.md new file mode 100644 index 0000000..b508761 --- /dev/null +++ b/.changeset/validate-sparse-array-metadata.md @@ -0,0 +1,5 @@ +--- +"superformdata": patch +--- + +Reject sparse array metadata that would truncate decoded values. diff --git a/src/decode.ts b/src/decode.ts index 03db46c..6b29b04 100644 --- a/src/decode.ts +++ b/src/decode.ts @@ -145,9 +145,12 @@ export function decode( for (const [path, segments, length] of sortedSparseArrays) { if (path === "") { - if (Array.isArray(result)) result.length = length; + if (Array.isArray(result)) { + validateSparseArrayLength(path, result, length); + result.length = length; + } } else { - resizeArray(result, segments, length); + resizeArray(result, path, segments, length); } } @@ -278,7 +281,12 @@ function isConvertedStructuralValue(value: unknown, typeId: string): boolean { return (typeId === "set" && value instanceof Set) || (typeId === "map" && value instanceof Map); } -function resizeArray(root: unknown, segments: readonly PathSegment[], length: number): void { +function resizeArray( + root: unknown, + path: string, + segments: readonly PathSegment[], + length: number, +): void { if (segments.length === 0) return; let current: Record = root as Record; @@ -289,5 +297,19 @@ function resizeArray(root: unknown, segments: readonly PathSegment[], length: nu const lastSeg = segments[segments.length - 1]!; const value = current[lastSeg]; - if (Array.isArray(value)) value.length = length; + if (!Array.isArray(value)) return; + + validateSparseArrayLength(path, value, length); + value.length = length; +} + +function validateSparseArrayLength(path: string, value: readonly unknown[], length: number): void { + for (const key of Object.keys(value)) { + const index = Number(key); + if (!Number.isInteger(index) || index < length) continue; + + throw new TypeError( + `Invalid superformdata metadata: sparse array length ${length} at path "${path}" would truncate decoded index ${index}`, + ); + } } diff --git a/test/roundtrip.test.ts b/test/roundtrip.test.ts index 3ddc031..85bbd1a 100644 --- a/test/roundtrip.test.ts +++ b/test/roundtrip.test.ts @@ -442,6 +442,28 @@ describe("encode/decode round-trip", () => { expect(() => decode([["a[100000000]", "x"]])).toThrow("Array index too large"); }); + test("decode rejects sparse array metadata shorter than decoded indexes", () => { + expect(() => + decode([ + ["items[5]", "x"], + ["$types", JSON.stringify({ items: "array:2" })], + ]), + ).toThrow( + 'Invalid superformdata metadata: sparse array length 2 at path "items" would truncate decoded index 5', + ); + }); + + test("decode rejects large sparse array metadata shorter than decoded indexes", () => { + expect(() => + decode([ + ["items[100000]", "x"], + ["$types", JSON.stringify({ items: "array:100000" })], + ]), + ).toThrow( + 'Invalid superformdata metadata: sparse array length 100000 at path "items" would truncate decoded index 100000', + ); + }); + test("decode rejects malformed path syntax", () => { expect(() => decode([["items[0", "x"]])).toThrow( 'Invalid path "items[0": missing closing bracket',