Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions packages/cachekit/test/fixtures/decode-bounds-vectors.ts
Original file line number Diff line number Diff line change
@@ -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})`)
);
}
83 changes: 29 additions & 54 deletions packages/cachekit/test/protocol/decode-bounds.protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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]);
Expand Down
30 changes: 30 additions & 0 deletions packages/cachekit/test/workers/decode-bounds.workers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading