Files
cadSet/SimpleCADAPI/scene-contract/src/canonical.ts
T

267 lines
8.7 KiB
TypeScript

import { createHash } from "node:crypto";
export class DuplicateKeyError extends SyntaxError {}
const decoder = new TextDecoder("utf-8", { fatal: true });
const maxSafeInteger = 9_007_199_254_740_991;
const parserDepthCapacity = 256;
function assertUnicodeScalarString(value: string): void {
for (let index = 0; index < value.length; index += 1) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!Number.isInteger(next) || next < 0xdc00 || next > 0xdfff) {
throw new TypeError("string contains an unpaired surrogate");
}
index += 1;
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
throw new TypeError("string contains an unpaired surrogate");
}
}
}
class StrictJsonParser {
private offset = 0;
public constructor(private readonly text: string) {}
public parse(): unknown {
this.space();
const parsed = this.value(0);
this.space();
if (this.offset !== this.text.length) {
throw new SyntaxError("trailing data after JSON value");
}
return parsed;
}
private value(depth: number): unknown {
if (depth > parserDepthCapacity) {
throw new SyntaxError("JSON nesting exceeds parser capacity");
}
this.space();
const token = this.text[this.offset];
if (
this.text.startsWith("NaN", this.offset) ||
this.text.startsWith("Infinity", this.offset) ||
this.text.startsWith("-Infinity", this.offset)
) {
throw new SyntaxError("non-finite JSON number is forbidden");
}
if (token === "{") return this.object(depth + 1);
if (token === "[") return this.array(depth + 1);
if (token === '"') return this.string();
if (token === "t" && this.consume("true")) return true;
if (token === "f" && this.consume("false")) return false;
if (token === "n" && this.consume("null")) return null;
if (token === "-" || (token !== undefined && token >= "0" && token <= "9")) {
return this.number();
}
throw new SyntaxError(`invalid JSON token at offset ${this.offset}`);
}
private object(depth: number): Record<string, unknown> {
this.offset += 1;
this.space();
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
const keys = new Set<string>();
if (this.text[this.offset] === "}") {
this.offset += 1;
return result;
}
while (true) {
if (this.text[this.offset] !== '"') {
throw new SyntaxError(`JSON object key expected at offset ${this.offset}`);
}
const key = this.string();
if (keys.has(key)) {
throw new DuplicateKeyError(`duplicate JSON object member: ${JSON.stringify(key)}`);
}
keys.add(key);
this.space();
if (this.text[this.offset] !== ":") {
throw new SyntaxError(`JSON object colon expected at offset ${this.offset}`);
}
this.offset += 1;
const child = this.value(depth);
result[key] = child;
this.space();
const separator = this.text[this.offset];
this.offset += 1;
if (separator === "}") return result;
if (separator !== ",") {
throw new SyntaxError(`JSON object separator expected at offset ${this.offset - 1}`);
}
this.space();
}
}
private array(depth: number): unknown[] {
this.offset += 1;
this.space();
const result: unknown[] = [];
if (this.text[this.offset] === "]") {
this.offset += 1;
return result;
}
while (true) {
const child = this.value(depth);
result.push(child);
this.space();
const separator = this.text[this.offset];
this.offset += 1;
if (separator === "]") return result;
if (separator !== ",") {
throw new SyntaxError(`JSON array separator expected at offset ${this.offset - 1}`);
}
this.space();
}
}
private string(): string {
const start = this.offset;
this.offset += 1;
while (this.offset < this.text.length) {
const unit = this.text.charCodeAt(this.offset);
if (unit < 0x20) {
throw new SyntaxError(`unescaped control character at offset ${this.offset}`);
}
if (unit === 0x22) {
this.offset += 1;
const result = JSON.parse(this.text.slice(start, this.offset)) as string;
assertUnicodeScalarString(result);
return result;
}
if (unit === 0x5c) {
this.offset += 1;
const escape = this.text[this.offset];
if (escape === "u") {
if (!/^[0-9a-fA-F]{4}$/.test(this.text.slice(this.offset + 1, this.offset + 5))) {
throw new SyntaxError(`invalid JSON Unicode escape at offset ${this.offset}`);
}
this.offset += 5;
continue;
}
if (escape === undefined || !'"\\/bfnrt'.includes(escape)) {
throw new SyntaxError(`invalid JSON escape at offset ${this.offset}`);
}
}
this.offset += 1;
}
throw new SyntaxError("unterminated JSON string");
}
private number(): number {
const match = /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/.exec(
this.text.slice(this.offset),
);
if (match === null) throw new SyntaxError(`invalid JSON number at offset ${this.offset}`);
const token = match[0];
this.offset += token.length;
const result = Number(token);
if (!/[.eE]/.test(token) && Math.abs(result) > maxSafeInteger) {
throw new SyntaxError("JSON integer exceeds the JCS safe domain");
}
if (!Number.isFinite(result)) throw new SyntaxError("non-finite JSON number is forbidden");
return result;
}
private consume(token: string): boolean {
if (!this.text.startsWith(token, this.offset)) return false;
this.offset += token.length;
return true;
}
private space(): void {
while (
this.text[this.offset] === " " ||
this.text[this.offset] === "\n" ||
this.text[this.offset] === "\r" ||
this.text[this.offset] === "\t"
) {
this.offset += 1;
}
}
}
export function parseStrictJson(data: Uint8Array | string): unknown {
let text: string;
if (typeof data === "string") {
text = data;
} else {
if (data.length >= 3 && data[0] === 0xef && data[1] === 0xbb && data[2] === 0xbf) {
throw new SyntaxError("UTF-8 BOM is forbidden");
}
text = decoder.decode(data);
}
if (text.startsWith("\ufeff")) throw new SyntaxError("UTF-8 BOM is forbidden");
return new StrictJsonParser(text).parse();
}
function serialize(value: unknown, stack: Set<object>): string {
if (value === null) return "null";
if (typeof value === "boolean") return value ? "true" : "false";
if (typeof value === "number") {
if (!Number.isFinite(value)) throw new TypeError("JSON number must be finite");
return JSON.stringify(value);
}
if (typeof value === "string") {
assertUnicodeScalarString(value);
return JSON.stringify(value);
}
if (typeof value !== "object") throw new TypeError(`unsupported JSON value: ${typeof value}`);
if (stack.has(value)) throw new TypeError("cyclic value is not JSON");
stack.add(value);
let result: string;
if (Array.isArray(value)) {
const items: string[] = [];
for (let index = 0; index < value.length; index += 1) {
if (!Object.hasOwn(value, index)) throw new TypeError("sparse arrays are outside the JSON domain");
items.push(serialize(value[index], stack));
}
result = `[${items.join(",")}]`;
} else {
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort();
for (const key of keys) assertUnicodeScalarString(key);
result = `{${keys
.map((key) => `${JSON.stringify(key)}:${serialize(record[key], stack)}`)
.join(",")}}`;
}
stack.delete(value);
return result;
}
export function canonicalJsonBytes(value: unknown): Buffer {
return Buffer.from(serialize(value, new Set()), "utf8");
}
export function parseCanonicalJson(data: Uint8Array | string): unknown {
const raw = typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data);
const parsed = parseStrictJson(raw);
if (!raw.equals(canonicalJsonBytes(parsed))) {
throw new SyntaxError("JSON bytes are not RFC 8785 canonical");
}
return parsed;
}
export function sha256(data: Uint8Array): string {
return `sha256:${createHash("sha256").update(data).digest("hex")}`;
}
export function canonicalJsonHash(value: unknown): string {
return sha256(canonicalJsonBytes(value));
}
export function computeSceneRevision(scene: Record<string, unknown>): string {
const { revision: _revision, ...draft } = scene;
return canonicalJsonHash(draft);
}
export function withSceneRevision(scene: Record<string, unknown>): Record<string, unknown> {
const result = structuredClone(scene);
result.revision = computeSceneRevision(result);
return result;
}