From bd90e0c983dcece5e1b1b3773c0767dc14402a84 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Fri, 14 Aug 2026 14:06:00 +0900 Subject: [PATCH] fix: keep Browser RPC collaborator input and output inside the contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RPC-RR-02. The server-stream path captured the generation fence outside its protected boundary and raceWithin invoked clock.sleep outside a promise boundary, so a synchronous throw from either escaped the Result contract and skipped the listener and timer release. Both now run inside the boundary, and release moved to finally. RPC-RR-03. The runtime snapshotted its transports only after validating the caller's raw objects, which ran their accessors first. It now decodes the registry from own data descriptors before anything reads it — refusing an accessor without invoking it and rejecting extra, inherited and symbol-keyed fields — and validates that snapshot. Every installed binding registry is a read facade over a private store instead of a frozen Map whose set, delete and clear still worked. RPC-RR-04. Transport results and stream frames are decoded per union variant from own data descriptors into new frozen values. A throwing getter, an inherited or extra field, a symbol key, an unknown failure code and an out-of-range retryAfterMs all close as protocol failures instead of escaping. Co-Authored-By: Claude Opus 5 (1M context) --- .../browser-rpc/browser-rpc-runtime.ts | 294 +++++++++++++--- src/contracts/browser-rpc.ts | 25 +- .../browser-rpc-remediation.test.ts | 315 ++++++++++++++++++ 3 files changed, 575 insertions(+), 59 deletions(-) create mode 100644 tests/unit/browser-rpc/browser-rpc-remediation.test.ts diff --git a/src/adapters/browser-rpc/browser-rpc-runtime.ts b/src/adapters/browser-rpc/browser-rpc-runtime.ts index cc9aac4..a5d6d86 100644 --- a/src/adapters/browser-rpc/browser-rpc-runtime.ts +++ b/src/adapters/browser-rpc/browser-rpc-runtime.ts @@ -1,3 +1,7 @@ +import { + createReadOnlyRegistry, + type ReadOnlyRegistry, +} from "../../contracts/read-only-registry.ts"; import type { BrowserRpcGenerationFence, BrowserRpcServerStreamPort, @@ -107,7 +111,11 @@ export function createBrowserRpcRuntime( const clock = dependencies.clock ?? systemClock; const generationFence = dependencies.generationFence ?? stableGenerationFence; - validateRuntimeDependencies(dependencies); + // RPC-RR-03. Snapshot before validating. Reading the caller's transport + // objects first would run their accessors, letting a hostile getter observe + // validation and then return something else to the runtime. + const installedTransports = snapshotTransports(dependencies.transports); + validateRuntimeDependencies(dependencies, installedTransports); // R-04. Install exact immutable snapshots once. Every later `bind()` reads // the snapshot, never the caller's registry objects, so a post-composition // mutation cannot change replay policy, deadlines, byte ceilings or @@ -119,7 +127,6 @@ export function createBrowserRpcRuntime( mappers: dependencies.mappers, requestEncoders: dependencies.requestEncoders, }); - const installedTransports = snapshotTransports(dependencies.transports); function bind( operationId: string, @@ -457,7 +464,6 @@ async function* executeServerStream( clock: ClockPort, ): AsyncIterable> { const { operation, profile, encoder, transport } = bound; - const generation = generationFence.capture(); const deadlineAt = safeNowOrDeadline(clock, 0) + operation.totalDeadlineMs; const linked = linkedAbortController(context.signal); let iterator: AsyncIterator | null = null; @@ -470,6 +476,23 @@ async function* executeServerStream( observe(dependencies, operation, outcome, 1, messageCount); }; + // RPC-RR-02. The fence is a caller collaborator, so a synchronous throw from + // it is an ordinary failure of this call. Capturing outside the protected + // boundary let that throw escape the stream's Result contract entirely. + const generation = safeCapture(generationFence); + if (generation === FENCE_FAILURE) { + finish("FAILED"); + yield failureResult( + callFailure( + operation, + 0, + "SCOPE_GENERATION_CHANGED", + "RPC_SCOPE_GENERATION_UNAVAILABLE", + ), + ); + return; + } + try { const contextFailure = validateCallContext(operation, context, 0); if (contextFailure) { @@ -711,9 +734,23 @@ async function* executeServerStream( * R-04. Transports are collaborators, not data rows, so only their identity is * snapshotted; the callable itself is captured once and rebound. */ +const TRANSPORT_KEYS: ReadonlySet = new Set([ + "runtimeProfileId", + "providerId", + "protocol", + "rpcKind", + "invokeUnary", + "openServerStream", +]); + +/** + * RPC-RR-03. Decodes the transport registry from own data descriptors only, so + * no accessor on a caller's object is ever invoked, and returns a read facade + * whose backing store cannot be reached by `set`, `delete` or `clear`. + */ function snapshotTransports( source: Readonly>, -): ReadonlyMap { +): ReadOnlyRegistry { const installed = new Map(); if (Object.getOwnPropertySymbols(source).length > 0) { throw new TypeError("Browser RPC transport registry has symbol keys."); @@ -725,28 +762,59 @@ function snapshotTransports( `Browser RPC transport registry entry is not a data property: ${key}`, ); } - const transport = descriptor.value as BrowserRpcTransport; + const transport = descriptor.value as unknown; + if (!transport || typeof transport !== "object") { + throw new TypeError( + `Browser RPC transport registry entry is not an object: ${key}`, + ); + } + if (Object.getOwnPropertySymbols(transport).length > 0) { + throw new TypeError( + `Browser RPC transport row has symbol keys: ${key}`, + ); + } + const row: Record = Object.create(null); + for (const field of Object.getOwnPropertyNames(transport)) { + if (!TRANSPORT_KEYS.has(field)) { + throw new TypeError( + `Browser RPC transport row has an unexpected key: ${key}.${field}`, + ); + } + const fieldDescriptor = Object.getOwnPropertyDescriptor( + transport, + field, + ); + if (!fieldDescriptor || !("value" in fieldDescriptor)) { + throw new TypeError( + `Browser RPC transport row key is not a data property: ${key}.${field}`, + ); + } + row[field] = fieldDescriptor.value; + } + const invokeUnary = row.invokeUnary; + const openServerStream = row.openServerStream; installed.set( key, Object.freeze({ - runtimeProfileId: transport.runtimeProfileId, - providerId: transport.providerId, - protocol: transport.protocol, - rpcKind: transport.rpcKind, - ...(transport.invokeUnary - ? { invokeUnary: transport.invokeUnary.bind(transport) } + runtimeProfileId: row.runtimeProfileId, + providerId: row.providerId, + protocol: row.protocol, + rpcKind: row.rpcKind, + ...(typeof invokeUnary === "function" + ? { invokeUnary: invokeUnary.bind(transport) } : {}), - ...(transport.openServerStream - ? { openServerStream: transport.openServerStream.bind(transport) } + ...(typeof openServerStream === "function" + ? { openServerStream: openServerStream.bind(transport) } : {}), }) as BrowserRpcTransport, ); } - return Object.freeze(installed) as ReadonlyMap; + return createReadOnlyRegistry(installed); } function validateRuntimeDependencies( dependencies: BrowserRpcRuntimeDependencies, + transports: ReadOnlyRegistry, ): void { const runtimeBindings: Record< string, @@ -755,9 +823,7 @@ function validateRuntimeDependencies( "runtimeProfileId" | "providerId" | "protocol" | "rpcKind" > > = Object.create(null); - for (const [profileId, transport] of Object.entries( - dependencies.transports, - )) { + for (const [profileId, transport] of transports.entries()) { if ( profileId !== transport.runtimeProfileId || Object.hasOwn(runtimeBindings, profileId) @@ -782,7 +848,7 @@ function validateRuntimeDependencies( runtimeBindings: Object.freeze(runtimeBindings), }); for (const operation of Object.values(dependencies.operations)) { - if (!dependencies.transports[operation.runtimeProfileId]) { + if (!transports.has(operation.runtimeProfileId)) { throw new TypeError( `Browser RPC transport is missing: ${operation.operationId}`, ); @@ -1072,29 +1138,145 @@ function safelyMatches( } } +/** + * RPC-RR-04. A transport value is decoded, never adopted. + * + * `in` and a direct property read run accessors, so a throwing getter escapes + * the Result contract and an inherited or extra field passes unseen. Each + * variant is decoded from own data descriptors into a new frozen value, so a + * transport that mutates its own object after returning it cannot change what + * the runtime already admitted. + */ +function ownDataValue(source: unknown, key: string): unknown { + if (source === null || typeof source !== "object") return undefined; + try { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !("value" in descriptor)) return undefined; + return descriptor.value; + } catch { + return undefined; + } +} + +function exactOwnKeys( + source: unknown, + allowed: ReadonlySet, +): boolean { + if (source === null || typeof source !== "object") return false; + try { + if (Object.getOwnPropertySymbols(source).length > 0) return false; + for (const key of Object.getOwnPropertyNames(source)) { + if (!allowed.has(key)) return false; + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (!descriptor || !("value" in descriptor)) return false; + } + return true; + } catch { + return false; + } +} + +const UNARY_OK_KEYS: ReadonlySet = new Set([ + "ok", + "message", + "encodedBytes", +]); +const UNARY_FAILED_KEYS: ReadonlySet = new Set(["ok", "failure"]); +const FRAME_MESSAGE_KEYS: ReadonlySet = new Set([ + "kind", + "message", + "encodedBytes", +]); +const FRAME_TERMINAL_OK_KEYS: ReadonlySet = new Set(["kind", "ok"]); +const FRAME_TERMINAL_FAILED_KEYS: ReadonlySet = new Set([ + "kind", + "ok", + "failure", +]); +const TRANSPORT_FAILURE_KEYS: ReadonlySet = new Set([ + "code", + "retryAfterMs", +]); + function validateUnaryTransportResult( value: unknown, ): BrowserRpcUnaryTransportResult | null { - if (!value || typeof value !== "object" || !("ok" in value)) return null; - const candidate = value as BrowserRpcUnaryTransportResult; - if (candidate.ok) { - return validEncodedByteCount(candidate.encodedBytes, Number.MAX_SAFE_INTEGER) - ? candidate - : null; + const ok = ownDataValue(value, "ok"); + if (typeof ok !== "boolean") return null; + if (ok) { + if (!exactOwnKeys(value, UNARY_OK_KEYS)) return null; + const encodedBytes = ownDataValue(value, "encodedBytes"); + if (!validEncodedByteCount(encodedBytes, Number.MAX_SAFE_INTEGER)) { + return null; + } + return Object.freeze({ + ok: true as const, + message: ownDataValue(value, "message"), + encodedBytes, + }); } - return validTransportFailure(candidate.failure) ? candidate : null; + if (!exactOwnKeys(value, UNARY_FAILED_KEYS)) return null; + const failure = decodeTransportFailure(ownDataValue(value, "failure")); + return failure === null + ? null + : Object.freeze({ ok: false as const, failure }); } function validateStreamFrame(value: unknown): BrowserRpcStreamFrame | null { - if (!value || typeof value !== "object" || !("kind" in value)) return null; - const frame = value as BrowserRpcStreamFrame; - if (frame.kind === "MESSAGE") { - return validEncodedByteCount(frame.encodedBytes, Number.MAX_SAFE_INTEGER) - ? frame - : null; + const kind = ownDataValue(value, "kind"); + if (kind === "MESSAGE") { + if (!exactOwnKeys(value, FRAME_MESSAGE_KEYS)) return null; + const encodedBytes = ownDataValue(value, "encodedBytes"); + if (!validEncodedByteCount(encodedBytes, Number.MAX_SAFE_INTEGER)) { + return null; + } + return Object.freeze({ + kind: "MESSAGE" as const, + message: ownDataValue(value, "message"), + encodedBytes, + }); } - if (frame.kind !== "TERMINAL" || typeof frame.ok !== "boolean") return null; - return frame.ok || validTransportFailure(frame.failure) ? frame : null; + if (kind !== "TERMINAL") return null; + const ok = ownDataValue(value, "ok"); + if (typeof ok !== "boolean") return null; + if (ok) { + if (!exactOwnKeys(value, FRAME_TERMINAL_OK_KEYS)) return null; + return Object.freeze({ kind: "TERMINAL" as const, ok: true as const }); + } + if (!exactOwnKeys(value, FRAME_TERMINAL_FAILED_KEYS)) return null; + const failure = decodeTransportFailure(ownDataValue(value, "failure")); + return failure === null + ? null + : Object.freeze({ + kind: "TERMINAL" as const, + ok: false as const, + failure, + }); +} + +function decodeTransportFailure( + value: unknown, +): BrowserRpcTransportFailure | null { + if (!exactOwnKeys(value, TRANSPORT_FAILURE_KEYS)) return null; + const code = ownDataValue(value, "code"); + const retryAfterMs = ownDataValue(value, "retryAfterMs"); + if (!TRANSPORT_FAILURE_CODES.has(code as BrowserRpcTransportFailureCode)) { + return null; + } + if (retryAfterMs === undefined) { + return Object.freeze({ code: code as BrowserRpcTransportFailureCode }); + } + if ( + !Number.isSafeInteger(retryAfterMs) || + (retryAfterMs as number) < 0 || + (retryAfterMs as number) > BROWSER_RPC_HARD_LIMITS.maxRetryAfterMs + ) { + return null; + } + return Object.freeze({ + code: code as BrowserRpcTransportFailureCode, + retryAfterMs: retryAfterMs as number, + }); } function validTransportFailure( @@ -1240,8 +1422,12 @@ function failureResult( return Object.freeze({ ok: false, error }); } -function validEncodedByteCount(value: number, maximum: number): boolean { +function validEncodedByteCount( + value: unknown, + maximum: number, +): value is number { return ( + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum @@ -1274,23 +1460,29 @@ async function raceWithin( const timerController = new AbortController(); const onAbort = () => timerController.abort(signal.reason); signal.addEventListener("abort", onAbort, { once: true }); - const workResult = work.then, TimedResult>( - (value) => Object.freeze({ kind: "VALUE", value }), - () => Object.freeze({ kind: "THREW" }), - ); - const timerResult = clock.sleep(milliseconds, timerController.signal).then< - TimedResult, - TimedResult - >( - () => Object.freeze({ kind: "TIMEOUT" }), - () => Object.freeze({ kind: "ABORTED" }), - ); - const selected = await Promise.race([workResult, timerResult]); - timerController.abort("race-complete"); - signal.removeEventListener("abort", onAbort); - return signal.aborted && selected.kind === "VALUE" - ? Object.freeze({ kind: "ABORTED" }) - : selected; + try { + const workResult = work.then, TimedResult>( + (value) => Object.freeze({ kind: "VALUE", value }), + () => Object.freeze({ kind: "THREW" }), + ); + // RPC-RR-02. A clock is an external collaborator: calling `sleep` inside a + // promise boundary turns its synchronous throw into a normalized rejection + // instead of an exception that escapes the Result contract and skips the + // listener and timer release below. + const timerResult = Promise.resolve() + .then(() => clock.sleep(milliseconds, timerController.signal)) + .then, TimedResult>( + () => Object.freeze({ kind: "TIMEOUT" }), + () => Object.freeze({ kind: "ABORTED" }), + ); + const selected = await Promise.race([workResult, timerResult]); + return signal.aborted && selected.kind === "VALUE" + ? Object.freeze({ kind: "ABORTED" }) + : selected; + } finally { + timerController.abort("race-complete"); + signal.removeEventListener("abort", onAbort); + } } function observe( diff --git a/src/contracts/browser-rpc.ts b/src/contracts/browser-rpc.ts index 45b61d8..3e099f6 100644 --- a/src/contracts/browser-rpc.ts +++ b/src/contracts/browser-rpc.ts @@ -1,3 +1,7 @@ +import { + createReadOnlyRegistry, + type ReadOnlyRegistry, +} from "./read-only-registry.ts"; import type { InstalledBoundaryMapper } from "./boundary-mapper.ts"; import type { RuntimeSchemaCodec } from "./schema-registry.ts"; @@ -327,13 +331,18 @@ export function composeBrowserRpcRequestEncoderRegistry( ); } +/** + * RPC-RR-03. Read facades, never `Map`s. `Object.freeze(new Map(...))` leaves + * `set`, `delete` and `clear` working, so an installed registry could still be + * emptied or re-pointed after the snapshot was validated. + */ export type InstalledBrowserRpcContractBindings = Readonly<{ - operations: ReadonlyMap; - profiles: ReadonlyMap; - schemaCodecs: ReadonlyMap; - mappers: ReadonlyMap; - requestEncoders: ReadonlyMap; - runtimeBindings: ReadonlyMap; + operations: ReadOnlyRegistry; + profiles: ReadOnlyRegistry; + schemaCodecs: ReadOnlyRegistry; + mappers: ReadOnlyRegistry; + requestEncoders: ReadOnlyRegistry; + runtimeBindings: ReadOnlyRegistry; }>; /** @@ -351,7 +360,7 @@ function installRegistrySnapshot( source: Readonly>, label: string, allowedKeys: readonly string[], -): ReadonlyMap { +): ReadOnlyRegistry { let ownKeys: string[]; let symbols: readonly symbol[]; try { @@ -376,7 +385,7 @@ function installRegistrySnapshot( installRowSnapshot(descriptor.value as Value, `${label}.${key}`, allowedKeys), ); } - return Object.freeze(installed) as ReadonlyMap; + return createReadOnlyRegistry(installed); } function installRowSnapshot( diff --git a/tests/unit/browser-rpc/browser-rpc-remediation.test.ts b/tests/unit/browser-rpc/browser-rpc-remediation.test.ts new file mode 100644 index 0000000..e295139 --- /dev/null +++ b/tests/unit/browser-rpc/browser-rpc-remediation.test.ts @@ -0,0 +1,315 @@ +import { describe, expect, it } from "vitest"; + +import { + createBrowserRpcRuntime, + defineBrowserRpcTransport, + type BrowserRpcStreamFrame, + type BrowserRpcTransport, +} from "../../../src/adapters/browser-rpc/index.ts"; +import { installBrowserRpcContractBindings } from "../../../src/contracts/browser-rpc.ts"; +import { + MAPPERS, + SCHEMA_CODECS, + STREAM_ENCODER, + UNARY_ENCODER, + isResourceView, + streamOperation, + streamProfile, + unaryOperation, + unaryProfile, +} from "./fixture.ts"; + +function unaryRuntime( + transport: BrowserRpcTransport, + extra: Record = {}, +) { + return createBrowserRpcRuntime({ + operations: { GET_RPC_RESOURCE: unaryOperation() }, + profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() }, + schemaCodecs: SCHEMA_CODECS, + mappers: MAPPERS, + requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER }, + transports: { CONNECT_REFERENCE_UNARY: transport }, + ...extra, + }); +} + +function streamingRuntime( + transport: BrowserRpcTransport, + extra: Record = {}, +) { + return createBrowserRpcRuntime({ + operations: { WATCH_RPC_RESOURCES: streamOperation() }, + profiles: { CONNECT_REFERENCE_STREAM: streamProfile() }, + schemaCodecs: SCHEMA_CODECS, + mappers: MAPPERS, + requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER }, + transports: { CONNECT_REFERENCE_STREAM: transport }, + ...extra, + }); +} + +function unaryTransport( + invokeUnary: BrowserRpcTransport["invokeUnary"], +): BrowserRpcTransport { + return defineBrowserRpcTransport({ + runtimeProfileId: "CONNECT_REFERENCE_UNARY", + providerId: "REFERENCE_RPC", + protocol: "CONNECT_HTTP", + rpcKind: "UNARY", + invokeUnary, + }); +} + +function streamTransport( + frames: readonly BrowserRpcStreamFrame[], +): BrowserRpcTransport { + return defineBrowserRpcTransport({ + runtimeProfileId: "CONNECT_REFERENCE_STREAM", + providerId: "REFERENCE_RPC", + protocol: "CONNECT_HTTP", + rpcKind: "SERVER_STREAM", + openServerStream: () => ({ + async *[Symbol.asyncIterator]() { + for (const frame of frames) yield frame; + }, + }), + }); +} + +async function collect( + source: AsyncIterable, +): Promise { + const seen: unknown[] = []; + for await (const value of source) seen.push(value); + return seen; +} + +/** + * RPC-RR-02. A collaborator that throws synchronously must not escape the + * Result contract. A generation fence captured outside the protected boundary + * and a `clock.sleep` invoked outside a promise boundary both did exactly that, + * and the second also skipped the listener and timer release. + */ +describe("RPC-RR-02 synchronous collaborator throws stay inside Result", () => { + const throwingFence = { + capture: () => { + throw new TypeError("fence exploded"); + }, + isCurrent: () => true, + }; + + it("closes a unary call whose fence throws", async () => { + const runtime = unaryRuntime( + unaryTransport(async () => ({ + ok: true, + message: { id: "a", name: "A" }, + encodedBytes: 8, + })), + { generationFence: throwingFence }, + ); + + const result = await runtime + .bindUnary("GET_RPC_RESOURCE", isResourceView) + .execute({ resourceId: "resource-1" }); + expect(result.ok).toBe(false); + }); + + it("closes a stream whose fence throws", async () => { + const runtime = streamingRuntime( + streamTransport([{ kind: "TERMINAL", ok: true }]), + { generationFence: throwingFence }, + ); + + const results = await collect( + runtime + .bindServerStream("WATCH_RPC_RESOURCES", isResourceView) + .open({ topic: "resources" }), + ); + expect(results.length).toBeGreaterThan(0); + expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe( + true, + ); + }); + + it("closes a unary call whose clock throws synchronously", async () => { + const runtime = unaryRuntime( + unaryTransport(() => new Promise(() => {})), + { + clock: { + now: () => 0, + sleep: () => { + throw new TypeError("clock exploded"); + }, + }, + }, + ); + + const result = await runtime + .bindUnary("GET_RPC_RESOURCE", isResourceView) + .execute({ resourceId: "resource-1" }); + expect(result.ok).toBe(false); + }); +}); + +/** + * RPC-RR-03. The runtime reads a validated snapshot, never the caller's + * objects: an accessor is refused without being invoked, and no installed + * registry exposes a mutator. + */ +describe("RPC-RR-03 transport and binding registries are snapshots", () => { + it("never invokes a transport accessor", () => { + let getterCalls = 0; + const hostile = {} as Record; + Object.defineProperties(hostile, { + runtimeProfileId: { + enumerable: true, + get: () => { + getterCalls += 1; + return "CONNECT_REFERENCE_UNARY"; + }, + }, + providerId: { enumerable: true, value: "REFERENCE_RPC" }, + protocol: { enumerable: true, value: "CONNECT_HTTP" }, + rpcKind: { enumerable: true, value: "UNARY" }, + invokeUnary: { + enumerable: true, + value: async () => ({ ok: true, message: {}, encodedBytes: 1 }), + }, + }); + + expect(() => + unaryRuntime(hostile as unknown as BrowserRpcTransport), + ).toThrow(TypeError); + expect(getterCalls).toBe(0); + }); + + it("rejects an unexpected own field on a transport row", () => { + const transport = unaryTransport(async () => ({ + ok: true, + message: { id: "a", name: "A" }, + encodedBytes: 8, + })); + const widened = { ...transport, injected: true }; + expect(() => + unaryRuntime(widened as unknown as BrowserRpcTransport), + ).toThrow(TypeError); + }); + + it("exposes no mutation API on any installed binding registry", () => { + const installed = installBrowserRpcContractBindings({ + operations: { GET_RPC_RESOURCE: unaryOperation() }, + profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() }, + schemaCodecs: SCHEMA_CODECS, + mappers: MAPPERS, + requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER }, + }); + for (const registry of Object.values(installed)) { + const record = registry as unknown as Record; + for (const mutator of ["set", "delete", "clear"]) { + expect(record[mutator]).toBeUndefined(); + } + expect(() => + Map.prototype.clear.call(registry as never), + ).toThrow(); + } + expect(installed.operations.get("GET_RPC_RESOURCE")).toBeDefined(); + }); +}); + +/** + * RPC-RR-04. Transport values are decoded, not adopted. `in` and a direct + * property read run accessors and admit inherited or extra fields, and keeping + * the caller's object lets it change after validation. + */ +describe("RPC-RR-04 transport results and frames are exactly decoded", () => { + const hostileResults: readonly (readonly [string, () => unknown])[] = [ + ["extra own field", () => ({ ok: true, message: {}, encodedBytes: 1, injected: 1 })], + [ + "inherited fields", + () => + Object.create({ ok: true, message: {}, encodedBytes: 1 }) as object, + ], + [ + "throwing getter", + () => { + const value: Record = { message: {}, encodedBytes: 1 }; + Object.defineProperty(value, "ok", { + enumerable: true, + get: () => { + throw new TypeError("hostile getter"); + }, + }); + return value; + }, + ], + [ + "symbol key", + () => ({ + ok: true, + message: {}, + encodedBytes: 1, + [Symbol("injected")]: 1, + }), + ], + ]; + + for (const [label, build] of hostileResults) { + it(`refuses a unary result with ${label}`, async () => { + const runtime = unaryRuntime( + unaryTransport(async () => build() as never), + ); + const result = await runtime + .bindUnary("GET_RPC_RESOURCE", isResourceView) + .execute({ resourceId: "resource-1" }); + expect(result.ok).toBe(false); + }); + } + + it("refuses an unknown transport failure code", async () => { + const runtime = unaryRuntime( + unaryTransport(async () => ({ + ok: false, + failure: { code: "MADE_UP_CODE" }, + }) as never), + ); + const result = await runtime + .bindUnary("GET_RPC_RESOURCE", isResourceView) + .execute({ resourceId: "resource-1" }); + expect(result.ok).toBe(false); + }); + + it("refuses a retryAfterMs outside the hard ceiling", async () => { + const runtime = unaryRuntime( + unaryTransport(async () => ({ + ok: false, + failure: { code: "NETWORK_UNREACHABLE", retryAfterMs: -1 }, + }) as never), + ); + const result = await runtime + .bindUnary("GET_RPC_RESOURCE", isResourceView) + .execute({ resourceId: "resource-1" }); + expect(result.ok).toBe(false); + }); + + it("refuses a stream frame with an extra own field", async () => { + const runtime = streamingRuntime( + streamTransport([ + { + kind: "MESSAGE", + message: { id: "a", name: "A" }, + encodedBytes: 4, + injected: 1, + } as never, + ]), + ); + const results = await collect( + runtime + .bindServerStream("WATCH_RPC_RESOURCES", isResourceView) + .open({ topic: "resources" }), + ); + expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe( + true, + ); + }); +});