diff --git a/packages/cachekit/src/serialization/interop.test.ts b/packages/cachekit/src/serialization/interop.test.ts index fccaf69..416b8e6 100644 --- a/packages/cachekit/src/serialization/interop.test.ts +++ b/packages/cachekit/src/serialization/interop.test.ts @@ -199,6 +199,50 @@ describe('interop Set encoding budgets (encodeCanonical, shared by both profiles }); }); +describe('interop map/object collection cap timing (encodeMapEntries)', () => { + it('rejects an over-cap Map before iterating a single entry', () => { + // Map.size is O(1), so the cap must fire before the entry loop runs — + // otherwise 10,001 tuples materialise pre-cap. The own-property iterator + // spy shadows Map.prototype[Symbol.iterator] and counts pulls. + const m = new Map(Array.from({ length: 10_001 }, (_, i) => [`k${i}`, 0])); + let iterated = 0; + const inner = Map.prototype[Symbol.iterator].bind(m); + Object.defineProperty(m, Symbol.iterator, { + value: function* (): Generator<[string, number]> { + for (const e of inner()) { + iterated++; + yield e as [string, number]; + } + }, + }); + expect(() => encodeInteropValue(m)).toThrow(ValueTooLargeError); + expect(iterated).toBe(0); + }); + + it('rejects an over-cap plain object before any key is UTF-8 encoded or sorted', () => { + // The first-iterated key is a lone surrogate: if any key reached + // utf8Strict, the encoder would throw SerializationError (well-formedness) + // instead of ValueTooLargeError. The cap winning pins the ordering — the + // count check fires before key materialisation. + const obj: Record = { '\ud800': 0 }; + for (let i = 0; i < 10_001; i++) obj[`k${i}`] = 0; + expect(() => encodeInteropValue(obj)).toThrow(ValueTooLargeError); + // Same object one key under the cap: key encoding now runs and the lone + // surrogate is what rejects it (proves the spy key is actually live). + const under: Record = { '\ud800': 0 }; + for (let i = 0; i < 9_998; i++) under[`k${i}`] = 0; + expect(() => encodeInteropValue(under)).toThrow(/well-formed Unicode|lone surrogates/); + }); + + it('accepts a Map at exactly the cap with unchanged canonical bytes', () => { + const atCap = new Map(Array.from({ length: 10_000 }, (_, i) => [`k${i}`, i])); + const bytes = encodeInteropValue(atCap); + // Object form of the same entries encodes byte-identically (shared + // encodeMapEntries path, key-sorted canonical form). + expect(hex(encodeInteropValue(Object.fromEntries(atCap)))).toBe(hex(bytes)); + }); +}); + describe('interop value encoding (value profile)', () => { it('maps undefined to nil (no cross-SDK arity contract for values)', () => { expect(hex(encodeInteropValue(undefined))).toBe('c0'); diff --git a/packages/cachekit/src/serialization/interop.ts b/packages/cachekit/src/serialization/interop.ts index 721977f..7147309 100644 --- a/packages/cachekit/src/serialization/interop.ts +++ b/packages/cachekit/src/serialization/interop.ts @@ -339,6 +339,13 @@ function encodeMapEntries( depth: number, sink: ChunkSink ): void { + // Cap BEFORE materialising key encodings: map keys are unique by + // construction, so the entry count is final up front — unlike Sets, no + // dedupe can shrink it. Checking here (rather than in encodeMapHeader + // after the map/sort below) keeps an over-cap map from forcing N + // Uint8Array allocations plus an O(N log N) sort that never pass through + // pushChunk's byte budget. + checkCollectionSize(entries.length, 'map'); // Sort keys by UTF-8 byte order (== Unicode code point order). The default // Array.prototype.sort comparator orders UTF-16 code units and gets // supplementary-plane characters backwards (map_key_sort_supplementary). @@ -454,6 +461,9 @@ function encodeCanonical( encodeCanonical(item, profile, depth + 1, sink); } } else if (v instanceof Map) { + // Map.size is O(1) — reject over-cap maps before iterating at all, so + // the tuple materialisation below is also bounded. + checkCollectionSize(v.size, 'map'); const entries: [string, unknown][] = []; for (const [k, val] of v) { if (typeof k !== 'string') {