|
| 1 | +// Copyright 2018-2026 the Deno authors. MIT license. |
| 2 | +// This module is browser compatible. |
| 3 | + |
| 4 | +import type { JsonPrimitive, JsonValue } from "./types.ts"; |
| 5 | + |
| 6 | +/** |
| 7 | + * Serializes a primitive JSON value (null, boolean, number, string) to its |
| 8 | + * canonical string representation per RFC 8785. |
| 9 | + */ |
| 10 | +function serializePrimitive(value: JsonPrimitive): string { |
| 11 | + // JSON.stringify handles null, boolean, and string correctly per RFC 8785 |
| 12 | + if (typeof value !== "number") return JSON.stringify(value); |
| 13 | + |
| 14 | + // RFC 8785 Section 3.2.2.3: Numbers must conform to I-JSON (RFC 7493) |
| 15 | + if (!Number.isFinite(value)) { |
| 16 | + throw new TypeError( |
| 17 | + `Cannot canonicalize non-finite number: ${value} is not allowed in I-JSON`, |
| 18 | + ); |
| 19 | + } |
| 20 | + // Handle -0 as "0" (RFC 8785 Section 3.2.2.3) |
| 21 | + if (Object.is(value, -0)) return "0"; |
| 22 | + // ECMAScript Number-to-String for all other numbers |
| 23 | + return value.toString(); |
| 24 | +} |
| 25 | + |
| 26 | +/** |
| 27 | + * Serializes an array to its canonical string representation. |
| 28 | + * Undefined elements become null (standard JSON behavior). |
| 29 | + */ |
| 30 | +function serializeArray(value: JsonValue[], ancestors: object[]): string { |
| 31 | + if (value.length === 0) return "[]"; |
| 32 | + |
| 33 | + const parts: string[] = []; |
| 34 | + for (const elem of value) { |
| 35 | + parts.push(elem === undefined ? "null" : serializeValue(elem, ancestors)); |
| 36 | + } |
| 37 | + return "[" + parts.join(",") + "]"; |
| 38 | +} |
| 39 | + |
| 40 | +/** |
| 41 | + * Serializes an object to its canonical string representation. |
| 42 | + * Keys are sorted by UTF-16 code units (RFC 8785 Section 3.2.3). |
| 43 | + * Undefined values are skipped (standard JSON behavior, RFC 8785 Section 3.1). |
| 44 | + */ |
| 45 | +function serializeObject( |
| 46 | + value: { [key: string]: JsonValue | undefined }, |
| 47 | + ancestors: object[], |
| 48 | +): string { |
| 49 | + // Default sort uses UTF-16 code unit comparison per RFC 8785 |
| 50 | + const keys = Object.keys(value).sort(); |
| 51 | + |
| 52 | + const parts: string[] = []; |
| 53 | + for (const key of keys) { |
| 54 | + const propValue = value[key]; |
| 55 | + if (propValue === undefined) continue; |
| 56 | + parts.push( |
| 57 | + JSON.stringify(key) + ":" + serializeValue(propValue, ancestors), |
| 58 | + ); |
| 59 | + } |
| 60 | + |
| 61 | + return "{" + parts.join(",") + "}"; |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Recursively serializes a JSON value to its canonical string representation. |
| 66 | + * |
| 67 | + * @param value The JSON value to serialize |
| 68 | + * @param ancestors Stack of ancestor objects for cycle detection |
| 69 | + */ |
| 70 | +function serializeValue(value: JsonValue, ancestors: object[] = []): string { |
| 71 | + if (value === null) return "null"; |
| 72 | + if (typeof value !== "object") return serializePrimitive(value); |
| 73 | + |
| 74 | + // Circular reference detection: check if this object is an ancestor |
| 75 | + if (ancestors.includes(value)) { |
| 76 | + throw new TypeError("Converting circular structure to JSON"); |
| 77 | + } |
| 78 | + ancestors.push(value); |
| 79 | + |
| 80 | + const result = Array.isArray(value) |
| 81 | + ? serializeArray(value, ancestors) |
| 82 | + : serializeObject(value, ancestors); |
| 83 | + |
| 84 | + ancestors.pop(); |
| 85 | + return result; |
| 86 | +} |
| 87 | + |
| 88 | +/** |
| 89 | + * Serializes a JSON value to a canonical string per |
| 90 | + * {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} JSON |
| 91 | + * Canonicalization Scheme (JCS). |
| 92 | + * |
| 93 | + * This produces a deterministic JSON string suitable for hashing or signing, |
| 94 | + * with object keys sorted lexicographically by UTF-16 code units and no |
| 95 | + * whitespace between tokens. |
| 96 | + * |
| 97 | + * Note: The input must be JSON-compatible data. Objects with `toJSON()` methods |
| 98 | + * (like `Date`) should be converted to their JSON representation first. |
| 99 | + * |
| 100 | + * @experimental **UNSTABLE**: New API, yet to be vetted. |
| 101 | + * |
| 102 | + * @param value The JSON value to canonicalize. |
| 103 | + * @returns The canonical JSON string. |
| 104 | + * |
| 105 | + * @example Basic usage (RFC 8785 Appendix E inspired) |
| 106 | + * ```ts |
| 107 | + * import { canonicalize } from "@std/json/unstable-canonicalize"; |
| 108 | + * import { assertEquals } from "@std/assert"; |
| 109 | + * |
| 110 | + * // Keys are sorted lexicographically, no whitespace between tokens |
| 111 | + * const data = { |
| 112 | + * time: "2019-01-28T07:45:10Z", |
| 113 | + * big: "055", |
| 114 | + * val: 3.5, |
| 115 | + * }; |
| 116 | + * assertEquals(canonicalize(data), '{"big":"055","time":"2019-01-28T07:45:10Z","val":3.5}'); |
| 117 | + * ``` |
| 118 | + * |
| 119 | + * @example Number serialization (RFC 8785 Section 3.2.2.3) |
| 120 | + * ```ts |
| 121 | + * import { canonicalize } from "@std/json/unstable-canonicalize"; |
| 122 | + * import { assertEquals } from "@std/assert"; |
| 123 | + * |
| 124 | + * // Numbers follow ECMAScript serialization rules |
| 125 | + * assertEquals(canonicalize(10.0), "10"); // No unnecessary decimals |
| 126 | + * assertEquals(canonicalize(1e21), "1e+21"); // Scientific notation for large |
| 127 | + * assertEquals(canonicalize(0.0000001), "1e-7"); // Scientific notation for small |
| 128 | + * assertEquals(canonicalize(-0), "0"); // Negative zero becomes "0" |
| 129 | + * ``` |
| 130 | + * |
| 131 | + * @example Key sorting by UTF-16 code units (RFC 8785 Section 3.2.3) |
| 132 | + * ```ts |
| 133 | + * import { canonicalize } from "@std/json/unstable-canonicalize"; |
| 134 | + * import { assertEquals } from "@std/assert"; |
| 135 | + * |
| 136 | + * // Keys sorted by UTF-16 code units: digits < uppercase < lowercase |
| 137 | + * const data = { a: 1, A: 2, "1": 3 }; |
| 138 | + * assertEquals(canonicalize(data), '{"1":3,"A":2,"a":1}'); |
| 139 | + * ``` |
| 140 | + * |
| 141 | + * @throws {TypeError} If the value contains non-finite numbers (Infinity, -Infinity, NaN). |
| 142 | + * @throws {TypeError} If the value contains circular references. |
| 143 | + * |
| 144 | + * @see {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} |
| 145 | + */ |
| 146 | +export function canonicalize(value: JsonValue): string { |
| 147 | + return serializeValue(value); |
| 148 | +} |
| 149 | + |
| 150 | +/** |
| 151 | + * Serializes a JSON value to canonical UTF-8 bytes per |
| 152 | + * {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} JSON |
| 153 | + * Canonicalization Scheme (JCS). |
| 154 | + * |
| 155 | + * This is equivalent to `new TextEncoder().encode(canonicalize(value))` and |
| 156 | + * is provided as a convenience for cryptographic operations that require |
| 157 | + * byte input. |
| 158 | + * |
| 159 | + * @experimental **UNSTABLE**: New API, yet to be vetted. |
| 160 | + * |
| 161 | + * @param value The JSON value to canonicalize. |
| 162 | + * @returns The canonical JSON as UTF-8 bytes. |
| 163 | + * |
| 164 | + * @example Creating a verifiable hash |
| 165 | + * ```ts |
| 166 | + * import { canonicalizeToBytes } from "@std/json/unstable-canonicalize"; |
| 167 | + * import { encodeHex } from "@std/encoding/hex"; |
| 168 | + * import { assertEquals } from "@std/assert"; |
| 169 | + * |
| 170 | + * // Create a deterministic hash of JSON data for verification |
| 171 | + * const payload = { action: "transfer", amount: 100, to: "alice" }; |
| 172 | + * const bytes = canonicalizeToBytes(payload); |
| 173 | + * const hashBuffer = await crypto.subtle.digest("SHA-256", bytes); |
| 174 | + * const hash = encodeHex(new Uint8Array(hashBuffer)); |
| 175 | + * |
| 176 | + * // Same hash regardless of original key order |
| 177 | + * const reordered = { to: "alice", action: "transfer", amount: 100 }; |
| 178 | + * const reorderedBytes = canonicalizeToBytes(reordered); |
| 179 | + * const reorderedHash = encodeHex( |
| 180 | + * new Uint8Array(await crypto.subtle.digest("SHA-256", reorderedBytes)), |
| 181 | + * ); |
| 182 | + * |
| 183 | + * assertEquals(hash, reorderedHash); |
| 184 | + * ``` |
| 185 | + * |
| 186 | + * @throws {TypeError} If the value contains non-finite numbers (Infinity, -Infinity, NaN). |
| 187 | + * @throws {TypeError} If the value contains circular references. |
| 188 | + * |
| 189 | + * @see {@link https://www.rfc-editor.org/rfc/rfc8785 | RFC 8785} |
| 190 | + */ |
| 191 | +export function canonicalizeToBytes(value: JsonValue): Uint8Array { |
| 192 | + return new TextEncoder().encode(canonicalize(value)); |
| 193 | +} |
0 commit comments