Files
stack/scripts/foundation/strict-json.test.mjs
T
jason.woltje 8ebddd6f93 feat(foundation): offline synthetic scope/permission inspector (FI-FILBERT-8 APPROVED r6)
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.
2026-09-07 14:06:35 -05:00

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