diff --git a/packages/cachekit/test/fixtures/decode-bounds-vectors.ts b/packages/cachekit/test/fixtures/decode-bounds-vectors.ts new file mode 100644 index 00000000..7efb5da8 --- /dev/null +++ b/packages/cachekit/test/fixtures/decode-bounds-vectors.ts @@ -0,0 +1,111 @@ +/** + * The vendored protocol/test-vectors/decode-bounds.json, typed, plus the + * envelope pre-scan assertions both ByteStorage lanes use: the NAPI lane + * (test/protocol/decode-bounds.protocol.test.ts, which sha256-pins the file) + * and the Workers lane (test/workers/decode-bounds.workers.test.ts). Plain TS + * with no node:* imports, so workerd can load it. + */ + +import { expect } from 'vitest'; +import fixture from '../protocol/fixtures/decode-bounds.json' with { type: 'json' }; +import { construct } from './wire-vectors.js'; + +export interface Vector { + name: string; + construction: { repeat_hex: string; count: number; suffix_hex: string }; + input_hex: string; + nesting_depth: number; + declared_slots: number; + reject_reasons?: string[]; +} + +interface VectorFile { + spec: string; + reject_vectors: Vector[]; + accept_vectors: Vector[]; +} + +// Annotated, not cast: `pnpm type-check:tests` then checks every entry of the +// JSON import against the interface, where a cast lets a short entry through. +export const vectors: VectorFile = fixture; + +/** Bytes either side of a built input; see build(). */ +const GUARD = 8; + +/** + * field_notes.construction: input = fromhex(repeat_hex) * count + fromhex(suffix_hex). + * + * Returned as a view at a non-zero offset into a larger buffer, as a pooled + * Node Buffer or a network read arrives, with the reserved marker 0xc1 on both + * sides: a decoder that ignores byteOffset, or reads past byteLength, sees + * bytes that are not the input. + */ +export function build(v: Vector): Uint8Array { + const input = construct([ + { hex: v.construction.repeat_hex, count: v.construction.count }, + { hex: v.construction.suffix_hex, count: 1 }, + ]); + const padded = new Uint8Array(input.length + 2 * GUARD).fill(0xc1); + padded.set(input, GUARD); + return padded.subarray(GUARD, GUARD + input.length); +} + +/** + * How both `unpack` bindings surface a cachekit-core envelope decode failure: + * `ByteStorageError::DeserializationFailed`'s display. A pre-scan rejection + * then carries the pre-scan's own tag, then the reason. + */ +const DESERIALIZATION_FAILED = 'deserialization failed: '; +const PRE_SCAN_TAG = 'decode pre-scan: '; +const ENVELOPE_PRE_SCAN = `${DESERIALIZATION_FAILED}${PRE_SCAN_TAG}`; + +/** + * The error a reject vector must raise at `unpack`, matched from its start: a + * typed-decode error can echo attacker bytes mid-message, so only a prefix + * match is unforgeable. + * + * The prefix alone is not enough. The pre-scan's own end-of-input rejection + * carries it too, so with the slot check deleted the overclaim-only vectors it + * guards still fail with the prefix, once the walk runs out of input. + * Depth-only and overclaim-only vectors therefore pin their rule's reason, as + * cachekit-core's own tests/decode_bounds_vectors.rs does: overclaim-only ones + * the byte or slot budget, depth-only ones (structurally complete, so nothing + * else can reject them) the depth bound itself, which is cachekit-core's, not + * this package's. + */ +function envelopeRejection(v: Vector): RegExp { + switch (v.reject_reasons?.join()) { + case 'depth': + return new RegExp(`^${ENVELOPE_PRE_SCAN}nests deeper than 100 levels$`); + case 'overclaim': + return new RegExp(`^${ENVELOPE_PRE_SCAN}declares more `); + default: + // A vector that breaks several rules can trip any of them first. An + // incomplete-only vector fits the spec's slot budget but not core's + // stricter running one, so core rejects it there rather than at end of + // input. Either is the pre-scan, so only the prefix is pinned. + return new RegExp(`^${ENVELOPE_PRE_SCAN}`); + } +} + +export function expectEnvelopePreScanRejects( + unpack: (bytes: Uint8Array) => unknown, + v: Vector +): void { + expect(() => unpack(build(v))).toThrow(envelopeRejection(v)); +} + +/** + * An accept vector is not an envelope, so the typed decode after the pre-scan + * refuses it, and that refusal is required: an error that is not core's + * decode failure (a wasm trap, say) would otherwise pass for "past the + * pre-scan" without showing the pre-scan ran to completion. + */ +export function expectEnvelopePreScanAccepts( + unpack: (bytes: Uint8Array) => unknown, + v: Vector +): void { + expect(() => unpack(build(v))).toThrow( + new RegExp(`^${DESERIALIZATION_FAILED}(?!${PRE_SCAN_TAG})`) + ); +} diff --git a/packages/cachekit/test/protocol/decode-bounds.protocol.test.ts b/packages/cachekit/test/protocol/decode-bounds.protocol.test.ts index 7beccd5b..59e37bca 100644 --- a/packages/cachekit/test/protocol/decode-bounds.protocol.test.ts +++ b/packages/cachekit/test/protocol/decode-bounds.protocol.test.ts @@ -6,13 +6,12 @@ * package: the auto-mode serializer, the interop/v1 value decoder and the * invalidation-event decoder. Spec: protocol/spec/interop-mode.md#decode-bounds. * The bounds themselves are `assertDecodeDepth`. The - * ByteStorage envelope is decoded in Rust (cachekit-core, reached via NAPI - * `unpack` / wasm), not here. cachekit-io/cachekit-core#80 adds the envelope - * pre-scan to `ByteStorage::retrieve` and runs the protocol's - * decode-bounds.json 1.1.0 through it in - * core CI. The cachekit-core 0.6.0 this repo pins has no envelope pre-scan, so - * `unpack` gains the guard only when the pin moves to a core release that - * carries it. + * ByteStorage envelope is decoded in Rust instead: `unpack` reaches + * cachekit-core's `ByteStorage::retrieve`, which pre-scans the envelope bytes + * before decoding them (cachekit-core 0.7.0 and later). The envelope entry + * point is covered in both lanes: the NAPI `unpack` here, and the wasm + * `unpack` inside workerd in test/workers/decode-bounds.workers.test.ts. Both + * share their assertions through test/fixtures/decode-bounds-vectors.ts. * * Provenance: cachekit-io/protocol @ 42ea601f (the merge of * cachekit-io/protocol#164, the revision that last touched the fixture; @@ -30,6 +29,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; +import { ByteStorage } from '@cachekit-io/cachekit-core-ts'; import { MessagePackSerializer, assertDecodeDepth } from '../../src/serialization/serializer.js'; import { decodeInteropValue } from '../../src/serialization/interop.js'; import { deserializeEvent } from '../../src/invalidation/event.js'; @@ -39,60 +39,23 @@ import { MAX_INVALIDATION_EVENT_DEPTH, } from '../../src/constants.js'; import { SerializationError } from '../../src/errors.js'; -import { assertFixture, table, type ObjectShape } from '../fixtures/fixture-shape.js'; +import { + build, + expectEnvelopePreScanAccepts, + expectEnvelopePreScanRejects, + vectors, + type Vector, +} from '../fixtures/decode-bounds-vectors.js'; /** sha256 of test-vectors/decode-bounds.json at the provenance above. */ const FIXTURE_SHA256 = 'c52c27f724fe138e63440dc0306936b48fe389e2bd823c058b86b30d854e2e2e'; // pragma: allowlist secret -interface Vector { - name: string; - construction: { repeat_hex: string; count: number; suffix_hex: string }; - input_hex: string; - nesting_depth: number; - declared_slots: number; - reject_reasons?: string[]; -} - -interface VectorFile { - spec: string; - reject_vectors: Vector[]; - accept_vectors: Vector[]; -} - +// Raw bytes of the same file the shared module imports: the pin covers every +// byte, not a re-serialisation. const raw = readFileSync( join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'decode-bounds.json') ); -const VECTOR_SHAPE: ObjectShape = { - name: 'string', - construction: { repeat_hex: 'string', count: 'number', suffix_hex: 'string' }, - input_hex: 'string', - nesting_depth: 'number', - declared_slots: 'number', - reject_reasons: [table('string'), 'undefined'], -}; - -function assertVectorFile(value: unknown): asserts value is VectorFile { - assertFixture( - value, - { spec: 'string', reject_vectors: table(VECTOR_SHAPE), accept_vectors: table(VECTOR_SHAPE) }, - 'decode-bounds.json' - ); -} - -const parsed: unknown = JSON.parse(raw.toString('utf8')); -assertVectorFile(parsed); -const vectors: VectorFile = parsed; - -/** field_notes.construction: input = fromhex(repeat_hex) * count + fromhex(suffix_hex). */ -function build(v: Vector): Buffer { - const unit = Buffer.from(v.construction.repeat_hex, 'hex'); - return Buffer.concat([ - Buffer.alloc(unit.length * v.construction.count, unit), - Buffer.from(v.construction.suffix_hex, 'hex'), - ]); -} - const serializer = new MessagePackSerializer(); /** What each accept vector must decode to on the value paths (keyed by name; a @@ -204,7 +167,7 @@ describe('Protocol decode-bounds vectors (spec/interop-mode.md#decode-bounds)', it('every construction reproduces its input_hex', () => { for (const v of [...vectors.reject_vectors, ...vectors.accept_vectors]) { - expect(build(v).toString('hex'), v.name).toBe(v.input_hex); + expect(Buffer.from(build(v)).toString('hex'), v.name).toBe(v.input_hex); } }); @@ -267,6 +230,18 @@ describe('Protocol decode-bounds vectors (spec/interop-mode.md#decode-bounds)', }); }); + describe('ByteStorage.unpack (NAPI)', () => { + const storage = new ByteStorage(); + + it.each(vectors.reject_vectors)('rejects $name at the envelope pre-scan', (v) => { + expectEnvelopePreScanRejects((b) => storage.unpack(b), v); + }); + + it.each(vectors.accept_vectors)('gets $name past the envelope pre-scan', (v) => { + expectEnvelopePreScanAccepts((b) => storage.unpack(b), v); + }); + }); + describe('accept vectors', () => { it.each(vectors.accept_vectors)('MessagePackSerializer.decode accepts $name', (v) => { expect(serializer.decode(build(v))).toEqual(EXPECTED[v.name]); diff --git a/packages/cachekit/test/workers/decode-bounds.workers.test.ts b/packages/cachekit/test/workers/decode-bounds.workers.test.ts new file mode 100644 index 00000000..ebc3cbc2 --- /dev/null +++ b/packages/cachekit/test/workers/decode-bounds.workers.test.ts @@ -0,0 +1,30 @@ +/** + * Decode Bounds — Workers lane + * + * Runs protocol/test-vectors/decode-bounds.json (vendored in + * test/protocol/fixtures/ and sha256-pinned by the Node lane, + * test/protocol/decode-bounds.protocol.test.ts) through the wasm ByteStorage + * `unpack` inside real workerd: the Workers build's envelope entry point must + * apply cachekit-core's envelope pre-scan exactly as the NAPI one does. The + * assertions are shared with the Node lane (test/fixtures/decode-bounds-vectors.ts). + */ + +import { describe, it } from 'vitest'; +import { ByteStorage } from '../../src/workers/runtime.js'; +import { + expectEnvelopePreScanAccepts, + expectEnvelopePreScanRejects, + vectors, +} from '../fixtures/decode-bounds-vectors.js'; + +describe('decode-bounds vectors (wasm ByteStorage.unpack)', () => { + const storage = new ByteStorage(); + + it.each(vectors.reject_vectors)('rejects $name at the envelope pre-scan', (v) => { + expectEnvelopePreScanRejects((b) => storage.unpack(b), v); + }); + + it.each(vectors.accept_vectors)('gets $name past the envelope pre-scan', (v) => { + expectEnvelopePreScanAccepts((b) => storage.unpack(b), v); + }); +});