Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions tests/edge-oracles.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
});
121 changes: 121 additions & 0 deletions tests/edge-oracles/canonical-json.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
Loading