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.
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
Reference in New Issue
Block a user