// Unit tests for the strict JSON parser (charter §6 bounds, r2 §5 lexical rules). import { test } from "node:test"; import assert from "node:assert/strict"; import { parseStrict, StrictJsonError, utf8Encode, MAX_DEPTH, MAX_ITEMS, MAX_STRING_BYTES, MAX_INPUT_BYTES, } from "./strict-json.mjs"; function bytes(text) { return utf8Encode(text); } function rejects(input, code, byteOffset) { const data = typeof input === "string" ? bytes(input) : input; let error = null; try { parseStrict(data); } catch (e) { error = e; } assert.ok(error instanceof StrictJsonError, `expected StrictJsonError for ${JSON.stringify(input).slice(0, 60)}`); assert.equal(error.code, code); if (byteOffset !== undefined) assert.equal(error.byteOffset, byteOffset); } test("bounds are the charter values", () => { assert.equal(MAX_DEPTH, 32); assert.equal(MAX_ITEMS, 1024); assert.equal(MAX_STRING_BYTES, 4096); assert.equal(MAX_INPUT_BYTES, 1048576); }); test("parses ordinary documents into null-prototype objects", () => { const v = parseStrict(bytes('{"a": [1, true, null, "x"], "b": {"c": -5}}')); assert.equal(Object.getPrototypeOf(v), null); assert.equal(Object.getPrototypeOf(v.b), null); assert.deepEqual(JSON.parse(JSON.stringify(v)), { a: [1, true, null, "x"], b: { c: -5 } }); }); test("__proto__ and constructor are ordinary own keys, never prototype writes", () => { const v = parseStrict(bytes('{"__proto__": {"polluted": true}, "constructor": 1}')); assert.equal(Object.getPrototypeOf(v), null); assert.ok(Object.hasOwn(v, "__proto__")); assert.equal(v.__proto__.polluted, true); assert.equal(({}).polluted, undefined); assert.equal(v.constructor, 1); }); test("duplicate keys are rejected at the second key", () => { rejects('{"a": 1, "a": 2}', "duplicate-key", 9); }); test("numbers: integers only, no fraction/exponent/leading zero/negative zero/unsafe", () => { assert.equal(parseStrict(bytes("0")), 0); assert.equal(parseStrict(bytes("-17")), -17); assert.equal(parseStrict(bytes("9007199254740991")), 9007199254740991); assert.equal(parseStrict(bytes("-9007199254740991")), -9007199254740991); rejects("1.0", "number-not-integer", 0); rejects("1.5", "number-not-integer", 0); rejects("1e0", "number-not-integer", 0); rejects("01", "number-leading-zero", 0); rejects("-0", "number-negative-zero", 0); rejects("9007199254740992", "number-unsafe-integer", 0); rejects("123456789012345678901234567890", "number-unsafe-integer", 0); rejects("-", "number-invalid", 0); rejects("NaN", "unexpected-token", 0); rejects("Infinity", "unexpected-token", 0); }); test("strings: escapes, surrogates, control characters, byte bound", () => { assert.equal(parseStrict(bytes('"a\\u00e9\\n\\/\\\\"')), "aé\n/\\"); assert.equal(parseStrict(bytes('"\\ud83d\\ude00"')), "\u{1F600}"); assert.equal(parseStrict(bytes('"\u{1F600}"')), "\u{1F600}"); rejects('"\\ud800"', "escape-unpaired-surrogate"); rejects('"\\udc00"', "escape-unpaired-surrogate"); rejects('"\\udc00\\ud800"', "escape-unpaired-surrogate"); rejects('"\\x41"', "escape-invalid"); rejects('"\\u00"', "escape-truncated"); rejects(`"a${String.fromCharCode(1)}b"`, "string-control-character"); rejects(`"a${String.fromCharCode(0x1f)}b"`, "string-control-character"); rejects('"ab', "string-unterminated"); assert.equal(parseStrict(bytes(`"${"x".repeat(4096)}"`)).length, 4096); rejects(`"${"x".repeat(4097)}"`, "string-too-long"); assert.equal(parseStrict(bytes(`"${"é".repeat(2048)}"`)).length, 2048); rejects(`"${"é".repeat(2049)}"`, "string-too-long"); // U+2028 / U+200B / U+0085 are not JSON control characters and parse as data. assert.equal(parseStrict(bytes(`"${String.fromCharCode(0x2028, 0x200b, 0x85)}"`)).length, 3); }); test("UTF-8: invalid, overlong, surrogate and truncated sequences fail with byte offsets", () => { rejects(new Uint8Array([0x22, 0xc0, 0x80, 0x22]), "utf8-invalid", 1); // C0 is never a valid lead byte rejects(new Uint8Array([0x22, 0xe0, 0x80, 0x80, 0x22]), "utf8-overlong", 1); rejects(new Uint8Array([0x22, 0xf0, 0x80, 0x80, 0x80, 0x22]), "utf8-overlong", 1); rejects(new Uint8Array([0x22, 0xed, 0xa0, 0x80, 0x22]), "utf8-surrogate", 1); rejects(new Uint8Array([0x22, 0xf4, 0x90, 0x80, 0x80, 0x22]), "utf8-out-of-range", 1); rejects(new Uint8Array([0x22, 0xe2, 0x82]), "utf8-truncated", 1); rejects(new Uint8Array([0x22, 0x80, 0x22]), "utf8-invalid", 1); rejects(new Uint8Array([0x22, 0xff, 0x22]), "utf8-invalid", 1); }); test("BOM, trailing content, empty input, bare words", () => { rejects("\ufeff{}", "unexpected-token", 0); rejects("{} {}", "trailing-content", 3); rejects("{}\n{}", "trailing-content", 3); rejects("", "unexpected-end", 0); rejects(" ", "unexpected-end", 3); rejects("{", "unexpected-end", 1); rejects('{"a"', "unexpected-token", 4); rejects("[1,]", "unexpected-token", 3); rejects("{,}", "object-key-not-string", 1); rejects("{1: 2}", "object-key-not-string", 1); rejects("tru", "unexpected-token", 0); rejects("'a'", "unexpected-token", 0); assert.equal(parseStrict(bytes(" \n\t\r{}\n")).constructor, undefined); }); test("depth: 32 nesting levels parse, 33 refuse", () => { assert.ok(Array.isArray(parseStrict(bytes("[".repeat(32) + "]".repeat(32))))); assert.ok(Array.isArray(parseStrict(bytes("[".repeat(32) + "1" + "]".repeat(32))))); rejects("[".repeat(33) + "]".repeat(33), "depth-exceeded", 32); rejects("[".repeat(33) + "1" + "]".repeat(33), "depth-exceeded", 32); assert.ok(parseStrict(bytes('{"a":'.repeat(32) + "1" + "}".repeat(32)))); rejects('{"a":'.repeat(33) + "1" + "}".repeat(33), "depth-exceeded"); }); test("array and object item bounds: 1024 accepted, 1025 refused", () => { assert.equal(parseStrict(bytes(`[${new Array(1024).fill("0").join(",")}]`)).length, 1024); rejects(`[${new Array(1025).fill("0").join(",")}]`, "array-too-long", 0); const keys = (n) => `{${Array.from({ length: n }, (_, i) => `"k${i}":0`).join(",")}}`; assert.equal(Object.keys(parseStrict(bytes(keys(1024)))).length, 1024); rejects(keys(1025), "object-too-large", 0); }); test("input bound: exactly 1 MiB parses, one more byte refuses before any parsing", () => { const full = new Uint8Array(MAX_INPUT_BYTES).fill(0x20); full[0] = 0x5b; full[full.length - 1] = 0x5d; assert.deepEqual(parseStrict(full), []); const over = new Uint8Array(MAX_INPUT_BYTES + 1).fill(0x20); rejects(over, "input-too-large", 0); assert.throws(() => parseStrict("not bytes"), (e) => e instanceof StrictJsonError && e.code === "input-not-bytes"); }); test("utf8Encode round-trips through the parser and refuses nothing silently", () => { const text = '{"x": "café \u{1F600}"}'; assert.equal(parseStrict(utf8Encode(text)).x, "café \u{1F600}"); });