From 75a6ec08699bf82450818e2a95dd897b261189c0 Mon Sep 17 00:00:00 2001 From: Xuepoo Date: Sun, 27 Sep 2026 20:31:59 +0800 Subject: [PATCH] [CTX-0082] fix(protocol): close live response decoding and prove fail-closed continuation (#138) - DEV-006: replace permissive decodeResponse with a closed exact-record decoder (single JSONL line, key allowlist, exclusive result/error, validated ErrorCategory, bounded code/message/details, duplicate-key rejection via shared src/json-guard.ts). - DEV-007: live requests fail closed before any socket write when a logical request exceeds one 256 KiB frame; oversized sends stay unavailable until the core continuation contract lands. - DEV-001: hermetic subprocess loopback test for bin/bitty-devtools.ts (inspect dials once; --help and malformed args never dial). --- src/campaign.ts | 75 +----------- src/client.ts | 10 +- src/json-guard.ts | 85 ++++++++++++++ src/protocol-boundary.ts | 10 +- src/protocol.ts | 189 +++++++++++++++++++++++++++--- src/transport.ts | 23 ++++ tests/cli.test.ts | 64 ++++++++++ tests/client.test.ts | 86 +++++++++++++- tests/helpers/fake-live-socket.ts | 8 ++ tests/platform-contract.test.ts | 3 +- tests/protocol.test.ts | 159 +++++++++++++++++++++++++ 11 files changed, 609 insertions(+), 103 deletions(-) create mode 100644 src/json-guard.ts diff --git a/src/campaign.ts b/src/campaign.ts index 199c3b1..be61c96 100644 --- a/src/campaign.ts +++ b/src/campaign.ts @@ -25,6 +25,7 @@ import { DIR_MODE } from "./auth.js"; import { truncateToBytes } from "./bounds.js"; +import { assertUniqueJsonObjectKeys } from "./json-guard.js"; import { redactSensitiveText, sanitizeTerminalOutput } from "./redaction.js"; function isAbsoluteSocketPath(socketPath: string): boolean { @@ -377,78 +378,6 @@ export function validateEnvelopeShape(value: unknown): string[] { return [...new Set(problems)].slice(0, 8).map(safeReportText); } -/** - * Reject a ctl stdout envelope that repeats an object key. `JSON.parse` keeps - * the last occurrence, so a duplicate key lets a hostile target show one value - * to the operator and hand a different one to every later consumer. - * Single-line JSONL only: `parseCtlEnvelopeShape` rejects embedded newlines - * before this runs. - */ -function assertCtlEnvelopeUniqueKeys(raw: string): void { - const stack: Array< - { kind: "object"; keys: Set } | { kind: "array" } - > = []; - for (let index = 0; index < raw.length; index += 1) { - const character = raw[index]; - if (character === "{") { - stack.push({ kind: "object", keys: new Set() }); - continue; - } - if (character === "[") { - stack.push({ kind: "array" }); - continue; - } - if (character === "}" || character === "]") { - const expected = character === "}" ? "object" : "array"; - const current = stack.pop(); - if (current?.kind !== expected) { - throw new SyntaxError("JSON contains mismatched structure"); - } - continue; - } - if (character !== '"') continue; - let end = index + 1; - let escaped = false; - for (; end < raw.length; end += 1) { - const code = raw.charCodeAt(end); - if (escaped) { - escaped = false; - continue; - } - if (raw[end] === "\\") { - escaped = true; - continue; - } - if (raw[end] === '"') break; - if (code < 0x20) { - throw new SyntaxError("JSON string contains a control character"); - } - } - if (end >= raw.length) { - throw new SyntaxError("JSON contains an unterminated string"); - } - const current = stack.at(-1); - if (current?.kind === "object") { - let next = end + 1; - while (next < raw.length && /\s/u.test(raw[next] ?? "")) next += 1; - if (raw[next] === ":") { - const parsedKey: unknown = JSON.parse(raw.slice(index, end + 1)); - if (typeof parsedKey !== "string") { - throw new SyntaxError("JSON object key is not a string"); - } - if (current.keys.has(parsedKey)) { - throw new SyntaxError("JSON contains a duplicate object key"); - } - current.keys.add(parsedKey); - } - } - index = end; - } - if (stack.length > 0) { - throw new SyntaxError("JSON contains unclosed structure"); - } -} - function parseCtlEnvelopeShape(stdout: string): CtlEnvelope { if (utf8Bytes(stdout) > MAX_CAMPAIGN_OUTPUT_BYTES) { throw new CampaignError( @@ -470,7 +399,7 @@ function parseCtlEnvelopeShape(stdout: string): CtlEnvelope { } let parsed: unknown; try { - assertCtlEnvelopeUniqueKeys(line); + assertUniqueJsonObjectKeys(line); parsed = JSON.parse(line); } catch { throw new CampaignError("InvalidJson", "ctl stdout is not valid JSON"); diff --git a/src/client.ts b/src/client.ts index 7af76d6..e204d7f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -621,15 +621,9 @@ export class DevtoolsClient { throw new TransportError("TransportClosed", "request cancelled"); } transport.getRateLimiter().check(nowMs); - const frames = transport.encodeRequest(req); - if (frames.length !== 1) { - throw new TransportError( - "FrameTooLarge", - "live request requires a server continuation contract", - ); - } + const frame = transport.encodeSingleLiveRequest(req); const raw = await live.requestResponse( - frames[0]!.slice(4), + frame.slice(4), nowMs, req.id, signal, diff --git a/src/json-guard.ts b/src/json-guard.ts new file mode 100644 index 0000000..b86b0da --- /dev/null +++ b/src/json-guard.ts @@ -0,0 +1,85 @@ +/** + * Shared structural guards for untrusted single-record JSON input. + * + * `JSON.parse` keeps the last occurrence of a duplicate object key, so a + * hostile record can show one value to a human reader and hand a different + * one to every later consumer. This scanner rejects a duplicate object key + * (and malformed structure) before parsing, without building a second object + * graph. It is the single implementation used by the campaign ctl-envelope + * validator and the debug-protocol response decoder. + * + * Callers pass a single JSONL line; embedded newlines are rejected by the + * callers before this runs. + */ + +export class DuplicateJsonKeyError extends SyntaxError { + constructor(public readonly key: string) { + super("JSON contains a duplicate object key"); + this.name = "DuplicateJsonKeyError"; + } +} + +export function assertUniqueJsonObjectKeys(raw: string): void { + const stack: Array< + { kind: "object"; keys: Set } | { kind: "array" } + > = []; + for (let index = 0; index < raw.length; index += 1) { + const character = raw[index]; + if (character === "{") { + stack.push({ kind: "object", keys: new Set() }); + continue; + } + if (character === "[") { + stack.push({ kind: "array" }); + continue; + } + if (character === "}" || character === "]") { + const expected = character === "}" ? "object" : "array"; + const current = stack.pop(); + if (current?.kind !== expected) { + throw new SyntaxError("JSON contains mismatched structure"); + } + continue; + } + if (character !== '"') continue; + let end = index + 1; + let escaped = false; + for (; end < raw.length; end += 1) { + const code = raw.charCodeAt(end); + if (escaped) { + escaped = false; + continue; + } + if (raw[end] === "\\") { + escaped = true; + continue; + } + if (raw[end] === '"') break; + if (code < 0x20) { + throw new SyntaxError("JSON string contains a control character"); + } + } + if (end >= raw.length) { + throw new SyntaxError("JSON contains an unterminated string"); + } + const current = stack.at(-1); + if (current?.kind === "object") { + let next = end + 1; + while (next < raw.length && /\s/u.test(raw[next] ?? "")) next += 1; + if (raw[next] === ":") { + const parsedKey: unknown = JSON.parse(raw.slice(index, end + 1)); + if (typeof parsedKey !== "string") { + throw new SyntaxError("JSON object key is not a string"); + } + if (current.keys.has(parsedKey)) { + throw new DuplicateJsonKeyError(parsedKey); + } + current.keys.add(parsedKey); + } + } + index = end; + } + if (stack.length > 0) { + throw new SyntaxError("JSON contains unclosed structure"); + } +} diff --git a/src/protocol-boundary.ts b/src/protocol-boundary.ts index 7212c41..9c8e7f1 100644 --- a/src/protocol-boundary.ts +++ b/src/protocol-boundary.ts @@ -1,10 +1,12 @@ /** * CTX-0080 live request admission boundary. * - * CTX-0079 owns src/protocol.ts decoding, duplicate-key handling, payload - * bounds, and redaction. This module owns only the live request admission - * checks added by CTX-0080: supported protocol version, registered method, - * exact scope, and the shared protocol encoder's size/shape validation. + * src/protocol.ts owns debug-protocol response decoding, duplicate-key + * rejection (shared scanner in src/json-guard.ts), payload bounds, and error + * shaping; it does not own redaction. This module owns only the live request + * admission checks added by CTX-0080: supported protocol version, registered + * method, exact scope, and the shared protocol encoder's size/shape + * validation. */ import { diff --git a/src/protocol.ts b/src/protocol.ts index 9947735..5b1c17d 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -11,13 +11,31 @@ * Windows named pipe); this file owns only framing and schema validation. */ -import { BOUNDS, assertBounded, assertStringBounded } from "./bounds.js"; +import { + BOUNDS, + assertBounded, + assertStringBounded, + truncateToChars, +} from "./bounds.js"; +import { + DuplicateJsonKeyError, + assertUniqueJsonObjectKeys, +} from "./json-guard.js"; export const PROTOCOL_VERSION = "1.0" as const; export const SUPPORTED_VERSIONS: readonly string[] = [ PROTOCOL_VERSION, ] as const; +const ERROR_CATEGORIES: readonly ErrorCategory[] = [ + "usage", + "capability", + "scope", + "budget", + "generation", + "transport", +] as const; + export type DebugScope = "debug.inspect" | "debug.trace" | "debug.control"; export const DEBUG_SCOPES: readonly DebugScope[] = [ @@ -100,10 +118,35 @@ export function encodeRequest(frame: RequestFrame): string { export function decodeResponse(raw: string): ResponseFrame { validateFrameBytes(raw); - const line = raw.trim().split("\n")[0] ?? ""; + const lines = raw.split("\n").filter((line) => line.trim().length > 0); + if (lines.length !== 1) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidJson", + message: "response must be exactly one non-empty JSONL line", + }); + } + // Reject a repeated object key before `JSON.parse`, which is last-wins and + // would otherwise let a hostile server mask one value behind another. + try { + assertUniqueJsonObjectKeys(lines[0] as string); + } catch (error) { + if (error instanceof DuplicateJsonKeyError) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "DuplicateField", + message: `response repeats field '${error.key}'`, + }); + } + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidJson", + message: "response is not well-formed JSON", + }); + } let parsed: unknown; try { - parsed = JSON.parse(line); + parsed = JSON.parse(lines[0] as string); } catch { throw new ProtocolErrorImpl({ category: "usage", @@ -111,7 +154,25 @@ export function decodeResponse(raw: string): ResponseFrame { message: "response is not valid JSON", }); } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidJson", + message: "response must be a JSON object", + }); + } const obj = parsed as Record; + const hasResult = Object.hasOwn(obj, "result"); + const hasError = Object.hasOwn(obj, "error"); + for (const key of Object.keys(obj)) { + if (!ALLOWED_RESPONSE_KEYS.has(key)) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "UnknownField", + message: `response field '${key}' is not allowed`, + }); + } + } if (obj["jsonrpc"] !== "2.0") { throw new ProtocolErrorImpl({ category: "usage", @@ -119,11 +180,11 @@ export function decodeResponse(raw: string): ResponseFrame { message: "jsonrpc must be 2.0", }); } - if (typeof obj["id"] !== "number") { + if (!Number.isSafeInteger(obj["id"]) || (obj["id"] as number) < 0) { throw new ProtocolErrorImpl({ category: "usage", - code: "MissingId", - message: "response id must be number", + code: "InvalidId", + message: "response id must be a nonnegative safe integer", }); } if ( @@ -136,23 +197,121 @@ export function decodeResponse(raw: string): ResponseFrame { message: "invalid or unsupported version", }); } - if (obj["error"] !== undefined) { - const err = obj["error"] as ProtocolError; - if (typeof err.code !== "string" || typeof err.category !== "string") { + if (hasResult === hasError) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidResult", + message: "response must carry exactly one of result or error", + }); + } + if (hasError) { + return { + jsonrpc: "2.0", + id: obj["id"] as number, + error: decodeErrorField(obj["error"]), + version: obj["version"] as string, + }; + } + return { + jsonrpc: "2.0", + id: obj["id"] as number, + result: obj["result"], + version: obj["version"] as string, + }; +} + +const ALLOWED_RESPONSE_KEYS = new Set([ + "jsonrpc", + "id", + "result", + "error", + "version", +]); + +function decodeErrorField(value: unknown): ProtocolError { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidErrorShape", + message: "error must be a JSON object", + }); + } + const err = value as Record; + for (const key of Object.keys(err)) { + if (!ALLOWED_ERROR_KEYS.has(key)) { throw new ProtocolErrorImpl({ category: "usage", - code: "InvalidErrorShape", - message: "error shape invalid", + code: "UnknownField", + message: `error field '${key}' is not allowed`, }); } - // Never echo unbounded bytes in error - if (err.message && err.message.length > 512) { - err.message = err.message.slice(0, 512); + } + const category = err["category"]; + if ( + typeof category !== "string" || + !(ERROR_CATEGORIES as readonly string[]).includes(category) + ) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidErrorShape", + message: "error category is not a known category", + }); + } + const code = err["code"]; + if ( + typeof code !== "string" || + code.length === 0 || + code.length > MAX_ERROR_CODE_CHARS + ) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidErrorShape", + message: `error code must be a string of 1..${MAX_ERROR_CODE_CHARS} chars`, + }); + } + const message = err["message"]; + if (typeof message !== "string") { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidErrorShape", + message: "error message must be a string", + }); + } + const error: ProtocolError = { + category: category as ErrorCategory, + code, + message: truncateToChars(message, MAX_ERROR_MESSAGE_CHARS).text, + }; + if (err["details"] !== undefined) { + // The RFC forbids echoing unbounded untrusted bytes: reject rather than + // truncate opaque details so a hostile server can never smuggle a large + // payload (or an over-budget record) through the error channel. + let serialized: string | undefined; + try { + serialized = JSON.stringify(err["details"]); + } catch { + serialized = undefined; } + if ( + serialized === undefined || + new TextEncoder().encode(serialized).length > MAX_ERROR_DETAILS_BYTES + ) { + throw new ProtocolErrorImpl({ + category: "usage", + code: "InvalidErrorShape", + message: `error details must serialize within ${MAX_ERROR_DETAILS_BYTES} bytes`, + }); + } + error.details = err["details"]; } - return parsed as ResponseFrame; + return error; } +const ALLOWED_ERROR_KEYS = new Set(["category", "code", "message", "details"]); +const MAX_ERROR_MESSAGE_CHARS = 512; +const MAX_ERROR_CODE_CHARS = 128; +const MAX_ERROR_DETAILS_BYTES = 4 * 1024; + export function chunkText( text: string, chunkBytes: number = BOUNDS.CHUNK_BYTES, diff --git a/src/transport.ts b/src/transport.ts index 36f05da..cb96afe 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -619,6 +619,29 @@ export class IpcTransport { return chunks; } + /** + * Encode exactly one physical frame for a live request, or fail closed. + * + * `encodeRequest` splits an oversized logical request into RC-10 chunks for + * the headless streaming transport. The live path must never use that split: + * `bitty` reads one complete frame per exchange and defines no inbound + * request continuation identity, so a fragmented request would become + * several independent exchanges with partial side effects. Until the server + * continuation contract lands in `bitty`, a request above + * {@link MAX_FRAME_BYTES} is rejected with `FrameTooLarge` before any byte + * reaches the socket. + */ + encodeSingleLiveRequest(req: IpcRequest): Uint8Array { + const frames = this.encodeRequest(req); + if (frames.length !== 1) { + throw new TransportError( + "FrameTooLarge", + "live request requires a server continuation contract", + ); + } + return frames[0]!; + } + sendRequest(req: IpcRequest, nowMs: number): void { if (this.stub.isClosed()) { this.disconnect(); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 2b2f8a2..6d2460e 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; import { CliConfigError, CliUsageError, @@ -34,6 +35,8 @@ import { import { TracingError } from "../src/tracing.js"; import { BoundError } from "../src/bounds.js"; import { DevtoolsClient } from "../src/client.js"; +import { isLiveSocketSupported } from "../src/ipc-socket.js"; +import { createScratchLoopback } from "./helpers/fake-live-socket.js"; type Harness = { out: string[]; @@ -520,6 +523,67 @@ describe("runCliLive over a loopback socket (CTX-0036)", () => { }); }); +describe("installed bin/bitty-devtools.ts subprocess over a loopback socket (CTX-0082)", () => { + test("inspect dials the socket; --help and malformed args never dial", async () => { + if (!isLiveSocketSupported()) return; + const responsePayload = new TextEncoder().encode( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { plugins: [pluginPayload()] }, + version: "1.0", + }), + ); + const loopback = createScratchLoopback({ + prefix: "bitty-devtools-cli-ctx0082-bin", + responsePayload, + timeoutMs: 1000, + }); + const binPath = resolve(import.meta.dir, "..", "bin", "bitty-devtools.ts"); + const run = async ( + args: string[], + ): Promise<{ + exitCode: number; + stdout: string; + stderr: string; + connections: number; + }> => { + const proc = Bun.spawn([process.execPath, binPath, ...args], { + cwd: resolve(import.meta.dir, ".."), + env: { ...process.env, BITTY_SOCKET: loopback.socketPath }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, stdout, stderr, connections: loopback.connections() }; + }; + try { + const inspect = await run(["inspect", "--plugins"]); + expect(inspect.connections).toBe(1); + expect(inspect.exitCode).toBe(EXIT_OK); + expect(inspect.stderr).toBe(""); + expect(inspect.stdout).toContain("plugin-a"); + expect(inspect.stdout).toContain("Activated"); + + const help = await run(["--help"]); + expect(help.connections).toBe(1); + expect(help.exitCode).toBe(EXIT_OK); + expect(help.stdout).toContain("Usage:"); + + const malformed = await run(["inspect"]); + expect(malformed.connections).toBe(1); + expect(malformed.exitCode).toBe(EXIT_USAGE); + expect(malformed.stderr).toContain("Usage:"); + } finally { + loopback.stop(); + } + }); +}); + describe("exitCodeForError", () => { test("maps usage to 2, config to its code, and unknown errors to 1", () => { expect(exitCodeForError(new CliUsageError("bad"))).toBe(EXIT_USAGE); diff --git a/tests/client.test.ts b/tests/client.test.ts index 6512820..9ead817 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { DevtoolsClient } from "../src/client.js"; -import { IpcTransport } from "../src/transport.js"; +import { IpcTransport, TransportError } from "../src/transport.js"; import { peerCredentials } from "../src/auth.js"; import { createScratchLoopback } from "./helpers/fake-live-socket.js"; import { isLiveSocketSupported } from "../src/ipc-socket.js"; @@ -212,6 +212,88 @@ describe("DevtoolsClient inspection live IPC wiring", () => { loopback.stop(); } }); + + test("oversized live request fails closed before any socket write", async () => { + if (!isLiveSocketSupported()) return; + const responsePayload = new TextEncoder().encode( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: { plugins: [] }, + version: "1.0", + }), + ); + const loopback = createScratchLoopback({ + prefix: "bitty-devtools-client-ctx0082", + responsePayload, + timeoutMs: 1000, + }); + type WriteSocket = { write(data: Uint8Array): number }; + const runtime = Bun as unknown as { + connect(options: { + socket: { open?: (socket: WriteSocket) => void }; + }): Promise; + }; + const writes: number[] = []; + const realConnect = runtime.connect.bind(runtime); + const connectSpy = spyOn(runtime, "connect").mockImplementation( + (options) => { + const originalOpen = options.socket.open; + options.socket.open = (socket: WriteSocket) => { + const realWrite = socket.write.bind(socket); + socket.write = (data: Uint8Array) => { + writes.push(data.length); + return realWrite(data); + }; + originalOpen?.(socket); + }; + return realConnect(options); + }, + ); + try { + const c = new DevtoolsClient(); + await c.connectLiveSocket( + loopback.runtimeUid, + peerCredentials(loopback.runtimeUid, loopback.runtimeUid, 1), + undefined, + undefined, + loopback.socketPath, + ); + c.grantScope("debug.inspect"); + await c.requestLive( + { + id: 1, + method: "bitty.debug/listPlugins", + params: {}, + version: "1.0", + }, + 0, + ); + expect(writes.length).toBe(1); + let caught: unknown = null; + try { + await c.requestLive( + { + id: 2, + method: "bitty.debug/listPlugins", + params: { x: "a".repeat(600 * 1024) }, + version: "1.0", + }, + 0, + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(TransportError); + expect((caught as TransportError).code).toBe("FrameTooLarge"); + expect((caught as Error).message).toContain("continuation contract"); + expect(writes.length).toBe(1); + c.disconnect(); + } finally { + connectSpy.mockRestore(); + loopback.stop(); + } + }); }); test("failed live connect fails closed and never falls back to mock", () => { diff --git a/tests/helpers/fake-live-socket.ts b/tests/helpers/fake-live-socket.ts index f33f0b3..9847965 100644 --- a/tests/helpers/fake-live-socket.ts +++ b/tests/helpers/fake-live-socket.ts @@ -146,6 +146,8 @@ export type ScratchLoopback = { socketPath: string; runtimeUid: number; timeoutMs: number; + /** Number of accepted connections; proves whether a dial reached the server. */ + connections: () => number; stop: () => void; }; @@ -180,11 +182,13 @@ export function createScratchLoopback(options: { const wire = new Uint8Array(4 + options.responsePayload.length); new DataView(wire.buffer).setUint32(0, options.responsePayload.length, false); wire.set(options.responsePayload, 4); + let acceptedConnections = 0; const server = ( Bun as unknown as { listen(options: { unix: string; socket: { + open(sock: { write(data: Uint8Array): void }): void; data(sock: { write(data: Uint8Array): void }, data: Uint8Array): void; error(): void; }; @@ -193,6 +197,9 @@ export function createScratchLoopback(options: { ).listen({ unix: socketPath, socket: { + open() { + acceptedConnections += 1; + }, data(sock) { sock.write(wire); }, @@ -205,6 +212,7 @@ export function createScratchLoopback(options: { socketPath, runtimeUid, timeoutMs, + connections: () => acceptedConnections, stop: () => { try { server.stop(true); diff --git a/tests/platform-contract.test.ts b/tests/platform-contract.test.ts index 30031c0..91a2a5d 100644 --- a/tests/platform-contract.test.ts +++ b/tests/platform-contract.test.ts @@ -131,7 +131,8 @@ describe("offline platform contract", () => { test("protocol ownership is split at the CTX-0080 boundary", () => { const boundary = repositoryFile("src/protocol-boundary.ts"); - expect(boundary).toContain("CTX-0079 owns src/protocol.ts"); + expect(boundary).toContain("src/protocol.ts owns debug-protocol"); + expect(boundary).toContain("src/json-guard.ts"); expect(boundary).toContain("CTX-0080"); expect(boundary).toContain("validateLiveRequest"); }); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index 4e0550f..c8ddae3 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -48,6 +48,165 @@ describe("protocol versioned framing", () => { expect(() => decodeResponse("not json")).toThrow("not valid JSON"); }); + test("closed decoder requires exactly one non-empty JSONL line", () => { + const one = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: {}, + version: "1.0", + }); + expect(() => decodeResponse(`${one}\n${one}`)).toThrow( + "exactly one non-empty JSONL line", + ); + // Surrounding blank lines around one JSON line are valid JSONL framing. + expect(decodeResponse(`\n${one}\n`).id).toBe(1); + expect(decodeResponse(`${one}\n`).id).toBe(1); + }); + + test("closed decoder rejects extra top-level and error fields", () => { + const base = { jsonrpc: "2.0", id: 1, result: {}, version: "1.0" }; + expect(() => + decodeResponse(JSON.stringify({ ...base, injected: true })), + ).toThrow("field 'injected' is not allowed"); + expect(() => + decodeResponse( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { + category: "usage", + code: "Bad", + message: "m", + injected: true, + }, + version: "1.0", + }), + ), + ).toThrow("error field 'injected' is not allowed"); + }); + + test("closed decoder enforces result xor error", () => { + const err = { category: "usage" as const, code: "Bad", message: "m" }; + expect(() => + decodeResponse( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: {}, + error: err, + version: "1.0", + }), + ), + ).toThrow("exactly one of result or error"); + expect(() => + decodeResponse(JSON.stringify({ jsonrpc: "2.0", id: 1, version: "1.0" })), + ).toThrow("exactly one of result or error"); + expect( + decodeResponse( + JSON.stringify({ jsonrpc: "2.0", id: 1, error: err, version: "1.0" }), + ).error, + ).toEqual(err); + expect( + decodeResponse( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + result: null, + version: "1.0", + }), + ).result, + ).toBeNull(); + }); + + test("closed decoder validates error category, code, and message", () => { + const wrap = (error: unknown): string => + JSON.stringify({ jsonrpc: "2.0", id: 1, error, version: "1.0" }); + expect(() => + decodeResponse(wrap({ category: "made-up", code: "Bad", message: "m" })), + ).toThrow("category is not a known category"); + expect(() => + decodeResponse(wrap({ category: "usage", code: "", message: "m" })), + ).toThrow("error code must be a string"); + expect(() => + decodeResponse( + wrap({ category: "usage", code: "C".repeat(129), message: "m" }), + ), + ).toThrow("error code must be a string"); + expect(() => + decodeResponse(wrap({ category: "usage", code: "Bad", message: 7 })), + ).toThrow("error message must be a string"); + const long = decodeResponse( + wrap({ category: "usage", code: "Bad", message: "x".repeat(600) }), + ); + expect(long.error?.message.length).toBe(512); + // Every supported category round-trips. + for (const category of [ + "usage", + "capability", + "scope", + "budget", + "generation", + "transport", + ] as const) { + expect( + decodeResponse(wrap({ category, code: "Bad", message: "m" })).error + ?.category, + ).toBe(category); + } + }); + + test("closed decoder rejects duplicate object keys", () => { + // JSON.parse is last-wins, so a repeated key can mask one value behind + // another; the decoder must refuse it before parsing. + expect(() => + decodeResponse( + '{"jsonrpc":"2.0","id":1,"result":{},"result":null,"version":"1.0"}', + ), + ).toThrow("repeats field 'result'"); + expect(() => + decodeResponse( + '{"jsonrpc":"2.0","id":1,"error":{"category":"usage","code":"Bad","message":"a","message":"b"},"version":"1.0"}', + ), + ).toThrow("repeats field 'message'"); + expect(() => + decodeResponse( + '{"jsonrpc":"2.0","id":1,"result":{"a":1,"a":2},"version":"1.0"}', + ), + ).toThrow("repeats field 'a'"); + }); + + test("closed decoder bounds error details to 4 KiB", () => { + const withDetails = (details: unknown): string => + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + error: { category: "usage", code: "Bad", message: "m", details }, + version: "1.0", + }); + const atBound = decodeResponse(withDetails({ note: "x".repeat(4085) })); + expect(atBound.error?.details).toEqual({ note: "x".repeat(4085) }); + expect(() => + decodeResponse(withDetails({ note: "x".repeat(4086) })), + ).toThrow("details must serialize within 4096 bytes"); + }); + + test("closed decoder rejects non-object JSON without throwing TypeError", () => { + expect(() => decodeResponse("null")).toThrow("must be a JSON object"); + expect(() => decodeResponse("[]")).toThrow("must be a JSON object"); + expect(() => decodeResponse('"str"')).toThrow("must be a JSON object"); + expect(() => decodeResponse("42")).toThrow("must be a JSON object"); + }); + + test("closed decoder requires a nonnegative safe-integer id", () => { + const wrap = (id: unknown): string => + JSON.stringify({ jsonrpc: "2.0", id, result: {}, version: "1.0" }); + for (const id of [undefined, "1", 1.5, -1, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => decodeResponse(wrap(id))).toThrow( + "id must be a nonnegative safe integer", + ); + } + }); + test("scope matrix", () => { expect( isValidMethodForScope("bitty.debug/listPlugins", "debug.inspect"),