From 8959ada684da90263e3a63dc7b233dba5f96bf33 Mon Sep 17 00:00:00 2001 From: Guillaume Fossier Date: Tue, 27 Oct 2026 20:25:19 +0100 Subject: [PATCH] test(oracles): freeze canonical JSON edge cases as differential oracles Freezes JSON inputs with the exact canonical text and digest the TypeScript implementation produces, or its refusal, where another implementation most easily diverges: key order by UTF-16 code unit, ECMAScript number formatting, string escaping, lone surrogates and non-finite numbers. The digest is also checked against an independent SHA-256 of the canonical UTF-8 bytes. Refs #84 Co-Authored-By: Claude Opus 5.5 --- tests/edge-oracles.test.ts | 66 ++++++++++++++ tests/edge-oracles/canonical-json.json | 121 +++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tests/edge-oracles.test.ts create mode 100644 tests/edge-oracles/canonical-json.json 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 + } + ] +}