260 lines
6.8 KiB
TypeScript
260 lines
6.8 KiB
TypeScript
// Strict P1 JSON domain/parser, shared algorithm with router codexLinkValidation.
|
|
// Browser-safe; never apply to the opaque native/provider JSON body.
|
|
type Data = Record<string, any>
|
|
|
|
export class CodexLinkValidationError extends Error {
|
|
readonly code: string
|
|
constructor(code: string) {
|
|
super(code)
|
|
this.code = code
|
|
this.name = 'CodexLinkValidationError'
|
|
}
|
|
}
|
|
|
|
function requireValue(condition: unknown, code: string): asserts condition {
|
|
if (!condition) {
|
|
throw new CodexLinkValidationError(code)
|
|
}
|
|
}
|
|
|
|
function object(value: unknown): Data {
|
|
requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), 'schema_rejected')
|
|
const prototype = Object.getPrototypeOf(value)
|
|
requireValue(prototype === Object.prototype || prototype === null, 'schema_rejected')
|
|
|
|
return value as Data
|
|
}
|
|
|
|
function integer(value: unknown, positive = false): asserts value is number {
|
|
requireValue(Number.isSafeInteger(value) && (value as number) >= (positive ? 1 : 0), 'schema_rejected')
|
|
}
|
|
|
|
function id(value: unknown): asserts value is string {
|
|
requireValue(typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value), 'schema_rejected')
|
|
}
|
|
|
|
function exactKeys(value: Data, keys: string[]) {
|
|
requireValue(
|
|
Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key)),
|
|
'schema_rejected'
|
|
)
|
|
}
|
|
|
|
function unicode(value: string) {
|
|
for (let i = 0; i < value.length; i++) {
|
|
const code = value.charCodeAt(i)
|
|
|
|
if (code >= 0xd800 && code <= 0xdbff) {
|
|
const low = value.charCodeAt(++i)
|
|
requireValue(low >= 0xdc00 && low <= 0xdfff, 'invalid_unicode')
|
|
} else {
|
|
requireValue(code < 0xdc00 || code > 0xdfff, 'invalid_unicode')
|
|
}
|
|
}
|
|
}
|
|
|
|
/** P1 canonical domain: ASCII object keys, safe integers, exact Unicode text. */
|
|
export function canonicalJson(value: unknown): string {
|
|
const ancestors = new Set<object>()
|
|
|
|
function encode(item: unknown, depth: number): string {
|
|
requireValue(depth <= 256, 'invalid_json')
|
|
|
|
if (item === null || typeof item === 'boolean') {
|
|
return JSON.stringify(item)
|
|
}
|
|
|
|
if (typeof item === 'string') {
|
|
unicode(item)
|
|
|
|
return JSON.stringify(item)
|
|
}
|
|
|
|
if (typeof item === 'number') {
|
|
requireValue(Number.isInteger(item), 'floats_or_non_json_type')
|
|
requireValue(Number.isSafeInteger(item), 'unsafe_integer')
|
|
|
|
return JSON.stringify(item)
|
|
}
|
|
|
|
requireValue(typeof item === 'object' && item !== null, 'floats_or_non_json_type')
|
|
requireValue(!ancestors.has(item), 'invalid_json')
|
|
ancestors.add(item)
|
|
let result: string
|
|
|
|
if (Array.isArray(item)) {
|
|
result = '[' + item.map(child => encode(child, depth + 1)).join(',') + ']'
|
|
} else {
|
|
const data = object(item)
|
|
const keys = Object.keys(data).sort()
|
|
requireValue(
|
|
keys.every(key => [...key].every(char => char.charCodeAt(0) <= 127)),
|
|
'non_ascii_key'
|
|
)
|
|
result = '{' + keys.map(key => JSON.stringify(key) + ':' + encode(data[key], depth + 1)).join(',') + '}'
|
|
}
|
|
|
|
ancestors.delete(item)
|
|
|
|
return result
|
|
}
|
|
|
|
return encode(value, 0)
|
|
}
|
|
|
|
/** A bounded parser that detects duplicates before JSON.parse can erase them. */
|
|
export function parseStrictJson(raw: string | Uint8Array, maxBytes: number): unknown {
|
|
integer(maxBytes, true)
|
|
const rawBytes = typeof raw === 'string' ? new TextEncoder().encode(raw).byteLength : raw.byteLength
|
|
requireValue(rawBytes <= maxBytes, 'transport_bytes_exceeded')
|
|
let text: string
|
|
|
|
try {
|
|
text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw)
|
|
} catch {
|
|
throw new CodexLinkValidationError('invalid_json')
|
|
}
|
|
|
|
unicode(text)
|
|
let position = 0
|
|
|
|
function whitespace() {
|
|
while (/[ \t\r\n]/.test(text[position] ?? '') && position < text.length) {
|
|
position++
|
|
}
|
|
}
|
|
|
|
function quoted(): string {
|
|
const start = position++
|
|
|
|
while (position < text.length) {
|
|
const next = text[position++]
|
|
|
|
if (next === '\\') {
|
|
position++
|
|
|
|
continue
|
|
}
|
|
|
|
if (next === '"') {
|
|
try {
|
|
const value = JSON.parse(text.slice(start, position))
|
|
unicode(value)
|
|
|
|
return value
|
|
} catch (error) {
|
|
if (error instanceof CodexLinkValidationError) {
|
|
throw error
|
|
}
|
|
|
|
throw new CodexLinkValidationError('invalid_json')
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new CodexLinkValidationError('invalid_json')
|
|
}
|
|
|
|
function read(depth: number): unknown {
|
|
requireValue(depth <= 256, 'invalid_json')
|
|
whitespace()
|
|
const token = text[position]
|
|
|
|
if (token === '"') {
|
|
return quoted()
|
|
}
|
|
|
|
if (token === '{') {
|
|
position++
|
|
whitespace()
|
|
const value: Data = Object.create(null)
|
|
|
|
if (text[position] === '}') {
|
|
position++
|
|
|
|
return value
|
|
}
|
|
|
|
while (position < text.length) {
|
|
requireValue(text[position] === '"', 'invalid_json')
|
|
const key = quoted()
|
|
requireValue(
|
|
[...key].every(char => char.charCodeAt(0) <= 127),
|
|
'non_ascii_key'
|
|
)
|
|
requireValue(!Object.hasOwn(value, key), 'duplicate_json_key')
|
|
whitespace()
|
|
requireValue(text[position++] === ':', 'invalid_json')
|
|
value[key] = read(depth + 1)
|
|
whitespace()
|
|
const separator = text[position++]
|
|
|
|
if (separator === '}') {
|
|
return value
|
|
}
|
|
|
|
requireValue(separator === ',', 'invalid_json')
|
|
whitespace()
|
|
}
|
|
} else if (token === '[') {
|
|
position++
|
|
whitespace()
|
|
const value: unknown[] = []
|
|
|
|
if (text[position] === ']') {
|
|
position++
|
|
|
|
return value
|
|
}
|
|
|
|
while (position < text.length) {
|
|
value.push(read(depth + 1))
|
|
whitespace()
|
|
const separator = text[position++]
|
|
|
|
if (separator === ']') {
|
|
return value
|
|
}
|
|
|
|
requireValue(separator === ',', 'invalid_json')
|
|
}
|
|
} else {
|
|
for (const [word, value] of [
|
|
['true', true],
|
|
['false', false],
|
|
['null', null]
|
|
] as const) {
|
|
if (text.startsWith(word, position)) {
|
|
position += word.length
|
|
|
|
return value
|
|
}
|
|
}
|
|
|
|
if (/^(?:NaN|[-+]?Infinity)/.test(text.slice(position))) {
|
|
throw new CodexLinkValidationError('floats_or_non_json_type')
|
|
}
|
|
|
|
const match = /^-?(?:0|[1-9]\d*)/.exec(text.slice(position))
|
|
|
|
if (match) {
|
|
position += match[0].length
|
|
requireValue(!/[.eE]/.test(text[position] ?? ''), 'floats_or_non_json_type')
|
|
const number = Number(match[0])
|
|
requireValue(Number.isSafeInteger(number), 'unsafe_integer')
|
|
|
|
return number
|
|
}
|
|
}
|
|
|
|
throw new CodexLinkValidationError('invalid_json')
|
|
}
|
|
|
|
const value = read(0)
|
|
whitespace()
|
|
requireValue(position === text.length, 'invalid_json')
|
|
canonicalJson(value)
|
|
|
|
return value
|
|
}
|