Files
aiturk-hermes-ide/apps/desktop/codex-link-json.ts
T

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
}