Files
stack/scripts/foundation/strict-json.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

328 lines
10 KiB
JavaScript

/**
* 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);
}