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
10 changes: 10 additions & 0 deletions changelog.d/20261010_lab-8754.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion conformance/coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>`tools/interop-crosscheck.mjs:loneSurrogateSelftest`<br>**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`<br>`tools/interop-crosscheck.mjs:loneSurrogateSelftest`<br>**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.<br>`tools/interop-crosscheck.mjs:loneSurrogateSelftest`<br>**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`<br>`tools/interop-reference.py:_non_string_key_selftest`<br>**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`<br>`tools/interop-reference.py:_non_string_key_selftest`<br>`tools/interop-crosscheck.mjs:nonStringKeySelftest`<br>**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.<br>`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.<br>`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.<br>`tools/interop-crosscheck.mjs:unsafeNumberSelftest`<br>**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 |
Expand Down
5 changes: 3 additions & 2 deletions conformance/requirements.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 29 additions & 2 deletions tools/interop-crosscheck.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
);
Expand Down Expand Up @@ -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 [
Expand All @@ -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() {
Expand All @@ -606,6 +632,7 @@ function unsafeNumberSelftest() {

loneSurrogateSelftest();
outOfModelSelftest();
nonStringKeySelftest();
unsafeNumberSelftest();

if (failures > 0) {
Expand Down
Loading