Skip to content

runtime: JSX_NODE_CLASS_ID and RAW_JSON_CLASS_ID both 0xFFFF_00A0 — String(JSON.rawJSON(x)) takes the JSX path #7587

Description

@proggeramlug

Two unrelated types share 0xFFFF_00A0

crates/perry-runtime/src/jsx.rs:33          pub(crate) const JSX_NODE_CLASS_ID: u32 = 0xFFFF_00A0;
crates/perry-runtime/src/json/raw_json.rs:23 pub(crate) const RAW_JSON_CLASS_ID: u32 = 0xFFFF_00A0;

Same shape as #7576, which was ITERATOR_HELPER_CLASS_ID and
STRING_ITERATOR_CLASS_ID both being 0xFFFF_0009 and which took out the
entire TC39 iterator-helpers surface. Found by the agent that fixed #7576;
I verified it and traced the consequence.

It is live, not merely latent

value/to_string.rs:1144 discriminates on class_id alone — no other
condition:

let obj = ptr as *const crate::object::ObjectHeader;
if (*obj).class_id == crate::jsx::JSX_NODE_CLASS_ID {
    let html = crate::object::js_object_get_field(obj, 0);
    return js_jsvalue_to_string(f64::from_bits(html.bits()));
}

A JSON.rawJSON(...) object is allocated as
js_object_alloc_null_proto(RAW_JSON_CLASS_ID, 1) (raw_json.rs:173) — one
field, payload in field 0. So String(JSON.rawJSON("123")) matches the JSX
arm and returns field 0.

Measured on main:

perry:  String(r): 123          stringify: {"a":123}
node :  TypeError (throws on String(r) -- rawJSON objects are null-prototype)

It returns a plausible-looking answer purely by coincidence of layout
both types happen to keep their payload in field 0. That is the worst kind of
latent bug: it is taking the wrong branch today and the output only looks right
by accident. Any change to either type's field layout turns it into garbage or
a crash.

The JSON.stringify direction is currently masked — is_raw_json
(raw_json.rs:78) carries an additional condition, and JSON.stringify(<div/>)
correctly gives {} rather than emitting the node's stored markup unquoted. So
the exposure today is the to_string path; the rest is one guard away.

The fix, and the more important half

Reassigning one constant is a one-line change.

The part worth doing properly is the test. #7583 added
iterator_class_ids_are_pairwise_distinct, which is good — but it is scoped to
the iterator family, so it cannot catch this pair, and it could not have
caught it. A distinctness test that enumerates one family is a gate that is
structurally unable to fail for the next family, which is CLAUDE.md's hazard 4
in miniature.

What is needed is a test over every reserved 0xFFFF_00xx class id in the
runtime, asserting pairwise distinctness across all families, so that the next
person who mints a constant by copying a neighbour and bumping the low byte
gets a red build instead of a silent cross-match years later. Both known
collisions were introduced exactly that way — each carrying a comment
describing where it sits relative to its neighbour.

Worth also checking whether the id space is documented anywhere central. Two
independent authors picking the same value twice suggests it is not.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions