diff --git a/tests/edge-oracles.test.ts b/tests/edge-oracles.test.ts new file mode 100644 index 0000000..1dc802f --- /dev/null +++ b/tests/edge-oracles.test.ts @@ -0,0 +1,66 @@ +/** Edge-case oracles for canonical JSON and its digest (ADR 0002). Each case freezes a JSON input text + * and the exact canonical text and digest the TypeScript implementation produces, or its refusal. + * The cases sit where another implementation most easily diverges: key order by UTF-16 code unit, + * ECMAScript number formatting, string escaping and lone surrogates. */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { canonicalize, sha256Canonical } from "../src/core/index.js"; +import { PACKAGE_ROOT } from "./support/goldens.js"; + +interface CanonicalCase { + id: string; + description: string; + /** JSON text, parsed as JSON.parse does before canonicalization. */ + input: string; + canonical?: string; + digest?: string; + refused?: true; +} + +/** The divergence traps a later implementation must face; removing one fails the suite. */ +const REQUIRED_CASES = [ + "key-order-utf16-code-units", + "number-negative-zero", + "number-exponent-forms", + "number-precision-limits", + "string-control-characters", + "string-lone-surrogates", + "duplicate-key-last-wins", + "number-overflow-refused", +]; + +const { cases } = JSON.parse( + await readFile(path.join(PACKAGE_ROOT, "tests", "edge-oracles", "canonical-json.json"), "utf8"), +) as { cases: CanonicalCase[] }; + +describe("canonical JSON edge-case oracles", () => { + it("keep every divergence trap, each case once", () => { + const ids = cases.map((entry) => entry.id); + assert.equal(new Set(ids).size, ids.length); + assert.deepEqual( + REQUIRED_CASES.filter((id) => !ids.includes(id)), + [], + ); + }); + + for (const entry of cases) { + it(`${entry.id}: ${entry.description}`, () => { + const value: unknown = JSON.parse(entry.input); + if (entry.refused === true) { + assert.equal(entry.canonical, undefined); + assert.throws(() => canonicalize(value), TypeError); + assert.throws(() => sha256Canonical(value), TypeError); + return; + } + assert.equal(canonicalize(value), entry.canonical); + assert.equal(sha256Canonical(value), entry.digest); + // The digest is SHA-256 over the UTF-8 bytes of the canonical text, checked independently. + const bytes = Buffer.from(entry.canonical as string, "utf8"); + assert.equal(entry.digest, `sha256:${createHash("sha256").update(bytes).digest("hex")}`); + assert.equal(canonicalize(JSON.parse(entry.canonical as string)), entry.canonical); + }); + } +}); diff --git a/tests/edge-oracles/canonical-json.json b/tests/edge-oracles/canonical-json.json new file mode 100644 index 0000000..01f5c09 --- /dev/null +++ b/tests/edge-oracles/canonical-json.json @@ -0,0 +1,121 @@ +{ + "cases": [ + { + "id": "key-order-utf16-code-units", + "description": "Keys sort by UTF-16 code unit: a supplementary character (surrogate D83D) sorts before U+FF61, unlike code point or UTF-8 byte order", + "input": "{\"\\uff61\":1,\"\\ud83d\\ude00\":2,\"z\":3}", + "canonical": "{\"z\":3,\"😀\":2,\"。\":1}", + "digest": "sha256:b38db409a4f5d29ec7b40f51996fc7fe30da3add890819be36128451797db392" + }, + { + "id": "key-order-ascii-case-and-empty", + "description": "The empty key first, then uppercase before lowercase, by code unit", + "input": "{\"b\":1,\"a\":2,\"B\":3,\"\":4,\"aa\":5,\"A\":6}", + "canonical": "{\"\":4,\"A\":6,\"B\":3,\"a\":2,\"aa\":5,\"b\":1}", + "digest": "sha256:19eff573dc4c3b367e7dbaf857ccf031d39f8d537e3db84af8f9ffa7ed025a5e" + }, + { + "id": "key-order-nested", + "description": "Every nested object is sorted; array order is kept", + "input": "{\"z\":{\"b\":[3,1,2],\"a\":null},\"a\":[{\"y\":1,\"x\":2}]}", + "canonical": "{\"a\":[{\"x\":2,\"y\":1}],\"z\":{\"a\":null,\"b\":[3,1,2]}}", + "digest": "sha256:4ce0510fa54b280364fe5bb9e01963d05104f7aa97651b14047305f04003fb30" + }, + { + "id": "key-order-latin1-vs-bmp", + "description": "U+00E9 sorts before U+0100 and after ASCII", + "input": "{\"\\u0100\":1,\"\\u00e9\":2,\"e\":3}", + "canonical": "{\"e\":3,\"é\":2,\"Ā\":1}", + "digest": "sha256:3bde177c327342f2dc6b1d899d188cb490b0cbc08dc8ae709dfe9faf1f33964d" + }, + { + "id": "number-negative-zero", + "description": "Negative zero serializes as 0", + "input": "[-0,0,-0.0]", + "canonical": "[0,0,0]", + "digest": "sha256:2a08979e8002252124e861b2816490b04fe03b6b779f9287fb7c25652bbc4052" + }, + { + "id": "number-exponent-forms", + "description": "Exponents follow the ECMAScript shortest round-trip form", + "input": "[1e21,1e20,1E2,1e-7,1e-6,-1e-7,1.5e300]", + "canonical": "[1e+21,100000000000000000000,100,1e-7,0.000001,-1e-7,1.5e+300]", + "digest": "sha256:75e82ba58534c6c70774f2f31e46c9271c10aef8feb74a179007353d9e148310" + }, + { + "id": "number-trailing-zeros", + "description": "Integral values drop the fraction", + "input": "[1.0,100.000,2.50]", + "canonical": "[1,100,2.5]", + "digest": "sha256:c0b56a212b52554f6ef7c931bf2b483dbb732a8c7e55786d7f6ce6de2a9c1adf" + }, + { + "id": "number-precision-limits", + "description": "Beyond 2^53 the nearest double prints in shortest form", + "input": "[9007199254740993,123456789012345678901,0.1,0.30000000000000004]", + "canonical": "[9007199254740992,123456789012345680000,0.1,0.30000000000000004]", + "digest": "sha256:7272c5888e5c4beb56187555a6e3979d081ab6f011f609a0c0a3f66de5f56a98" + }, + { + "id": "number-extremes", + "description": "Smallest subnormal and largest finite double", + "input": "[5e-324,1.7976931348623157e308,-1.7976931348623157e308]", + "canonical": "[5e-324,1.7976931348623157e+308,-1.7976931348623157e+308]", + "digest": "sha256:a0daade3bb88b0332d94e7bb5836c729ed03f513d94f3f302062994932e8bcdd" + }, + { + "id": "string-control-characters", + "description": "Control characters use lowercase \\u escapes except the short forms", + "input": "[\"\\u0000\\u0001\\u001f\",\"\\b\\f\\n\\r\\t\",\"\\u007f\"]", + "canonical": "[\"\\u0000\\u0001\\u001f\",\"\\b\\f\\n\\r\\t\",\"\"]", + "digest": "sha256:a2e028d940dfece7b4b5a6c944e1f1131d1f06c33b191fabf352b5c57285e32a" + }, + { + "id": "string-escapes-kept-minimal", + "description": "Solidus, U+2028 and U+2029 stay raw; quote and backslash are escaped", + "input": "[\"/\\u2028\\u2029\",\"\\\"\\\\\"]", + "canonical": "[\"/

\",\"\\\"\\\\\"]", + "digest": "sha256:6188aa29ea076d24a409b4123e2fd8426c295672e132c2f58450a4e038074abb" + }, + { + "id": "string-non-ascii-raw", + "description": "Non-ASCII characters stay raw and are hashed as UTF-8", + "input": "[\"\\u00e9t\\u00e9\",\"\\u4e2d\",\"\\ud83d\\ude00\"]", + "canonical": "[\"été\",\"中\",\"😀\"]", + "digest": "sha256:476a880b71b7c8d427006c0e22748ee609392b1a6fc50a48075c604f9b24646d" + }, + { + "id": "string-lone-surrogates", + "description": "Lone surrogates are written as lowercase \\u escapes, so the canonical text stays valid UTF-8", + "input": "[\"\\ud800\",\"\\udfff\",\"a\\ud83d\"]", + "canonical": "[\"\\ud800\",\"\\udfff\",\"a\\ud83d\"]", + "digest": "sha256:521ecb97b8aec48644991045b8b63f01a22493ee1c0dc01fd160b45f1ca10948" + }, + { + "id": "empty-containers", + "description": "Empty containers and scalars", + "input": "{\"a\":{},\"b\":[],\"c\":\"\",\"d\":false,\"e\":null,\"f\":[[],{}]}", + "canonical": "{\"a\":{},\"b\":[],\"c\":\"\",\"d\":false,\"e\":null,\"f\":[[],{}]}", + "digest": "sha256:ec62e09d829938ed8d89531d677ebad514f0fdbde588dfa1ecf21948e48d770c" + }, + { + "id": "duplicate-key-last-wins", + "description": "A duplicate key keeps its last value, as JSON.parse does", + "input": "{\"a\":1,\"a\":2}", + "canonical": "{\"a\":2}", + "digest": "sha256:7e8059f495589fcd981232cc11d00b00da3802c01d688fa1cf1f6bed6e5bb33c" + }, + { + "id": "number-overflow-refused", + "description": "A number that overflows to infinity is refused", + "input": "[1e400]", + "refused": true + }, + { + "id": "number-negative-overflow-refused", + "description": "A number that overflows to negative infinity is refused", + "input": "{\"a\":-1e400}", + "refused": true + } + ] +}