Rocko-authored, Filbert-reviewed inspector (r6 manifest a4a44930...) with full review/build/verdict evidence under docs/plans/reviews. 43/0 selftests, oracle zero-disagreement, foundation checker PASS. Owner A9 acceptance recorded separately.
146 lines
6.7 KiB
JavaScript
146 lines
6.7 KiB
JavaScript
// 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}");
|
|
});
|