266 lines
7.3 KiB
TypeScript
266 lines
7.3 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
|
|
export function canonicalize(value: unknown): string {
|
|
return renderCanonical(assertJsonValue(value));
|
|
}
|
|
|
|
export function hash(value: unknown): string {
|
|
return createHash("sha256").update(canonicalize(value)).digest("hex");
|
|
}
|
|
|
|
function renderCanonical(value: JsonValue): string {
|
|
if (value === null || typeof value === "boolean" || typeof value === "string") {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
return renderCanonicalNumber(value);
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return `[${value.map((item) => renderCanonical(item)).join(",")}]`;
|
|
}
|
|
|
|
return `{${Object.keys(value)
|
|
.sort()
|
|
.map((key) => `${JSON.stringify(key)}:${renderCanonical(value[key] as JsonValue)}`)
|
|
.join(",")}}`;
|
|
}
|
|
|
|
function assertJsonValue(value: unknown): JsonValue {
|
|
if (
|
|
value === null ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "string" ||
|
|
Array.isArray(value)
|
|
) {
|
|
if (Array.isArray(value)) {
|
|
assertOnlyArrayIndexKeys(value);
|
|
const arrayValue: JsonValue[] = [];
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
if (!(index in value)) {
|
|
throw new TypeError(`Cannot canonicalize sparse array at index ${index}`);
|
|
}
|
|
|
|
arrayValue.push(assertJsonValue(value[index]));
|
|
}
|
|
|
|
return arrayValue;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "number") {
|
|
if (!Number.isFinite(value)) {
|
|
throw new TypeError("Cannot canonicalize non-finite numbers");
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === "object") {
|
|
const prototype = Object.getPrototypeOf(value);
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
throw new TypeError("Cannot canonicalize non-plain object");
|
|
}
|
|
|
|
assertOnlyEnumerableStringKeys(value);
|
|
const objectValue: Record<string, JsonValue> = Object.create(null) as Record<string, JsonValue>;
|
|
|
|
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
|
if (childValue === undefined) {
|
|
throw new TypeError(`Cannot canonicalize undefined at key ${key}`);
|
|
}
|
|
|
|
objectValue[key] = assertJsonValue(childValue);
|
|
}
|
|
|
|
return objectValue;
|
|
}
|
|
|
|
throw new TypeError(`Cannot canonicalize ${typeof value}`);
|
|
}
|
|
|
|
function assertOnlyEnumerableStringKeys(value: object) {
|
|
const enumerableStringKeys = Object.keys(value);
|
|
const ownKeys = Reflect.ownKeys(value);
|
|
const hasOnlyEnumerableStringKeys =
|
|
ownKeys.length === enumerableStringKeys.length &&
|
|
ownKeys.every(
|
|
(key) => typeof key === "string" && Object.prototype.propertyIsEnumerable.call(value, key),
|
|
);
|
|
|
|
if (!hasOnlyEnumerableStringKeys) {
|
|
throw new TypeError("Cannot canonicalize non-enumerable or symbol object keys");
|
|
}
|
|
}
|
|
|
|
function assertOnlyArrayIndexKeys(value: unknown[]) {
|
|
for (const key of Reflect.ownKeys(value)) {
|
|
if (key === "length") {
|
|
continue;
|
|
}
|
|
|
|
if (typeof key !== "string" || !Object.prototype.propertyIsEnumerable.call(value, key)) {
|
|
throw new TypeError("Cannot canonicalize non-index array object keys");
|
|
}
|
|
|
|
const index = Number(key);
|
|
if (!Number.isInteger(index) || index < 0 || index >= value.length || String(index) !== key) {
|
|
throw new TypeError("Cannot canonicalize non-index array object keys");
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderCanonicalNumber(value: number): string {
|
|
if (Object.is(value, -0) || value === 0) {
|
|
return "0";
|
|
}
|
|
|
|
const candidates = new Set<string>();
|
|
addNumberCandidate(candidates, value, value.toString());
|
|
const jsonCandidate = JSON.stringify(value);
|
|
if (jsonCandidate !== undefined) {
|
|
addNumberCandidate(candidates, value, jsonCandidate);
|
|
}
|
|
|
|
for (let precision = 1; precision <= 17; precision += 1) {
|
|
addNumberCandidate(candidates, value, value.toPrecision(precision));
|
|
addNumberCandidate(candidates, value, value.toExponential(precision - 1));
|
|
}
|
|
|
|
const [best] = [...candidates].sort(
|
|
(left, right) => left.length - right.length || compareCodeUnits(left, right),
|
|
);
|
|
if (!best) {
|
|
throw new TypeError(`Cannot canonicalize number ${value}`);
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
function addNumberCandidate(candidates: Set<string>, value: number, raw: string) {
|
|
const candidate = normalizeNumberLiteral(raw);
|
|
for (const equivalent of expandNumberLiteral(candidate)) {
|
|
if (Number(equivalent) === value) {
|
|
candidates.add(equivalent);
|
|
}
|
|
}
|
|
}
|
|
|
|
function compareCodeUnits(left: string, right: string) {
|
|
if (left < right) {
|
|
return -1;
|
|
}
|
|
|
|
if (left > right) {
|
|
return 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
function normalizeNumberLiteral(raw: string): string {
|
|
const [mantissaText, exponentText] = raw.toLowerCase().split("e");
|
|
const mantissa = normalizeDecimal(mantissaText ?? "");
|
|
if (exponentText === undefined) {
|
|
return mantissa;
|
|
}
|
|
|
|
const exponent = normalizeExponent(exponentText);
|
|
if (exponent === "0") {
|
|
return mantissa;
|
|
}
|
|
|
|
return `${mantissa}e${exponent}`;
|
|
}
|
|
|
|
function normalizeDecimal(raw: string): string {
|
|
if (!raw.includes(".")) {
|
|
return raw;
|
|
}
|
|
|
|
const trimmed = raw.replace(/0+$/, "").replace(/\.$/, "");
|
|
if (trimmed === "-0") {
|
|
return "0";
|
|
}
|
|
|
|
return trimmed;
|
|
}
|
|
|
|
function normalizeExponent(raw: string): string {
|
|
const sign = raw.startsWith("-") ? "-" : "";
|
|
const unsigned = raw.replace(/^[+-]/, "").replace(/^0+/, "");
|
|
if (unsigned === "") {
|
|
return "0";
|
|
}
|
|
|
|
return `${sign}${unsigned}`;
|
|
}
|
|
|
|
function expandNumberLiteral(raw: string): string[] {
|
|
const parsed = parseNumberLiteral(raw);
|
|
if (!parsed) {
|
|
return [raw];
|
|
}
|
|
|
|
const plain = renderPlainDecimal(parsed);
|
|
const candidates = new Set([plain]);
|
|
|
|
for (let integerDigits = 1; integerDigits <= parsed.digits.length; integerDigits += 1) {
|
|
const exponent = parsed.power + parsed.digits.length - integerDigits;
|
|
if (exponent === 0) {
|
|
continue;
|
|
}
|
|
|
|
const mantissa =
|
|
integerDigits === parsed.digits.length
|
|
? parsed.digits
|
|
: `${parsed.digits.slice(0, integerDigits)}.${parsed.digits.slice(integerDigits)}`;
|
|
candidates.add(`${parsed.sign}${mantissa}e${exponent}`);
|
|
}
|
|
|
|
return [...candidates];
|
|
}
|
|
|
|
function parseNumberLiteral(raw: string) {
|
|
const match = raw.match(/^(-?)(\d+)(?:\.(\d+))?(?:e(-?\d+))?$/);
|
|
if (!match) {
|
|
return undefined;
|
|
}
|
|
|
|
const [, sign, integerPart, fractionalPart = "", exponentPart] = match;
|
|
let digits = `${integerPart}${fractionalPart}`;
|
|
let power = (exponentPart === undefined ? 0 : Number(exponentPart)) - fractionalPart.length;
|
|
digits = digits.replace(/^0+/, "");
|
|
if (digits === "") {
|
|
return { sign: "", digits: "0", power: 0 };
|
|
}
|
|
|
|
const lengthBeforeTrailingTrim = digits.length;
|
|
digits = digits.replace(/0+$/, "");
|
|
power += lengthBeforeTrailingTrim - digits.length;
|
|
|
|
return { sign: sign ?? "", digits, power };
|
|
}
|
|
|
|
function renderPlainDecimal(parsed: { sign: string; digits: string; power: number }) {
|
|
if (parsed.digits === "0") {
|
|
return "0";
|
|
}
|
|
|
|
if (parsed.power >= 0) {
|
|
return `${parsed.sign}${parsed.digits}${"0".repeat(parsed.power)}`;
|
|
}
|
|
|
|
const pointIndex = parsed.digits.length + parsed.power;
|
|
if (pointIndex > 0) {
|
|
return `${parsed.sign}${parsed.digits.slice(0, pointIndex)}.${parsed.digits.slice(pointIndex)}`;
|
|
}
|
|
|
|
return `${parsed.sign}0.${"0".repeat(-pointIndex)}${parsed.digits}`;
|
|
}
|