import { stableJSONStringify_fast } from './fast.js'; import { stableJSONStringify_portable } from './portable.js'; // For some signing operations, we need to be able to sign JSON objects in a deterministic and stable way. // `JSON.stringify` is not guaranteed to produce a stable output, so we provide our own implementation instead. // In general, there are several reasons why JSON serialization might not be stable: // * JSON allows insignificant whitespace to be inserted in various places. `JSON.stringify` does not do that though. // * The keys in an object can be serialized in any order. `JSON.stringify` is not stable in that regard because it (mostly) // respects the order in which the keys were added to the object (with the exception of keys that could be interpreted as // indices, which are serialized in numeric order and before all keys). // * The characters in a string can be escaped in various ways. For example, the character `\n` can be represented as `\n` or // as `\u000a` and the character `A` can be represented as `A` or as `\u0041`. The EcmaScript spec does not specify which // representation `JSON.stringify` should use. It seems that `JSON.stringify` in modern browsers uses the shortest // representation, but that is not guaranteed by the spec. // * Objects can provide a `toJSON` method that behaves in an arbitrary way. // * Numbers can be represented in various ways. For example, the number `0` can be represented as `0`, `-0`, `+0`, `0.0`, `-0.0`, // Our implementation of `stableJSONStringify` addresses most of these issues: // * It does not insert insignificant whitespace. // * It sorts the keys in an object in a canonical order defined below. // * It uses the shortest representation for each character in a string. // * It respects `toJSON` methods, but it cannot protect against `toJSON` implementations that behave in an arbitrary way. We assume // that we will serialize only standard EcmaScript objects such as `Date` which have well-defined `toJSON` methods. // * It does not attempt to canonicalize numbers, we rely on the fact the `toJSON` method of numbers returns a string that uses the // shortest representation. // Canonical order for object keys: // * Keys that could be interpreted as indices are sorted by numeric value and before all other keys. See `isIndexKey` for the definition. // * Other keys are sorted in lexicographic case-insensitive order, based on Unicode code points. See `compareObjectKeys` for the details. // The rationale for using this order instead of a lexicographical order on all keys is that it allows us to leverage // `JSON.stringify` to obtain a more performant implementation. // Shortest representation of characters in strings: // * We only escape the characters that need to be escaped according to RFC 8259. If a character has a two character escape sequence, // we use that escape sequence. All other characters are represented as a numeric \uXXXX escape sequence. In those sequences, we // always use lowercase hexadecimal digits. // * All other characters are represented as unescaped literal characters (i.e. they are represented as themselves). // We provide two implementations of `stableJSONStringify`: // * `stableJSONStringify_portable` is a portable but slower implementation. // * `stableJSONStringify_fast` is a faster implementation that partially relies on the native `JSON.stringify`. This implementation // is not guaranteed to produce the same output as `stableJSONStringify_portable` under all JavaScript engines. Then reason for that is // that `JSON.stringify` is not guaranteed to produce the shortest representation for strings, // but it should do that under most modern JavaScript engines. So we only use this implementation after testing that it // handles strings correctly. This should be the case under all modern JavaScript engines. // Some test strings that we use to test that the fast implementation produces the correct output // They are broken down into categories of characters const testStrings: { src: string; out: string }[] = [ { // All C0 control characters: they should represented as \uXXXX numeric escapes, except for \b, \f, \n, \r, \t, which should be represented as \b, \f, \n, \r, \t src: `\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f`, out: `\\u0000\\u0001\\u0002\\u0003\\u0004\\u0005\\u0006\\u0007\\b\\t\\n\\u000b\\f\\r\\u000e\\u000f\\u0010\\u0011\\u0012\\u0013\\u0014\\u0015\\u0016\\u0017\\u0018\\u0019\\u001a\\u001b\\u001c\\u001d\\u001e\\u001f` }, { // All C1 control characters: they should be represented literally src: `\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f`, out: `\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f` }, { // Other characters: they should be represented literally. We test: // * Some ASCII characters // * Some non-ASCII characters in the Basic Multilingual Plane // * Some characters outside the Basic Multilingual Plane src: `aπ€ℤ🍔`, out: `aπ€ℤ🍔` } ]; // export so that it can be used in tests export const testStringSrc = testStrings.map((x) => x.src).join(''); export const testStringOut = '"' + testStrings.map((x) => x.out).join('') + '"'; // TODO: send a log event if the portable but slower implementation is used const canUseFast = stableJSONStringify_fast(testStringSrc) === testStringOut; export const stableJSONStringify = canUseFast ? stableJSONStringify_fast : stableJSONStringify_portable;