diff --git a/packages/cachekit/src/serialization/interop.test.ts b/packages/cachekit/src/serialization/interop.test.ts index d35b01a..fccaf69 100644 --- a/packages/cachekit/src/serialization/interop.test.ts +++ b/packages/cachekit/src/serialization/interop.test.ts @@ -138,6 +138,67 @@ 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('accepts a duplicate larger than half the budget (dedupe is confirmed before the aggregate charge)', () => { + // Two distinct-identity copies of one ~700 KiB element: deduped output + // ~700 KiB, comfortably under the 1 MiB budget. The duplicate's re-encode + // must run against the parent base, not the advanced running total — + // otherwise it crosses the budget mid-encode before dedupe can identify + // it, falsely rejecting a Set whose canonical encoding fits. + const dup = (): { k: string } => ({ k: 'y'.repeat(700 * 1024) }); + const pair = new Set([dup(), dup()]); + expect(pair.size).toBe(2); + expect(encodeInteropValue(pair)).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..01a0abd 100644 --- a/packages/cachekit/src/serialization/interop.ts +++ b/packages/cachekit/src/serialization/interop.ts @@ -126,7 +126,12 @@ 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 (so it may exceed the total length of `chunks`), capping any single + * element; the aggregate across retained elements is charged separately in + * the Set loop, after dedupe. + */ bytes: number; } @@ -410,19 +415,38 @@ 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), each seeded from the parent total so no single + // element can exceed the absolute budget, and dedupe happens on insert. + // The aggregate byte budget is charged only AFTER an element is confirmed + // unique — duplicateness is unknowable until encoded, and charging the + // running total during the re-encode would falsely reject a duplicate + // bigger than the budget remainder even though the deduped output fits. + // Both the byte budget and the collection-size cap fail DURING iteration, + // counting exactly what the output retains: duplicates advance neither + // total. 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 }; 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 += bytes.length; + if (running > DEFAULT_MAX_ENCODED_SIZE) { + throw new ValueTooLargeError( + `Encoded interop payload exceeds max size ${DEFAULT_MAX_ENCODED_SIZE}` + ); + } + 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) {