diff --git a/packages/cachekit/src/serialization/interop.test.ts b/packages/cachekit/src/serialization/interop.test.ts index d35b01a..27fde92 100644 --- a/packages/cachekit/src/serialization/interop.test.ts +++ b/packages/cachekit/src/serialization/interop.test.ts @@ -138,6 +138,55 @@ describe('interop argument encoding (args profile)', () => { }); }); +describe('interop Set encoding budgets (encodeCanonical, shared by both profiles)', () => { + it('rejects a Set whose elements are collectively over budget during iteration', () => { + // 32 × ~64 KiB elements: each far under the 1 MiB budget, ~2 MiB in + // aggregate. Set element sub-encodes buffer into `encoded[]` before the + // parent sink sees any bytes, so the running total must be threaded + // across the loop — otherwise all 32 elements materialise before the + // budget fires. Getter spies count how many elements were actually + // encoded: each element encodes to 65,550 bytes, so the throw lands + // during element 16 — mid-iteration, not after the loop. + const total = 32; + let encoded = 0; + const elements = Array.from({ length: total }, (_, i) => ({ + get payload(): string { + encoded++; + return `${i}:`.padEnd(64 * 1024, 'x'); + }, + })); + expect(() => encodeInteropValue(new Set(elements))).toThrow(ValueTooLargeError); + expect(encoded).toBeLessThan(total); + }); + + it('accepts a duplicate-heavy Set whose deduped encoding fits the budget', () => { + // 20 distinct-identity objects with identical canonical encodings: ~4 MiB + // pre-dedupe, ~200 KiB deduped. Dedupe happens on insert, so duplicates + // advance neither the byte budget nor the count — this must encode + // byte-identically to the singleton, not throw at the pre-dedupe sum. + const dup = (): { k: string } => ({ k: 'x'.repeat(200 * 1024) }); + const many = new Set(Array.from({ length: 20 }, dup)); + expect(many.size).toBe(20); + expect(encodeInteropValue(many)).toEqual(encodeInteropValue(new Set([dup()]))); + }); + + it('rejects a Set with too many distinct elements during iteration', () => { + // 10,002 tiny distinct elements: far under the byte budget, over the + // 10,000 collection cap. The cap fires on the 10,001st retained element, + // not after the full Set has been encoded and buffered. + const total = 10_002; + let encoded = 0; + const elements = Array.from({ length: total }, (_, i) => ({ + get n(): number { + encoded++; + return i; + }, + })); + expect(() => encodeInteropValue(new Set(elements))).toThrow(ValueTooLargeError); + expect(encoded).toBeLessThan(total); + }); +}); + 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 89d6f32..215015d 100644 --- a/packages/cachekit/src/serialization/interop.ts +++ b/packages/cachekit/src/serialization/interop.ts @@ -126,7 +126,11 @@ function concatChunks(chunks: Uint8Array[]): Uint8Array { */ interface ChunkSink { chunks: Uint8Array[]; - /** Running encoded byte count (starts above 0 for Set element sub-encodes). */ + /** + * Running budget cursor. Set element sub-encodes seed it with the parent + * total plus prior retained elements' bytes, so it may exceed the total + * length of `chunks`. + */ bytes: number; } @@ -410,19 +414,32 @@ function encodeCanonical( // encoded bytes (unsigned lexicographic) and dedupe post-normalization — // a total, language-neutral order (spec: "Set ordering is not numeric // order"). + // Elements encode into isolated sub-sinks (the byte-order sort needs each + // element's bytes) threaded through a running byte total, and dedupe + // happens on insert, so the byte budget and the collection-size cap both + // fail DURING iteration — before every element is encoded and buffered — + // while counting exactly what the output retains: duplicates advance + // neither total. Acceptance matches checking after the loop, except that + // a duplicate arriving with less than its own encoded size left in the + // byte budget still throws mid-encode (duplicateness is unknowable until + // encoded). The parent's own total advances once, on the pushes below. const encoded: Uint8Array[] = []; + const seen = new Set(); + let running = sink.bytes; for (const element of v) { - // Isolated sub-sink (the byte-order sort needs each element's bytes), - // inheriting the parent's running total so a huge element fails fast. - // Parent bytes are counted once, when the deduped buffers are pushed. - const sub: ChunkSink = { chunks: [], bytes: sink.bytes }; + const sub: ChunkSink = { chunks: [], bytes: running }; encodeCanonical(element, profile, depth + 1, sub); - encoded.push(concatChunks(sub.chunks)); + const bytes = concatChunks(sub.chunks); + const key = bytesToHex(bytes); + if (seen.has(key)) continue; + seen.add(key); + checkCollectionSize(seen.size, 'array'); + running = sub.bytes; + encoded.push(bytes); } encoded.sort(compareBytes); - const deduped = encoded.filter((b, i) => i === 0 || compareBytes(b, encoded[i - 1]!) !== 0); - encodeArrayHeader(deduped.length, sink); - for (const b of deduped) pushChunk(sink, b); + encodeArrayHeader(encoded.length, sink); + for (const b of encoded) pushChunk(sink, b); } else if (Array.isArray(v)) { encodeArrayHeader(v.length, sink); for (const item of v) {