diff --git a/changelog.d/20261010_lab-8754.md b/changelog.d/20261010_lab-8754.md new file mode 100644 index 0000000..95dd451 --- /dev/null +++ b/changelog.d/20261010_lab-8754.md @@ -0,0 +1,10 @@ +### Conformance — the interop cross-check rejects Symbol-keyed object properties (LAB-8754) + +- `tools/interop-crosscheck.mjs` rejects a plain object with an own enumerable Symbol-keyed + property as a non-string map key ([IOP-13](spec/interop-mode.md#iop-13)), wherever it sits. + Before, `Object.keys` skipped the Symbol key and the object encoded as if it were absent, + which [IOP-8](spec/interop-mode.md#iop-8) forbids. A non-enumerable Symbol key is ignored, + as a non-enumerable string key already was. The new `nonStringKeySelftest` pins both. + Every published vector verifies with unchanged output. +- `conformance/requirements.json`: IOP-13 cites the new self-test, and its gap note no longer + says a JavaScript object has string keys only. `conformance/coverage.md` is regenerated. diff --git a/conformance/coverage.md b/conformance/coverage.md index 3df2cf5..3073c10 100644 --- a/conformance/coverage.md +++ b/conformance/coverage.md @@ -169,7 +169,7 @@ Marked not-a-requirement in `spec/intent-presets.md`: | [IOP-10](../spec/interop-mode.md#iop-10) | *The Interop Data Model*: UTF-8 bytes of the string as given. No Unicode normalization (no NFC/NFD) is applied. Strings **MUST** be well-formed Unicode scalar sequences — an unpaired surrogate MUST be rejected, never replacement-encoded (JS … | `tools/interop-reference.py:_surrogate_selftest`
`tools/interop-crosscheck.mjs:loneSurrogateSelftest`
**Gap:** The self-tests reject a lone high or low surrogate as an argument, in a map key, in a nested value and in a set, but only in the reference tools: portable JSON cannot carry a lone surrogate, so no vector reaches an SDK. | uncovered | uncovered | uncovered | | [IOP-11](../spec/interop-mode.md#iop-11) | *The Interop Data Model*: … NFC/NFD) is applied. Strings MUST be well-formed Unicode scalar sequences — an unpaired surrogate **MUST** be rejected, never replacement-encoded (JS `Buffer`/`TextEncoder` silently emit U+FFFD, so the TS SDK MUST check … | `tools/interop-reference.py:_surrogate_selftest`
`tools/interop-crosscheck.mjs:loneSurrogateSelftest`
**Gap:** The self-tests reject a lone high or low surrogate as an argument, in a map key, in a nested value and in a set, but only in the reference tools: portable JSON cannot carry a lone surrogate, so no vector reaches an SDK. | uncovered | uncovered | uncovered | | [IOP-12](../spec/interop-mode.md#iop-12) | *The Interop Data Model*: … rejected, never replacement-encoded (JS `Buffer`/`TextEncoder` silently emit U+FFFD, so the TS SDK **MUST** check `String.prototype.isWellFormed()` first; Rust `String` is immune by construction; Python raises on encode). … | Binds cachekit-ts only.
`tools/interop-crosscheck.mjs:loneSurrogateSelftest`
**Gap:** The self-test exercises the reference cross-check, not cachekit-ts; portable JSON cannot carry a lone surrogate, so only the SDK's own suite can test it. | n/a | uncovered | n/a | -| [IOP-13](../spec/interop-mode.md#iop-13) | *The Interop Data Model*: Keys **MUST** be strings (non-string key → error). Keys sorted by Unicode code point order at every nesting level; values normalized … | `interop-mode.json`: `map_key_proto`
`tools/interop-reference.py:_non_string_key_selftest`
**Gap:** Portable JSON object keys are always strings, so no vector carries a non-string key and only the Python reference's self-test rejects one; map_key_proto pins only that a string key such as __proto__ survives normalization. JavaScript objects have string keys only, and the cross-check rejects a Map as outside the data model (IOP-8). | partial (gap) | partial (gap) | partial (gap) | +| [IOP-13](../spec/interop-mode.md#iop-13) | *The Interop Data Model*: Keys **MUST** be strings (non-string key → error). Keys sorted by Unicode code point order at every nesting level; values normalized … | `interop-mode.json`: `map_key_proto`
`tools/interop-reference.py:_non_string_key_selftest`
`tools/interop-crosscheck.mjs:nonStringKeySelftest`
**Gap:** Portable JSON object keys are always strings, so no vector carries a non-string key and only the reference tools' self-tests reject one; map_key_proto pins only that a string key such as __proto__ survives normalization. A JavaScript object can also carry Symbol keys: the cross-check rejects an own enumerable Symbol key, ignores a non-enumerable one as it ignores a non-enumerable string key, and rejects a Map as outside the data model (IOP-8). | partial (gap) | partial (gap) | partial (gap) | | [IOP-14](../spec/interop-mode.md#iop-14) | *The Interop Data Model*: … sorts *after* `"𐀀"` (U+10000) in UTF-16 order but *before* it in code-point order. The TS SDK **MUST** sort by comparing UTF-8-encoded key bytes (or by code point), not with `Array.prototype.sort()`'s default comparator. … | Binds cachekit-ts only.
`interop-mode.json`: `map_key_sort_supplementary`, `map_key_sort_ascii`, `nested_map_recursive_sort`, `map_key_sort_case_across_letters`, `map_key_sort_punctuation`, `map_key_sort_shorter_key_later`, `map_key_sort_supplementary_shorter` | n/a | covered | n/a | | [IOP-15](../spec/interop-mode.md#iop-15) | *Number canonicalization*: … keep the full `[-2^63, 2^64-1]` range (snowflake IDs work). In JavaScript, integers above 2⁵³ **MUST** be handled as `BigInt`; the SDK MUST error on a non-integral-safe `Number` rather than silently rounding. | Binds cachekit-ts only.
`interop-mode.json`: `uint64_max`, `int64_min`, `int_beyond_float64_precision`, `small_int_as_bigint` | n/a | covered | n/a | | [IOP-16](../spec/interop-mode.md#iop-16) | *Number canonicalization*: … range (snowflake IDs work). In JavaScript, integers above 2⁵³ MUST be handled as `BigInt`; the SDK **MUST** error on a non-integral-safe `Number` rather than silently rounding. | Binds cachekit-ts only.
`tools/interop-crosscheck.mjs:unsafeNumberSelftest`
**Gap:** The self-test exercises the reference cross-check, not cachekit-ts: Python and Rust read a bare JSON integer beyond 2^53 exactly, so no shared vector can carry the unsafe Number only JavaScript would see. | n/a | uncovered | n/a | diff --git a/conformance/requirements.json b/conformance/requirements.json index ce3f4dc..af6b6c8 100644 --- a/conformance/requirements.json +++ b/conformance/requirements.json @@ -1127,9 +1127,10 @@ "interop-mode.json:map_key_proto" ], "tests": [ - "tools/interop-reference.py:_non_string_key_selftest" + "tools/interop-reference.py:_non_string_key_selftest", + "tools/interop-crosscheck.mjs:nonStringKeySelftest" ], - "gap": "Portable JSON object keys are always strings, so no vector carries a non-string key and only the Python reference's self-test rejects one; map_key_proto pins only that a string key such as __proto__ survives normalization. JavaScript objects have string keys only, and the cross-check rejects a Map as outside the data model (IOP-8)." + "gap": "Portable JSON object keys are always strings, so no vector carries a non-string key and only the reference tools' self-tests reject one; map_key_proto pins only that a string key such as __proto__ survives normalization. A JavaScript object can also carry Symbol keys: the cross-check rejects an own enumerable Symbol key, ignores a non-enumerable one as it ignores a non-enumerable string key, and rejects a Map as outside the data model (IOP-8)." }, "IOP-14": { "section": "The Interop Data Model", diff --git a/tools/interop-crosscheck.mjs b/tools/interop-crosscheck.mjs index 261f5d4..183ae03 100644 --- a/tools/interop-crosscheck.mjs +++ b/tools/interop-crosscheck.mjs @@ -222,7 +222,13 @@ function encodeCanonical(v, chunks, { collapseFloats }) { // outside the data model and falls through to the error below, never to an // empty map. Sort keys by UTF-8 byte order (== Unicode code point order). JS default // string sort compares UTF-16 code units and gets supplementary-plane - // characters WRONG — compare encoded bytes instead. + // characters WRONG — compare encoded bytes instead. Its data is its own + // enumerable properties, and an enumerable Symbol key is a non-string map key. + // propertyIsEnumerable goes through Object.prototype: a null-prototype object + // has none, and the message names no Symbol, since interpolating one throws. + if (Object.getOwnPropertySymbols(v).some((s) => Object.prototype.propertyIsEnumerable.call(v, s))) { + throw new OutOfModelError("map keys must be strings: object has an enumerable Symbol key"); + } const keys = Object.keys(v).sort((a, b) => Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")), ); @@ -566,7 +572,7 @@ function loneSurrogateSelftest() { } // A value outside the data model is rejected wherever it sits, never encoded -// as an empty map: in JavaScript a non-string map key needs a Map. +// as an empty map. function outOfModelSelftest() { class Point {} for (const [label, args] of [ @@ -582,6 +588,26 @@ function outOfModelSelftest() { } } +// Map keys must be strings, at every nesting level, as in the Python reference. +// A JavaScript object can carry Symbol keys: an own enumerable one is rejected, and +// a non-enumerable one is ignored, as Object.keys ignores a non-enumerable string key. +function nonStringKeySelftest() { + const sym = Symbol("k"); + const nullProto = Object.assign(Object.create(null), { [sym]: 1n }); + for (const [label, args] of [ + ["Symbol key", [{ [sym]: 1n, a: 2n }]], + ["Symbol key on a null-prototype object", [nullProto]], + ["Symbol key in a nested map", [{ ok: { [sym]: 1n } }]], + ["Symbol key in a list", [[1n, { [sym]: 1n }]]], + ["Symbol key in a set element", [new TaggedSet([{ [sym]: 1n }])]], + ]) { + expectRejected("nonStringKeySelftest", label, args); + } + const hidden = Object.defineProperty({ a: 2n }, sym, { value: 1n, enumerable: false }); + check("nonStringKeySelftest", "non-enumerable Symbol key", "9181a16102", encodeToBuffer([hidden], { collapseFloats: true }).toString("hex")); + check("nonStringKeySelftest", "string key", "9181a131a161", encodeToBuffer([{ "1": "a" }], { collapseFloats: true }).toString("hex")); +} + // A bare Number that is not a safe integer has already lost precision, so it is // rejected, never rounded; the largest safe integer still encodes like its BigInt. function unsafeNumberSelftest() { @@ -606,6 +632,7 @@ function unsafeNumberSelftest() { loneSurrogateSelftest(); outOfModelSelftest(); +nonStringKeySelftest(); unsafeNumberSelftest(); if (failures > 0) {