From 005d62abf7a5372474d436c102e140dbb5ec66fe Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 19 Jun 2026 14:09:32 +0530 Subject: [PATCH 1/3] Fix codec and parser bugs found by LLM fuzzing A fleet of property-based fuzzers (round-trip + no-throw invariants over the codec, fragment, base-N, git-patch, and JSON-tree surfaces) surfaced these, each now pinned by a regression test: - arx2/arx3 silently corrupted any content containing U+007F (DEL): 0x7F is the one overlay substitution code JSON.stringify leaves unescaped, so a literal DEL was expanded back into a dictionary pattern on decode -> invalid JSON. Escape it to  at the tuple-JSON boundary (a no-op for DEL-free payloads, so the wire stays compatible). - A ~27-char fragment could peg a core for ~55s / attempt a ~3.8 GB allocation: the base76/base1k/baseBMP decoders trusted an attacker-controlled length prefix before the decoded-size budget ran. Bound the wire byte length up front. - base76 silently corrupted payloads > 5928 bytes (its fixed 2-char length prefix overflowed to the string "undefined"). Add an "="-marked extended prefix; the legacy 2-char shape is preserved byte-for-byte for every length it can represent. - git-patch turned `rename to a/` into an empty displayPath and a degenerate "-0" id. stripDiffPrefix now returns null for an empty result so the file-N fallback applies. - The JSON tree renderer overflowed the React render stack on deeply nested input (a few KB). Cap render depth and add an error boundary that degrades to the raw view, matching DiffRenderer. Co-Authored-By: Claude Opus 4.8 --- src/components/renderers/json-renderer.tsx | 45 ++++++- src/lib/diff/git-patch.ts | 6 +- src/lib/payload/arx-codec.ts | 100 ++++++++++++++-- tests/arx-codec-robustness.test.ts | 81 +++++++++++++ tests/arx-codec.test.ts | 129 ++++++++++++++++++++- tests/components/json-renderer.test.tsx | 12 ++ tests/git-patch.test.ts | 10 ++ 7 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 tests/arx-codec-robustness.test.ts diff --git a/src/components/renderers/json-renderer.tsx b/src/components/renderers/json-renderer.tsx index 6d5b59a..52d8809 100644 --- a/src/components/renderers/json-renderer.tsx +++ b/src/components/renderers/json-renderer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { Component, type ReactNode, useEffect, useMemo, useRef, useState } from "react"; import { Braces, ChevronRight, ListTree } from "lucide-react"; import type { JsonArtifact } from "@/lib/payload/schema"; @@ -11,6 +11,12 @@ type JsonRendererProps = { type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }; +// Policy (owned decision): collapse nodes deeper than this to a leaf so a pathological or deeply +// nested payload can never overflow React's client render stack. A MAX_DECODED_PAYLOAD_LENGTH +// (200k char) payload can nest thousands deep in only a few KB, which crashes the reconciler with +// a RangeError; 200 is far beyond any human-readable JSON. Change only by maintainer decision. +const MAX_JSON_TREE_DEPTH = 200; + function JsonNode({ label, value, level = 0 }: { label?: string; value: JsonValue; level?: number }) { if (value === null || typeof value !== "object") { return ( @@ -21,6 +27,19 @@ function JsonNode({ label, value, level = 0 }: { label?: string; value: JsonValu ); } + if (level >= MAX_JSON_TREE_DEPTH) { + return ( +
+ {label ? {label} : null} + + {Array.isArray(value) + ? `Array(${value.length}) — max depth reached` + : `Object(${Object.keys(value).length}) — max depth reached`} + +
+ ); + } + if (Array.isArray(value)) { return (
@@ -65,6 +84,22 @@ function JsonRawSource({ content }: { content: string }) { ); } +// Defense-in-depth (mirrors DiffRendererBoundary): if the recursive tree render throws for any +// reason, degrade to the raw source view instead of crashing the whole viewer. The depth cap above +// is the primary guard; this catches anything it doesn't. Keyed by artifact id so a new artifact +// resets the error state and retries the tree. +class JsonTreeBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { hasError: boolean }> { + state = { hasError: false }; + + static getDerivedStateFromError(): { hasError: true } { + return { hasError: true }; + } + + render(): ReactNode { + return this.state.hasError ? this.props.fallback : this.props.children; + } +} + /** * Shows JSON artifacts with a toggle between structured tree and native raw source views. * Receives `artifact` and optional `onReady`, including readiness updates across parse and view-mode changes. @@ -114,9 +149,11 @@ export function JsonRenderer({ artifact, onReady }: JsonRendererProps) { read-only {view === "tree" ? ( -
- -
+ }> +
+ +
+
) : ( )} diff --git a/src/lib/diff/git-patch.ts b/src/lib/diff/git-patch.ts index 2ba8b04..1675a5c 100644 --- a/src/lib/diff/git-patch.ts +++ b/src/lib/diff/git-patch.ts @@ -18,7 +18,11 @@ function stripDiffPrefix(filePath: string | null): string | null { return null; } - return filePath.replace(/^[ab]\//, ""); + const stripped = filePath.replace(/^[ab]\//, ""); + // A path that reduces to empty (e.g. a bare "a/") is not a usable path; return null so the + // `displayPath`/`id` fallback chain (newPath ?? oldPath ?? `file-N`) applies instead of + // producing an empty label and a degenerate "-N" id. + return stripped === "" ? null : stripped; } function normalizePatch(patch: string): string { diff --git a/src/lib/payload/arx-codec.ts b/src/lib/payload/arx-codec.ts index bbae318..974e6ae 100644 --- a/src/lib/payload/arx-codec.ts +++ b/src/lib/payload/arx-codec.ts @@ -638,7 +638,24 @@ function envelopeFromArxTuple(value: unknown, codec: Extract= 5929: "=" marker + one count char (number of base-77 length +// digits) + that many base-77 length digits, then base76 digits. +// +// POLICY (deliberate, owned decision — not an incidental mechanism): the legacy +// 2-char shape is preserved byte-for-byte for every length it can represent, so +// fragments shared before the extended shape existed still decode unchanged. The +// extended shape is gated behind "=", the one chat-safe ASCII fragment character +// (see isChatSafeAsciiFragmentCodePoint in fragment.ts) that is NOT a base76 +// digit, so the two shapes can never be confused: a legacy prefix always begins +// with an alphabet digit, never "=". Lengths >= 5929 previously produced a +// corrupt "undefined" prefix, so no valid pre-existing payload used that range. // --------------------------------------------------------------------------- const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$*()',;:@/"; @@ -647,11 +664,26 @@ const BIGINT_0 = BigInt(0); const BIGINT_8 = BigInt(8); const BIGINT_0xFF = BigInt(0xFF); +/** Largest byte count the legacy 2-char base-77 length prefix can represent (76*77 + 76). */ +const BASE76_LEGACY_MAX_LENGTH = ALPHABET.length * ALPHABET.length - 1; +/** Marks the extended variable-width length prefix; never a base76 digit, so it cannot collide with the legacy prefix. */ +const BASE76_EXTENDED_LENGTH_MARKER = "="; + const CHAR_TO_INDEX = new Uint8Array(128); for (let i = 0; i < ALPHABET.length; i++) { CHAR_TO_INDEX[ALPHABET.charCodeAt(i)] = i; } +/** Encodes a non-negative integer as minimal-width big-endian base-77 digits (>= 1 digit). */ +function encodeBase76Length(length: number): string { + if (length === 0) return ALPHABET[0]; + const digits: string[] = []; + for (let value = length; value > 0; value = Math.floor(value / ALPHABET.length)) { + digits.push(ALPHABET[value % ALPHABET.length]); + } + return digits.reverse().join(""); +} + /** Public API for `encodeBase76`. */ export function encodeBase76(bytes: Uint8Array): string { if (bytes.length === 0) return ""; @@ -668,21 +700,44 @@ export function encodeBase76(bytes: Uint8Array): string { } chars.reverse(); - const lenHigh = Math.floor(bytes.length / ALPHABET.length); - const lenLow = bytes.length % ALPHABET.length; - return ALPHABET[lenHigh] + ALPHABET[lenLow] + chars.join(""); + const lenPrefix = encodeBase76LengthPrefix(bytes.length); + return lenPrefix + chars.join(""); +} + +/** Builds the length prefix, choosing the legacy 2-char shape or the extended "=" shape by size. */ +function encodeBase76LengthPrefix(length: number): string { + if (length <= BASE76_LEGACY_MAX_LENGTH) { + return ALPHABET[Math.floor(length / ALPHABET.length)] + ALPHABET[length % ALPHABET.length]; + } + const lengthDigits = encodeBase76Length(length); + return BASE76_EXTENDED_LENGTH_MARKER + ALPHABET[lengthDigits.length] + lengthDigits; } /** Public API for `decodeBase76`. */ export function decodeBase76(str: string): Uint8Array { if (str.length < 2) return new Uint8Array(0); - const lenHigh = CHAR_TO_INDEX[str.charCodeAt(0)]; - const lenLow = CHAR_TO_INDEX[str.charCodeAt(1)]; - const byteLen = lenHigh * ALPHABET.length + lenLow; + let byteLen: number; + let digitsStart: number; + + if (str[0] === BASE76_EXTENDED_LENGTH_MARKER) { + const digitCount = CHAR_TO_INDEX[str.charCodeAt(1)]; + byteLen = 0; + for (let i = 0; i < digitCount; i++) { + byteLen = byteLen * ALPHABET.length + CHAR_TO_INDEX[str.charCodeAt(2 + i)]; + } + digitsStart = 2 + digitCount; + } else { + const lenHigh = CHAR_TO_INDEX[str.charCodeAt(0)]; + const lenLow = CHAR_TO_INDEX[str.charCodeAt(1)]; + byteLen = lenHigh * ALPHABET.length + lenLow; + digitsStart = 2; + } + + assertWireByteLen(byteLen); let num = BIGINT_0; - for (let i = 2; i < str.length; i++) { + for (let i = digitsStart; i < str.length; i++) { num = num * BASE + BigInt(CHAR_TO_INDEX[str.charCodeAt(i)]); } @@ -749,6 +804,8 @@ export function decodeBase1k(str: string): Uint8Array { const lenLow = UNICODE_CHAR_TO_INDEX.get(str[1]) ?? 0; const byteLen = lenHigh * UNICODE_ALPHABET.length + lenLow; + assertWireByteLen(byteLen); + let num = BIGINT_0; for (let i = 2; i < str.length; i++) { num = num * UBASE + BigInt(UNICODE_CHAR_TO_INDEX.get(str[i]) ?? 0); @@ -988,6 +1045,8 @@ export function decodeBaseBMP(str: string): Uint8Array { const lenLow = BMP_CHAR_TO_INDEX.get(s[1]) ?? 0; const byteLen = lenHigh * BMP_ALPHABET.length + lenLow; + assertWireByteLen(byteLen); + let num = BIGINT_0; for (let i = 2; i < s.length; i++) { num = num * BMPBASE + BigInt(BMP_CHAR_TO_INDEX.get(s[i]) ?? 0); @@ -1114,6 +1173,20 @@ function assertDecodedTextBudget(text: string): void { } } +/** + * Bounds a wire byte length decoded from an attacker-controlled base-N length prefix BEFORE any + * allocation or per-byte loop. The prefix is tiny but can claim a huge count (baseBMP's 2-char + * prefix reaches ~3.8e9), which would otherwise peg a core for ~a minute on a multi-GB allocation + * — the existing decoded-size budget only runs after decompression, far too late. A real + * compressed payload is always far below MAX_BROTLI_OUTPUT_BYTES, so anything larger is provably + * implausible and rejected as decoded-too-large (caught upstream in decodeFragmentAsync). + */ +function assertWireByteLen(byteLen: number): void { + if (byteLen > MAX_BROTLI_OUTPUT_BYTES) { + throw new ArxDecodedPayloadTooLargeError(); + } +} + function concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array { const out = new Uint8Array(totalLength); let offset = 0; @@ -1269,7 +1342,16 @@ export async function arxCompressBase64url(json: string): Promise { * Returns all supported binary-to-text wire shapes so callers can choose by transport size. */ async function compressTupleEnvelope(envelope: PayloadEnvelope): Promise { - const tupleJson = JSON.stringify(envelopeToArx2Tuple(envelope)); + // The arx2/arx3 overlay repurposes 0x7F (DEL) as a single-byte substitution + // code (see ARX2_SINGLE_BYTE_CODES). JSON.stringify escapes every C0 control + // byte (< 0x20) — which covers all the other substitution code bytes — but + // emits a literal 0x7F because DEL is >= 0x20. A literal DEL in artifact + // content would then be indistinguishable from the overlay code byte and get + // expanded back into a dictionary pattern by overlayDecode, corrupting the + // tuple JSON. Escaping it to a \u007F JSON escape (which JSON.parse restores + // on decode) keeps DEL out of the substitution alphabet. This is a no-op for + // DEL-free payloads, so the wire form is byte-identical for existing content. + const tupleJson = JSON.stringify(envelopeToArx2Tuple(envelope)).replace(/\x7f/g, "\\u007f"); const substituted = dictEncode(overlayEncode(tupleJson)); const compressed = await compressSubstitutedText(substituted); return encodeWirePayloads(compressed); diff --git a/tests/arx-codec-robustness.test.ts b/tests/arx-codec-robustness.test.ts new file mode 100644 index 0000000..131b861 --- /dev/null +++ b/tests/arx-codec-robustness.test.ts @@ -0,0 +1,81 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import arxDictionaryJson from "../public/arx-dictionary.json"; +import arx2DictionaryJson from "../public/arx2-dictionary.json"; +import { + ArxDecodedPayloadTooLargeError, + arx2CompressEnvelope, + arx2DecompressEnvelope, + arx3CompressEnvelope, + arx3DecompressEnvelope, + decodeBaseBMP, + loadArx2OverlayDictionarySync, + loadArxDictionarySync, +} from "@/lib/payload/arx-codec"; +import { decodeFragmentAsync, encodeEnvelopeAsync } from "@/lib/payload/fragment"; +import type { PayloadEnvelope } from "@/lib/payload/schema"; + +// Regressions for bugs surfaced by the codec fuzzer. Each pins a property the fuzzer violated so a +// refactor cannot silently reintroduce it. + +function markdownEnvelope(content: string): PayloadEnvelope { + return { v: 1, codec: "plain", activeArtifactId: "a", artifacts: [{ id: "a", kind: "markdown", content }] }; +} + +beforeAll(() => { + loadArxDictionarySync(arxDictionaryJson); + loadArx2OverlayDictionarySync(arx2DictionaryJson); +}); + +describe("arx2/arx3 preserve a literal U+007F (DEL) byte in content", () => { + // 0x7F is the one arx2/arx3 overlay substitution code that JSON.stringify leaves unescaped, so a + // raw DEL in content used to be expanded back into a dictionary pattern on decode -> invalid + // JSON. compressTupleEnvelope now escapes it to . If this fails after a refactor, that is + // intentional only if the escaping policy changed — update the comment in arx-codec.ts too. + for (const content of ["\x7f", "log\x7fend", "\x7f\x7f", "a\x7fb\x7fc"]) { + it(`arx2 round-trips ${JSON.stringify(content)}`, async () => { + const payloads = await arx2CompressEnvelope({ ...markdownEnvelope(content), codec: "arx2" }); + const decoded = await arx2DecompressEnvelope(payloads.base64url); + expect(decoded.artifacts[0]).toMatchObject({ content }); + }); + + it(`arx3 round-trips ${JSON.stringify(content)}`, async () => { + const payloads = await arx3CompressEnvelope({ ...markdownEnvelope(content), codec: "arx3" }); + const decoded = await arx3DecompressEnvelope(payloads.base64url); + expect(decoded.artifacts[0]).toMatchObject({ content }); + }); + } + + it("round-trips a DEL byte through the public encode/decode fragment API", async () => { + const content = "terminal\x7fdump"; + const frag = await encodeEnvelopeAsync(markdownEnvelope(content), { codec: "arx3" }); + const decoded = await decodeFragmentAsync(`#${frag}`, { skipFragmentBudget: true }); + expect(decoded.ok).toBe(true); + if (decoded.ok) { + expect(decoded.envelope.artifacts[0]).toMatchObject({ content }); + } + }); +}); + +describe("base-N decoders reject an implausible wire length instead of hanging", () => { + // A ~27-char fragment used to drive decodeBaseBMP to compute byteLen ~3.8e9 and allocate multi-GB, + // pegging a core for ~55s before the decoded-size budget ran. The wire-length guard now rejects + // it before any allocation. The timing assertions pin "fast rejection, not a hang". + it("decodeBaseBMP rejects an oversized length prefix fast", () => { + const start = performance.now(); + expect(() => decodeBaseBMP("￰○○")).toThrow(ArxDecodedPayloadTooLargeError); + expect(performance.now() - start).toBeLessThan(1000); + }); + + it("decodeFragmentAsync returns decoded-too-large (not a hang) for a tiny malicious arx fragment", async () => { + const start = performance.now(); + const result = await decodeFragmentAsync( + "#agent-render=v1.arx.0.￰○○¡¢", + { skipFragmentBudget: true }, + ); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe("decoded-too-large"); + } + expect(performance.now() - start).toBeLessThan(2000); + }); +}); diff --git a/tests/arx-codec.test.ts b/tests/arx-codec.test.ts index 48e558f..48cf247 100644 --- a/tests/arx-codec.test.ts +++ b/tests/arx-codec.test.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, describe, expect, it, vi } from "vitest"; import arxDictionaryJson from "../public/arx-dictionary.json"; import arx2DictionaryJson from "../public/arx2-dictionary.json"; import { @@ -70,6 +70,52 @@ describe("base76 encoding", () => { const encoded = encodeBase76(bytes); expect(encoded).toMatch(/^[A-Za-z0-9\-._~!$*()',;:@/]+$/); }); + + // The 2-char length prefix encodes the byte count as floor(len/77) and len%77 over a + // 77-char alphabet, so it can only represent 76*77+76 = 5928 bytes. Past that the legacy + // encoder emitted "undefined" for the overflowing prefix char and the payload corrupted. + // Lengths up to the ceiling keep the legacy 2-char prefix (existing shared links stay + // decodable); longer payloads switch to an "=" marked variable-width length prefix. + const CEILING = 76 * 77 + 76; // 5928 + + function ramp(n: number): Uint8Array { + const bytes = new Uint8Array(n); + for (let i = 0; i < n; i++) bytes[i] = (i * 31 + 7) & 0xff; + return bytes; + } + + it("keeps the legacy 2-char prefix at the ceiling (5928 bytes) and round-trips", () => { + const input = ramp(CEILING); + const wire = encodeBase76(input); + expect(wire.startsWith("=")).toBe(false); + expect(decodeBase76(wire)).toEqual(input); + }); + + it("round-trips one byte past the ceiling (5929 bytes) without emitting 'undefined'", () => { + const input = ramp(CEILING + 1); + const wire = encodeBase76(input); + expect(wire.startsWith("undefined")).toBe(false); + expect(wire.startsWith("=")).toBe(true); + expect(decodeBase76(wire)).toEqual(input); + }); + + // base76's BigInt packing is O(n^2), so 60000 bytes takes a few seconds — far past any real + // fragment (the 8192-char budget caps base76 near 6400 bytes), but it pins the exported + // function's contract at the size from the original overflow report. Hence the raised timeout. + it("round-trips a large payload (60000 bytes)", () => { + const input = ramp(60000); + const wire = encodeBase76(input); + expect(wire.startsWith("undefined")).toBe(false); + expect(decodeBase76(wire)).toEqual(input); + }, 30000); + + it("preserves leading zero bytes past the ceiling", () => { + // All-zero bytes are the worst case for the length prefix: the BigInt packing drops every + // leading zero, so the byte count must come entirely from the prefix. + const input = new Uint8Array(CEILING + 1); // 5929 zero bytes + const wire = encodeBase76(input); + expect(decodeBase76(wire)).toEqual(input); + }); }); describe("base1k encoding", () => { @@ -844,6 +890,87 @@ describe("arx3 compact tuple envelope", () => { }); }); +describe("arx2/arx3 preserve control bytes in artifact content", () => { + // The arx2/arx3 overlay assigns byte 0x7F (DEL) as a substitution code + // (ARX2_SINGLE_BYTE_CODES). JSON.stringify escapes every C0 control (< 0x20) + // — which covers all the other code bytes — but leaves a literal 0x7F because + // DEL is >= 0x20. These tests pin that a literal DEL (and every other byte in + // 0x00..0x9F) round-trips losslessly through the tuple pipeline rather than + // being mistaken for the overlay code on decode. + // + // Characterization: if these break after a refactor, the canonical decoded + // envelope below changed — update the expectation AND confirm the DEL + // escaping in compressTupleEnvelope is still in place; do not relax the test. + beforeAll(() => { + loadArxDictionarySync(arxDictionaryJson); + loadArx2OverlayDictionarySync(arx2DictionaryJson); + }); + + function markdownEnvelope(content: string): PayloadEnvelope { + return { + v: 1, + codec: "plain", + activeArtifactId: "a", + artifacts: [{ id: "a", kind: "markdown", content }], + }; + } + + const allControlBytes = Array.from({ length: 0xa0 }, (_, byte) => String.fromCharCode(byte)).join(""); + + it("round-trips a literal DEL (U+007F) through the arx2 codec API to the canonical envelope", async () => { + const envelope = markdownEnvelope("log\x7fend"); + const payloads = await arx2CompressEnvelope(envelope); + const decoded = await arx2DecompressEnvelope(payloads.base64url); + expect(decoded).toEqual({ ...envelope, codec: "arx2" }); + }); + + it("round-trips a literal DEL (U+007F) through the arx3 codec API to the canonical envelope", async () => { + const envelope = markdownEnvelope("log\x7fend"); + const payloads = await arx3CompressEnvelope(envelope); + const decoded = await arx3DecompressEnvelope(payloads.base64url); + expect(decoded).toEqual({ ...envelope, codec: "arx3" }); + }); + + it("round-trips a lone DEL byte as the entire content through arx2", async () => { + const envelope = markdownEnvelope("\x7f"); + const payloads = await arx2CompressEnvelope(envelope); + const decoded = await arx2DecompressEnvelope(payloads.base64url); + expect(decoded).toEqual({ ...envelope, codec: "arx2" }); + }); + + it("round-trips every byte 0x00..0x9F through the arx2 codec API", async () => { + const envelope = markdownEnvelope(allControlBytes); + const payloads = await arx2CompressEnvelope(envelope); + const decoded = await arx2DecompressEnvelope(payloads.base64url); + expect(decoded).toEqual({ ...envelope, codec: "arx2" }); + }); + + it("round-trips every byte 0x00..0x9F through the arx3 codec API", async () => { + const envelope = markdownEnvelope(allControlBytes); + const payloads = await arx3CompressEnvelope(envelope); + const decoded = await arx3DecompressEnvelope(payloads.base64url); + expect(decoded).toEqual({ ...envelope, codec: "arx3" }); + }); + + it("decodes a DEL-containing arx2 fragment through the public fragment path", async () => { + const content = "log\x7fend"; + const hash = `#${await encodeEnvelopeAsync(markdownEnvelope(content), { codec: "arx2" })}`; + const parsed = await decodeFragmentAsync(hash, { skipFragmentBudget: true }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.envelope.artifacts[0]).toMatchObject({ content }); + }); + + it("decodes a DEL-containing arx3 fragment through the public fragment path", async () => { + const content = "log\x7fend"; + const hash = `#${await encodeEnvelopeAsync(markdownEnvelope(content), { codec: "arx3" })}`; + const parsed = await decodeFragmentAsync(hash, { skipFragmentBudget: true }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) return; + expect(parsed.envelope.artifacts[0]).toMatchObject({ content }); + }); +}); + describe("arx dictionary trie scanner", () => { const singleByteCodes = [ 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x0b, 0x0e, 0x0f, 0x10, diff --git a/tests/components/json-renderer.test.tsx b/tests/components/json-renderer.test.tsx index 2a83899..c8d901d 100644 --- a/tests/components/json-renderer.test.tsx +++ b/tests/components/json-renderer.test.tsx @@ -69,4 +69,16 @@ describe("JsonRenderer", () => { expect(screen.getByText(/expected property name/i)).toBeVisible(); expect(screen.getByTestId("renderer-json-raw")).toHaveTextContent("{ nope"); }); + + it("renders deeply nested JSON without overflowing the render stack", () => { + // ~3000-deep nesting crashed the client reconciler with RangeError before the depth cap. + // Built as a string so the test setup itself does not recurse. Fuzz regression (json tree). + const depth = 3000; + const content = "[".repeat(depth) + "0" + "]".repeat(depth); + const artifact = createArtifact({ content }); + + expect(() => render()).not.toThrow(); + expect(screen.getByTestId("renderer-json")).toBeInTheDocument(); + expect(screen.getAllByText(/max depth reached/i).length).toBeGreaterThan(0); + }); }); diff --git a/tests/git-patch.test.ts b/tests/git-patch.test.ts index 62a366c..7ace1b0 100644 --- a/tests/git-patch.test.ts +++ b/tests/git-patch.test.ts @@ -77,4 +77,14 @@ diff --git a/src/alpha.ts b/src/alpha.ts displayPath: "src/alpha.ts", }); }); + + it("falls back to a file-N label when a rename target strips to an empty path", () => { + // `rename to a/` reduces to "" after stripping the a/ prefix; the display label must not be + // empty and the id must not be degenerate ("-0"). Fuzz regression (git-patch parser). + const files = parseGitPatchBundle("rename to a/"); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ displayPath: "file-1", id: "file-1-0" }); + expect(files[0].displayPath).not.toBe(""); + }); }); From 7519687622ac82d3b2465c2154889c0a547f986d Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 19 Jun 2026 14:38:05 +0530 Subject: [PATCH 2/3] Harden base76 extended-prefix decode and json error boundary (review follow-up) Addresses automated review on the fuzz-fix PR: - decodeBase76: a truncated extended length prefix (e.g. "=B" claims a length digit but carries none) read charCodeAt past the end -> NaN byteLen, and `NaN > MAX_BROTLI_OUTPUT_BYTES` is false, so the guard missed it and the decode silently returned an empty array. Guard the digit count against the string length, and make assertWireByteLen reject non-integer lengths so a NaN can never slip past any base-N decoder. - JsonTreeBoundary: add componentDidCatch to log the swallowed throw (parity with DiffRendererBoundary) instead of falling back to raw with no trace. Co-Authored-By: Claude Opus 4.8 --- src/components/renderers/json-renderer.tsx | 6 ++++++ src/lib/payload/arx-codec.ts | 10 +++++++++- tests/arx-codec.test.ts | 7 +++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/renderers/json-renderer.tsx b/src/components/renderers/json-renderer.tsx index 52d8809..d1ae14f 100644 --- a/src/components/renderers/json-renderer.tsx +++ b/src/components/renderers/json-renderer.tsx @@ -95,6 +95,12 @@ class JsonTreeBoundary extends Component<{ fallback: ReactNode; children: ReactN return { hasError: true }; } + componentDidCatch(error: unknown): void { + // Log the swallowed throw (parity with DiffRendererBoundary) so a future JsonNode regression + // that slips past the depth cap leaves a trace instead of silently falling back to raw. + console.error("JSON tree render failed; falling back to raw source view.", error); + } + render(): ReactNode { return this.state.hasError ? this.props.fallback : this.props.children; } diff --git a/src/lib/payload/arx-codec.ts b/src/lib/payload/arx-codec.ts index 974e6ae..47dddd1 100644 --- a/src/lib/payload/arx-codec.ts +++ b/src/lib/payload/arx-codec.ts @@ -722,6 +722,11 @@ export function decodeBase76(str: string): Uint8Array { if (str[0] === BASE76_EXTENDED_LENGTH_MARKER) { const digitCount = CHAR_TO_INDEX[str.charCodeAt(1)]; + if (str.length < 2 + digitCount) { + // Truncated extended prefix (e.g. "=B" claims one length digit but carries none): reading + // the missing digit would index past the string -> NaN byteLen. Treat as malformed -> empty. + return new Uint8Array(0); + } byteLen = 0; for (let i = 0; i < digitCount; i++) { byteLen = byteLen * ALPHABET.length + CHAR_TO_INDEX[str.charCodeAt(2 + i)]; @@ -1182,7 +1187,10 @@ function assertDecodedTextBudget(text: string): void { * implausible and rejected as decoded-too-large (caught upstream in decodeFragmentAsync). */ function assertWireByteLen(byteLen: number): void { - if (byteLen > MAX_BROTLI_OUTPUT_BYTES) { + // Reject non-integer lengths too: a malformed prefix can yield NaN (e.g. an out-of-range + // charCodeAt), and `NaN > MAX_BROTLI_OUTPUT_BYTES` is false, which would otherwise slip the + // guard and decode to a misleading empty array instead of a rejection. + if (!Number.isInteger(byteLen) || byteLen > MAX_BROTLI_OUTPUT_BYTES) { throw new ArxDecodedPayloadTooLargeError(); } } diff --git a/tests/arx-codec.test.ts b/tests/arx-codec.test.ts index 48cf247..4e3a555 100644 --- a/tests/arx-codec.test.ts +++ b/tests/arx-codec.test.ts @@ -116,6 +116,13 @@ describe("base76 encoding", () => { const wire = encodeBase76(input); expect(decodeBase76(wire)).toEqual(input); }); + + it("treats a truncated extended length prefix as empty instead of producing a NaN byteLen", () => { + // "=B" sets the extended marker and claims one length digit but carries none; reading the + // missing digit used to give byteLen=NaN (which slips `NaN > max`). Must decode to empty. + expect(decodeBase76("=B")).toEqual(new Uint8Array(0)); + expect(decodeBase76("=")).toEqual(new Uint8Array(0)); + }); }); describe("base1k encoding", () => { From 21ea9fff6d3009629658682a00926a1a0a45fd0f Mon Sep 17 00:00:00 2001 From: Aanish Bhirud Date: Fri, 19 Jun 2026 14:52:27 +0530 Subject: [PATCH 3/3] Budget the decoded arx2/arx3 payload by its parsed size, not the escaped tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the DEL-escape fix: literal DEL bytes are escaped to the 6-char  JSON escape in the internal tuple JSON, so the decoded-size budget run on that escaped string over-counts DEL-heavy content ~6x — an artifact with ≳33k DEL bytes (real content well under the 200k limit) was wrongly rejected as decoded-too-large. Run the decoded-payload budget on the parsed tuple (re-serialized, where each escape collapses back to one character), and bound the intermediate dict/overlay-decoded strings by the brotli output budget so the expansion-bomb guard is preserved. Wire format and existing payloads are unchanged. Co-Authored-By: Claude Opus 4.8 --- src/lib/payload/arx-codec.ts | 34 ++++++++++++++++++++++++------ tests/arx-codec-robustness.test.ts | 12 +++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/lib/payload/arx-codec.ts b/src/lib/payload/arx-codec.ts index 47dddd1..2aa0ca4 100644 --- a/src/lib/payload/arx-codec.ts +++ b/src/lib/payload/arx-codec.ts @@ -1195,6 +1195,18 @@ function assertWireByteLen(byteLen: number): void { } } +/** + * Bounds an intermediate dict/overlay-decoded string by the brotli output budget. These strings can + * expand past the final payload (each control byte becomes a dictionary string), so this guards + * against an expansion bomb. The real decoded-payload limit is enforced on the parsed tuple, where + * the arx2/arx3 DEL escape collapses back to a single character. + */ +function assertWithinExpansionBudget(text: string): void { + if (text.length > MAX_BROTLI_OUTPUT_BYTES) { + throw new ArxDecodedPayloadTooLargeError(); + } +} + function concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array { const out = new Uint8Array(totalLength); let offset = 0; @@ -1394,10 +1406,16 @@ export async function arxDecompress(encoded: string): Promise { */ export async function arx2DecompressEnvelope(encoded: string): Promise { const v1Decoded = dictDecode(await decompressWirePayload(encoded)); - assertDecodedTextBudget(v1Decoded); + assertWithinExpansionBudget(v1Decoded); const tupleJson = overlayDecode(v1Decoded); - assertDecodedTextBudget(tupleJson); - return envelopeFromArxTuple(JSON.parse(tupleJson), "arx2"); + assertWithinExpansionBudget(tupleJson); + // Budget the real decoded payload, not the intermediate strings: the encode path escapes literal + // DEL bytes to the 6-char  JSON escape, which inflates the tuple ~6x for DEL-heavy content. + // Re-serializing the parsed tuple collapses each escape back to one character, so a valid + // sub-limit payload is no longer falsely rejected as decoded-too-large. + const tuple = JSON.parse(tupleJson); + assertDecodedTextBudget(JSON.stringify(tuple)); + return envelopeFromArxTuple(tuple, "arx2"); } /** @@ -1405,8 +1423,12 @@ export async function arx2DecompressEnvelope(encoded: string): Promise { const v1Decoded = dictDecode(await decompressWirePayload(encoded)); - assertDecodedTextBudget(v1Decoded); + assertWithinExpansionBudget(v1Decoded); const tupleJson = overlayDecode(v1Decoded); - assertDecodedTextBudget(tupleJson); - return envelopeFromArxTuple(JSON.parse(tupleJson), "arx3"); + assertWithinExpansionBudget(tupleJson); + // See arx2DecompressEnvelope: budget the parsed tuple so the DEL escape does not inflate the + // decoded-size check. + const tuple = JSON.parse(tupleJson); + assertDecodedTextBudget(JSON.stringify(tuple)); + return envelopeFromArxTuple(tuple, "arx3"); } diff --git a/tests/arx-codec-robustness.test.ts b/tests/arx-codec-robustness.test.ts index 131b861..9587710 100644 --- a/tests/arx-codec-robustness.test.ts +++ b/tests/arx-codec-robustness.test.ts @@ -54,6 +54,18 @@ describe("arx2/arx3 preserve a literal U+007F (DEL) byte in content", () => { expect(decoded.envelope.artifacts[0]).toMatchObject({ content }); } }); + + it("does not falsely reject a DEL-heavy payload as decoded-too-large", async () => { + // ~40k DEL bytes: each escapes to the 6-char on encode, so the intermediate tuple is ~240k. + // The decoded-size budget now runs on the parsed tuple (~40k real chars), well under the 200k + // limit, instead of on the inflated tuple string. + const content = "\x7f".repeat(40000); + const env = markdownEnvelope(content); + const payloads = await arx2CompressEnvelope({ ...env, codec: "arx2" }); + expect((await arx2DecompressEnvelope(payloads.base64url)).artifacts[0]).toMatchObject({ content }); + const arx3Payloads = await arx3CompressEnvelope({ ...env, codec: "arx3" }); + expect((await arx3DecompressEnvelope(arx3Payloads.base64url)).artifacts[0]).toMatchObject({ content }); + }); }); describe("base-N decoders reject an implausible wire length instead of hanging", () => {