/** * Strict JSON parser for the foundation synthetic inspector (pure module). * * Inspector input profile (charter candidate 3 §6, §10.5), stricter than RFC 8259: * - byte-level lexer over a Uint8Array; every error carries a byte offset * - UTF-8 is decoded fatally: overlong forms, surrogate code points, * values above U+10FFFF and truncated sequences refuse * - duplicate object keys refuse * - numbers are integer tokens only: no fraction, no exponent, no "-0"; * the digit string is bounded against 2^53-1 BEFORE Number conversion * - strings are bounded at MAX_STRING_BYTES UTF-8 bytes; escaped surrogates * must form a valid pair (an unpaired \uD800 refuses) * - arrays and objects are bounded at MAX_ITEMS members; nesting at MAX_DEPTH * - objects are built with a null prototype, so "__proto__" and * "constructor" are ordinary own keys and no setter ever runs * - no BOM, no trailing content, JSON whitespace only * * This module imports nothing and never touches the filesystem, the process, * the environment, the clock or the network. This lexical profile is an * inspector rule; it is not a change to candidate JSON Schema semantics. */ export const MAX_DEPTH = 32; export const MAX_ITEMS = 1024; export const MAX_STRING_BYTES = 4096; export const MAX_INPUT_BYTES = 1024 * 1024; const MAX_SAFE_DIGITS = "9007199254740991"; // 2^53 - 1, exact decimal export class StrictJsonError extends Error { constructor(code, byteOffset) { super(code); this.name = "StrictJsonError"; this.code = code; this.byteOffset = byteOffset; } } function fail(code, offset) { throw new StrictJsonError(code, offset); } function isWhitespace(b) { return b === 0x20 || b === 0x09 || b === 0x0a || b === 0x0d; } function isDigit(b) { return b >= 0x30 && b <= 0x39; } function hexValue(b) { if (b >= 0x30 && b <= 0x39) return b - 0x30; if (b >= 0x41 && b <= 0x46) return b - 0x41 + 10; if (b >= 0x61 && b <= 0x66) return b - 0x61 + 10; return -1; } /** Decimal digit string (no sign) exceeds 2^53-1? Compared exactly, without conversion. */ function exceedsSafeInteger(digits) { if (digits.length !== MAX_SAFE_DIGITS.length) return digits.length > MAX_SAFE_DIGITS.length; return digits > MAX_SAFE_DIGITS; } /** * Parse `bytes` (Uint8Array) strictly. Returns the parsed value. * Throws StrictJsonError {code, byteOffset} on any violation. */ export function parseStrict(bytes) { if (!(bytes instanceof Uint8Array)) fail("input-not-bytes", 0); if (bytes.length > MAX_INPUT_BYTES) fail("input-too-large", 0); const n = bytes.length; let pos = 0; function skipWhitespace() { while (pos < n && isWhitespace(bytes[pos])) pos += 1; } function expectLiteral(text, value) { const start = pos; for (let i = 0; i < text.length; i += 1) { if (pos >= n || bytes[pos] !== text.charCodeAt(i)) fail("unexpected-token", start); pos += 1; } return value; } function parseNumber() { const start = pos; let negative = false; if (bytes[pos] === 0x2d) { negative = true; pos += 1; } if (pos >= n || !isDigit(bytes[pos])) fail("number-invalid", start); const digitStart = pos; if (bytes[pos] === 0x30) { pos += 1; if (pos < n && isDigit(bytes[pos])) fail("number-leading-zero", start); } else { while (pos < n && isDigit(bytes[pos])) pos += 1; } if (pos < n && (bytes[pos] === 0x2e || bytes[pos] === 0x65 || bytes[pos] === 0x45)) { fail("number-not-integer", start); } let digits = ""; for (let i = digitStart; i < pos; i += 1) digits += String.fromCharCode(bytes[i]); if (negative && digits === "0") fail("number-negative-zero", start); if (exceedsSafeInteger(digits)) fail("number-unsafe-integer", start); const value = Number(digits); return negative ? -value : value; } /** Decode one UTF-8 encoded code point at pos (fatal). Returns the code point. */ function decodeUtf8(start) { const b0 = bytes[pos]; if (b0 < 0x80) { pos += 1; return b0; } let need; let cp; let min; if (b0 >= 0xc2 && b0 <= 0xdf) { need = 1; cp = b0 & 0x1f; min = 0x80; } else if (b0 >= 0xe0 && b0 <= 0xef) { need = 2; cp = b0 & 0x0f; min = 0x800; } else if (b0 >= 0xf0 && b0 <= 0xf4) { need = 3; cp = b0 & 0x07; min = 0x10000; } else { fail("utf8-invalid", pos); } for (let i = 1; i <= need; i += 1) { const b = bytes[pos + i]; if (b === undefined) fail("utf8-truncated", pos); if ((b & 0xc0) !== 0x80) fail("utf8-invalid", pos); cp = (cp << 6) | (b & 0x3f); } if (cp < min) fail("utf8-overlong", pos); if (cp >= 0xd800 && cp <= 0xdfff) fail("utf8-surrogate", pos); if (cp > 0x10ffff) fail("utf8-out-of-range", pos); pos += need + 1; return cp; } function utf8Length(cp) { if (cp < 0x80) return 1; if (cp < 0x800) return 2; if (cp < 0x10000) return 3; return 4; } function parseHex4(at) { if (at + 4 > n) fail("escape-truncated", at); let v = 0; for (let i = 0; i < 4; i += 1) { const h = hexValue(bytes[at + i]); if (h < 0) fail("escape-invalid", at); v = (v << 4) | h; } return v; } function parseString() { const start = pos; pos += 1; // opening quote let out = ""; let byteLen = 0; for (;;) { if (pos >= n) fail("string-unterminated", start); const b = bytes[pos]; if (b === 0x22) { pos += 1; return out; } let cp; if (b === 0x5c) { const escAt = pos; pos += 1; if (pos >= n) fail("escape-truncated", escAt); const e = bytes[pos]; pos += 1; switch (e) { case 0x22: cp = 0x22; break; case 0x5c: cp = 0x5c; break; case 0x2f: cp = 0x2f; break; case 0x62: cp = 0x08; break; case 0x66: cp = 0x0c; break; case 0x6e: cp = 0x0a; break; case 0x72: cp = 0x0d; break; case 0x74: cp = 0x09; break; case 0x75: { const unit = parseHex4(pos); pos += 4; if (unit >= 0xd800 && unit <= 0xdbff) { // must be followed by \uDC00-\uDFFF if (pos + 6 > n || bytes[pos] !== 0x5c || bytes[pos + 1] !== 0x75) { fail("escape-unpaired-surrogate", escAt); } const low = parseHex4(pos + 2); if (low < 0xdc00 || low > 0xdfff) fail("escape-unpaired-surrogate", escAt); pos += 6; cp = 0x10000 + ((unit - 0xd800) << 10) + (low - 0xdc00); } else if (unit >= 0xdc00 && unit <= 0xdfff) { fail("escape-unpaired-surrogate", escAt); } else { cp = unit; } break; } default: fail("escape-invalid", escAt); } } else if (b < 0x20) { fail("string-control-character", pos); } else { cp = decodeUtf8(pos); } byteLen += utf8Length(cp); if (byteLen > MAX_STRING_BYTES) fail("string-too-long", start); out += String.fromCodePoint(cp); } } function parseValue(depth) { skipWhitespace(); if (pos >= n) fail("unexpected-end", pos); const b = bytes[pos]; // Nesting counts containers only: a scalar inside the 32nd container is depth 32. if (b === 0x7b || b === 0x5b) { if (depth > MAX_DEPTH) fail("depth-exceeded", pos); return b === 0x7b ? parseObject(depth) : parseArray(depth); } if (b === 0x22) return parseString(); if (b === 0x2d || isDigit(b)) return parseNumber(); if (b === 0x74) return expectLiteral("true", true); if (b === 0x66) return expectLiteral("false", false); if (b === 0x6e) return expectLiteral("null", null); fail("unexpected-token", pos); return undefined; } function parseArray(depth) { const start = pos; pos += 1; const out = []; skipWhitespace(); if (pos < n && bytes[pos] === 0x5d) { pos += 1; return out; } for (;;) { if (out.length >= MAX_ITEMS) fail("array-too-long", start); out.push(parseValue(depth + 1)); skipWhitespace(); if (pos >= n) fail("unexpected-end", pos); const b = bytes[pos]; if (b === 0x2c) { pos += 1; continue; } if (b === 0x5d) { pos += 1; return out; } fail("unexpected-token", pos); } } function parseObject(depth) { const start = pos; pos += 1; const out = Object.create(null); let count = 0; skipWhitespace(); if (pos < n && bytes[pos] === 0x7d) { pos += 1; return out; } for (;;) { skipWhitespace(); if (pos >= n) fail("unexpected-end", pos); if (bytes[pos] !== 0x22) fail("object-key-not-string", pos); const keyAt = pos; const key = parseString(); if (Object.prototype.hasOwnProperty.call(out, key)) fail("duplicate-key", keyAt); skipWhitespace(); if (pos >= n || bytes[pos] !== 0x3a) fail("unexpected-token", pos); pos += 1; if (count >= MAX_ITEMS) fail("object-too-large", start); const value = parseValue(depth + 1); // Null-prototype object: plain assignment defines an own data property; // there is no inherited __proto__ accessor to invoke. out[key] = value; count += 1; skipWhitespace(); if (pos >= n) fail("unexpected-end", pos); const b = bytes[pos]; if (b === 0x2c) { pos += 1; continue; } if (b === 0x7d) { pos += 1; return out; } fail("unexpected-token", pos); } } const value = parseValue(1); skipWhitespace(); if (pos < n) fail("trailing-content", pos); return value; } /** Test helper: encode a JS string as UTF-8 bytes without TextEncoder (pure). */ export function utf8Encode(text) { const out = []; for (const ch of text) { const cp = ch.codePointAt(0); if (cp < 0x80) out.push(cp); else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f)); else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f)); } return Uint8Array.from(out); }