import { describe, it } from "@std/testing/bdd"; import { expect } from "@std/expect"; import { assert } from "@std/assert"; import { Identity, isCanonicalEd25519DID } from "@commonfabric/identity"; import { gzipSync } from "fflate"; import { compressRoutedPayload, decodeRoutedFrame, decodeRoutedFrameAsync, encodeRoutedFrame, parseRoutedJson, ROUTED_EXPANDED_LIMIT, ROUTED_RAW_LIMIT, routedFlags, } from "../v2/routed-parser.ts"; import { DEFAULT_ROUTED_HOST_LIMITS } from "../v2/routed-host.ts"; import { countValues, routedFrameOf } from "./support/routed-slots.ts"; import { getMemoryProtocolFlags } from "../v2.ts"; const space = (await Identity.fromRaw(new Uint8Array(32).fill(91))).did(); /** A slot cap no frame in this file reaches unless a test says so. */ const SLOTS = 1024; /** * The slot-counting vector shared with the router (infra * `memory-router/src/parser.rs`): one slot per JSON value, whether scalar, * object or array, keys free, the root included, so this frame body is 30 * slots. The literal and the count are for infra to mirror in the router's * tests. */ const COUNTING_VECTOR = '{"type":"session/effect","sessionId":"s1","effect":{"type":"sync",' + '"fromSeq":0,"toSeq":2,"upserts":[{"branch":"","id":"of:1","scope":"s",' + '"seq":1,"doc":{"a":[1,2,3],"b":{},"c":[],"d":null,"e":true,"f":"x"}},' + '{"branch":"","id":"of:2","scope":"s","seq":2,"doc":{}}],"removes":[]}}'; const COUNTING_VECTOR_SLOTS = 30; function envelope(payload: string, hint: string = space) { const zipped = gzipSync(new TextEncoder().encode(payload)); const header = new Uint8Array(11 + hint.length); header.set(new TextEncoder().encode("mcmp\x02")); const view = new DataView(header.buffer); view.setUint32(5, new TextEncoder().encode(payload).length); view.setUint16(9, hint.length); header.set(new TextEncoder().encode(hint), 11); return new Uint8Array([...header, ...zipped]); } describe("routed untrusted parsers", () => { it("rejects duplicate decoded keys, prototype keys, surrogate errors and excess structure", () => { for ( const source of [ '{"a":1,"a":2}', '{"a":1,"\\u0061":2}', '{"__proto__":{}}', '{"constructor":1}', '"\\ud800"', '"unterminated', '{"x":1e999}', "[".repeat(66) + "0" + "]".repeat(66), ] ) expect(() => parseRoutedJson(source, SLOTS)).toThrow(); expect(parseRoutedJson(' \n\t { "ok" : true } \r ', SLOTS)).toEqual({ ok: true, }); }); it("counts slots as the router does: one per value, keys free, root included", () => { const parsed = JSON.parse(COUNTING_VECTOR); expect(countValues(parsed)).toBe(COUNTING_VECTOR_SLOTS); expect(parseRoutedJson(COUNTING_VECTOR, COUNTING_VECTOR_SLOTS)).toEqual( parsed, ); expect(() => parseRoutedJson(COUNTING_VECTOR, COUNTING_VECTOR_SLOTS - 1)) .toThrow(); // Every scalar kind, an empty object and an empty array take one slot each. expect(parseRoutedJson('[null,true,1,"x",{},[]]', 7)).toEqual([ null, true, 1, "x", {}, [], ]); expect(() => parseRoutedJson('[null,true,1,"x",{},[]]', 6)).toThrow(); for (const cap of [0, -1, 1.5, Number.NaN]) { expect(() => parseRoutedJson("1", cap)).toThrow(); } // The frame prefix, the envelope and its hint are not values. const frame = `fvj1:${COUNTING_VECTOR}`; expect(decodeRoutedFrame(frame, false, COUNTING_VECTOR_SLOTS).body).toEqual( parsed, ); expect(() => decodeRoutedFrame(frame, false, COUNTING_VECTOR_SLOTS - 1)) .toThrow(); expect( decodeRoutedFrame(envelope(frame, ""), true, COUNTING_VECTOR_SLOTS).body, ).toEqual(parsed); expect(() => decodeRoutedFrame(envelope(frame, ""), true, COUNTING_VECTOR_SLOTS - 1) ).toThrow(); }); it("keeps the nesting cap at 64 whatever the slot cap", () => { expect(parseRoutedJson("[".repeat(64) + "0" + "]".repeat(64), SLOTS)) .toBeDefined(); expect(() => parseRoutedJson("[".repeat(65) + "0" + "]".repeat(65), SLOTS)) .toThrow(); }); it("disables only the value-count check for a null slot cap", () => { const payload = routedFrameOf(231_438); expect(() => decodeRoutedFrame(payload, false, 150_000)).toThrow(); expect(decodeRoutedFrame(payload, false, null).payload).toBe(payload); const compressed = encodeRoutedFrame(payload, null); expect(compressed).toBeInstanceOf(Uint8Array); expect(decodeRoutedFrame(compressed, true, null).payload).toBe(payload); expect(() => decodeRoutedFrame(compressed, true, 150_000)).toThrow(); for ( const malformed of [ '{"a":1,"a":2}', '{"a":1,"\\u0061":2}', '{"__proto__":{}}', '{"constructor":1}', '{"prototype":1}', '"\\ud800"', '{"x":1e999}', "[".repeat(65) + "0" + "]".repeat(65), ] ) { expect(() => parseRoutedJson(malformed, null)).toThrow(); } const oversized = 'fvj1:{"type":"response","ok":"' + "x".repeat(ROUTED_RAW_LIMIT) + '"}'; expect(() => decodeRoutedFrame(oversized, false, null)).toThrow(); expect(() => decodeRoutedFrame( 'fvj1:{"type":"response","space":"not-a-did"}', false, null, ) ).toThrow(); }); it("applies the sender's and the receiver's slot cap to a whole frame", () => { const cap = 2000; const exact = routedFrameOf(cap); const over = routedFrameOf(cap + 1); expect(countValues(JSON.parse(exact.slice(5)))).toBe(cap); const encoded = encodeRoutedFrame(exact, cap); expect(encoded).toBeInstanceOf(Uint8Array); expect(decodeRoutedFrame(encoded, true, cap).payload).toBe(exact); expect(() => decodeRoutedFrame(encoded, true, cap - 1)).toThrow(); expect(decodeRoutedFrame(exact, false, cap).payload).toBe(exact); expect(() => decodeRoutedFrame(exact, false, cap - 1)).toThrow(); expect(() => encodeRoutedFrame(over, cap)).toThrow(); expect(() => decodeRoutedFrame(over, false, cap)).toThrow(); expect(() => decodeRoutedFrame(envelope(over, ""), true, cap)).toThrow(); expect(decodeRoutedFrame(envelope(over, ""), true, cap + 1).payload).toBe( over, ); }); it("admits a 119,721-slot document-shaped sync frame at the default cap", () => { // The rehearsal's largest first-open sync frame is 119,720 slots without // the schema table; this one is one more, shaped like a sync effect whose // upserts carry nested documents, then padded to the exact count. const upserts: Record[] = []; for (let i = 0; i < 7041; i++) { upserts.push({ branch: "", id: `of:${i}`, scope: "s", seq: i + 1, doc: { value: { a: 1, b: "x", c: [1, 2, 3], d: { e: true, f: null } } }, }); } const message = { type: "session/effect", space, sessionId: "s1", effect: { type: "sync", fromSeq: 0, toSeq: 7042, upserts, removes: [] }, }; const target = 119_721; // The padding upsert is its object, four scalars and the array: 6 slots. const pad = target - countValues(message) - 6; expect(pad).toBeGreaterThan(0); upserts.push({ branch: "", id: "of:pad", scope: "s", seq: 7042, doc: Array(pad).fill(0), }); expect(countValues(message)).toBe(target); const frame = `fvj1:${JSON.stringify(message)}`; const cap = DEFAULT_ROUTED_HOST_LIMITS.frameSlots; expect(cap).toBe(150_000); const encoded = encodeRoutedFrame(frame, cap); expect(decodeRoutedFrame(encoded, true, cap).body.type).toBe( "session/effect", ); expect(() => decodeRoutedFrame(encoded, true, target - 1)).toThrow(); // The fixed cap this replaced would have refused it. expect(() => decodeRoutedFrame(encoded, true, 100_000)).toThrow(); }); it("bounds the holdings a frame names, and not its watches", () => { const cap = DEFAULT_ROUTED_HOST_LIMITS.frameSlots; const frame = (fields: Record) => `fvj1:${JSON.stringify({ type: "session.watch.set", ...fields })}`; const watches = (length: number) => Array.from({ length }, (_, i) => ({ id: `w${i}` })); // More watches than a session may hold by default still parse, on the // way in and on the way out: the toolshed answers that request, and the // slot cap is what bounds the frame. const many = frame({ watches: watches(5000) }); expect(decodeRoutedFrame(many, false, cap).body.type).toBe( "session.watch.set", ); expect( decodeRoutedFrame(encodeRoutedFrame(many, cap), true, cap).body.type, ).toBe("session.watch.set"); expect(() => decodeRoutedFrame(many, false, 5000)).toThrow(); for (const watches of [null, 7, true, "watch"]) { expect(() => decodeRoutedFrame(frame({ watches }), false, cap)).toThrow(); expect(() => encodeRoutedFrame(frame({ watches }), cap)).toThrow(); } expect( decodeRoutedFrame(frame({ holdings: Array(8192).fill(0) }), false, cap) .body.type, ).toBe("session.watch.set"); expect(() => decodeRoutedFrame(frame({ holdings: Array(8193).fill(0) }), false, cap) ).toThrow(); }); it("checks DIDs rather than accepting a did:key prefix", () => { expect(isCanonicalEd25519DID(space)).toBe(true); for ( const did of [ "did:key:z6Mk-test", space + "x", space + "#key-1", "did:key:z" + "1".repeat(34), ] ) expect(isCanonicalEd25519DID(did)).toBe(false); }); it("accepts the modern routed codec and independently checks header and body", () => { const payload = `fvj1:${ JSON.stringify({ type: "session.open", requestId: "r1", space, principal: space, session: {}, }) }`; expect(decodeRoutedFrame(envelope(payload), true, SLOTS).space).toBe( space, ); expect( decodeRoutedFrame(encodeRoutedFrame(payload, SLOTS), false, SLOTS) .payload, ).toBe(payload); expect(() => decodeRoutedFrame(envelope(payload, ""), true, SLOTS)) .toThrow(); expect(() => decodeRoutedFrame(envelope(payload).slice(0, -1), true, SLOTS)) .toThrow(); const corrupt = envelope(payload); corrupt[corrupt.length - 8] ^= 1; expect(() => decodeRoutedFrame(corrupt, true, SLOTS)).toThrow(); expect(() => decodeRoutedFrame( new Uint8Array([ ...envelope(payload), ...gzipSync(new Uint8Array([0])), ]), true, SLOTS, ) ).toThrow(); expect(() => decodeRoutedFrame(envelope(payload), false, SLOTS)).toThrow(); expect(() => decodeRoutedFrame( envelope( 'fvj1:{"type":"transact","requestId":"r1","space":"' + space + '","value":"' + "a".repeat(100000) + '"}', ), true, SLOTS, ) ).toThrow(); }); it("requires negotiated routed flags and rejects unknown or tagged security records", () => { const flags = { ...getMemoryProtocolFlags(), connectionAuth: true, routedAuthV1: true, }; expect(routedFlags(flags).length).toBeLessThan(2048); // Either cell representation passes: client and toolshed agree on it at // the toolshed's handshake, not here. for (const modernCellRep of [false, true]) { expect(routedFlags({ ...flags, modernCellRep }).length).toBeGreaterThan( 0, ); } for ( const invalid of [ { ...flags, connectionAuth: false }, { ...flags, routedAuthV1: false }, { ...flags, modernCellRep: "true" }, { ...flags, arbitraryFutureAuthority: 42 }, { ...flags, "/tag": true }, ] ) expect(() => routedFlags(invalid)).toThrow(); }); it("refuses an expanded payload when its raw fallback cannot fit the wire bound", () => { const payload = `fvj1:${ JSON.stringify({ type: "transact", requestId: "large", space, value: "a".repeat(9 * 1024 * 1024), }) }`; expect(() => encodeRoutedFrame(payload, SLOTS)).toThrow(); }); it("decodes with the platform gzip exactly as the reference decoder does", async () => { const payload = `fvj1:${ JSON.stringify({ type: "session/effect", space, sessionId: "s1", effect: { upserts: Array.from({ length: 200 }, (_, i) => ({ id: `of:${i}`, doc: { text: `entry ${i} `.repeat(20) }, })), }, }) }`; const seed = encodeRoutedFrame(payload, null); assert(seed instanceof Uint8Array); const outcome = async (decode: () => unknown) => { try { return { ok: ((await decode()) as { payload: string }).payload }; } catch (error) { expect(error).toBeInstanceOf(Error); return { refused: true }; } }; const both = async (bytes: Uint8Array, compression = true) => { const reference = await outcome(() => decodeRoutedFrame(bytes, compression, null) ); const platform = await outcome(() => decodeRoutedFrameAsync(bytes, compression, null) ); expect(platform).toEqual(reference); return platform; }; expect(await both(seed)).toEqual({ ok: payload }); // Compression not negotiated, truncated, trailing bytes, a second member. expect(await both(seed, false)).toEqual({ refused: true }); expect(await both(seed.subarray(0, seed.length - 1))).toEqual({ refused: true, }); expect(await both(new Uint8Array([...seed, 0, 0, 0, 0]))).toEqual({ refused: true, }); expect( await both( new Uint8Array([...seed, ...gzipSync(new TextEncoder().encode("x"))]), ), ).toEqual({ refused: true }); // The same two, ending in the declared size, so that the trailer check // is not what refuses them: the platform decoder refuses data after the // member. const expanded = new TextEncoder().encode(payload).length; const size = new Uint8Array(4); new DataView(size.buffer).setUint32(0, expanded, true); expect(await both(new Uint8Array([...seed, 1, 2, 3, 4, ...size]))) .toEqual({ refused: true }); expect( await both( new Uint8Array([...seed, ...gzipSync(new Uint8Array(expanded))]), ), ).toEqual({ refused: true }); // A declared size one more or one less than the content. for (const delta of [1, -1]) { const lying = seed.slice(); const view = new DataView(lying.buffer); view.setUint32(5, view.getUint32(5) + delta); expect(await both(lying)).toEqual({ refused: true }); } for (let i = 0; i < 600; i++) { const bytes = seed.slice(); bytes[(i * 7) % bytes.length] ^= (i % 255) + 1; await both(bytes); } // Text frames take the reference path. expect(await both(new TextEncoder().encode(payload), false)).toEqual({ refused: true, }); expect((await decodeRoutedFrameAsync(payload, true, null)).payload).toBe( payload, ); }); it("compresses a routed message only when the envelope is smaller and within the receiver's bounds", async () => { const message = (value: string) => `fvj1:${JSON.stringify({ type: "session/effect", space, value })}`; const encode = (payload: string) => compressRoutedPayload( payload, new TextEncoder().encode(payload), space, ); // Under 1 KiB, and past the 32x expansion bound: text. const small = message("x".repeat(900)); expect(encode(small)).toBe(small); const repetitive = message("a".repeat(1024 * 1024)); expect(encode(repetitive)).toBe(repetitive); // Ordinary JSON: an envelope that both decoders expand to the payload, // with the frame's space as the hint. const ordinary = message( Array.from({ length: 2000 }, (_, i) => `word${i % 300}`).join(" "), ); const envelope = encode(ordinary); assert(envelope instanceof Uint8Array); expect(envelope.length).toBeLessThan(ordinary.length); expect(decodeRoutedFrame(envelope, true, null).payload).toBe(ordinary); expect((await decodeRoutedFrameAsync(envelope, true, null)).payload).toBe( ordinary, ); // Past the expanded bound the text is returned, for the caller's raw bound. const huge = message( Array.from( { length: ROUTED_EXPANDED_LIMIT / 5 }, (_, i) => `w${i % 9000}`, ).join(" "), ); expect(new TextEncoder().encode(huge).length).toBeGreaterThan( ROUTED_EXPANDED_LIMIT, ); expect(encode(huge)).toBe(huge); }); it("mutation exercises binary and JSON parsers without parser panics", () => { const seed = envelope( `fvj1:${ JSON.stringify({ type: "session.open", requestId: "r1", space, principal: space, session: {}, }) }`, ); for (let i = 0; i < 3000; i++) { const bytes = seed.slice(); bytes[i % bytes.length] ^= (i % 255) + 1; try { decodeRoutedFrame(bytes, true, SLOTS); } catch (error) { expect(error).toBeInstanceOf(Error); } try { parseRoutedJson(new TextDecoder().decode(bytes), SLOTS); } catch (error) { expect(error).toBeInstanceOf(Error); } } }); });