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(""); + }); });