diff --git a/src/components/renderers/json-renderer.tsx b/src/components/renderers/json-renderer.tsx
index 6d5b59a..d1ae14f 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,28 @@ 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 };
+ }
+
+ 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;
+ }
+}
+
/**
* 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 +155,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..2aa0ca4 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,49 @@ 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)];
+ 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)];
+ }
+ 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 +809,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 +1050,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 +1178,35 @@ 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 {
+ // 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();
+ }
+}
+
+/**
+ * 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;
@@ -1269,7 +1362,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);
@@ -1304,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");
}
/**
@@ -1315,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
new file mode 100644
index 0000000..9587710
--- /dev/null
+++ b/tests/arx-codec-robustness.test.ts
@@ -0,0 +1,93 @@
+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 });
+ }
+ });
+
+ 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", () => {
+ // 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..4e3a555 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,59 @@ 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);
+ });
+
+ 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", () => {
@@ -844,6 +897,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("");
+ });
});