From 12ebae68fb1fb3704c468d219a4ea48cf3347fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 04:08:11 +0200 Subject: [PATCH 1/4] fix(json): JSON.stringify segfaulted on a non-ObjectHeader pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `JSON.stringify` could SIGSEGV. `is_object_pointer` checked that the value's pointer was not a handle-band id, then read `(*obj).keys_array` and validated *that* slot with only an alignment/magnitude heuristic: top16 in {0,1} && p > 0x10000 && (p & 7) == 0 A garbage word like `0x223af100` satisfies all three, so the following `(*keys_arr).length` load dereferenced unmapped memory and faulted. The deeper problem is that being GC-tracked only proves the *allocation* is real — not that it has an `ObjectHeader` layout. A Promise / WeakMap / ArrayBuffer reaches the object walkers anyway (e.g. through a static `TYPE_OBJECT` hint from codegen), and then that slot is some unrelated internal field being read as a pointer. The same held for `object_has_no_own_keys` and for the pretty printer's walkers, which loaded `field_count` / `keys_array` with no gate at all. - `ptr_is_tracked_heap_object` (dereference-free: page-map + malloc registry) now gates the object pointer itself, and — new — the keys array it points to. - `object_keys_array_checked` is the single accessor every object walker uses; a slot that isn't a tracked allocation yields `{}` instead of a fault. Fixing the walk exposed four value-level divergences from Node on the way, all in the pretty (`space`-argument) path, which had drifted from the plain one: - a function inside an array serialized as its own pointer bits reinterpreted as a string (`[function(){}]` → `[""]`, and for most closures a fault). Node emits `null`; every other array path already did. - `JSON.stringify(new Date(), null, 2)` walked the `DateCell` as a plain object and produced `""` instead of the ISO string — silently corrupting any indented JSON file containing a date. The plain path has always had the `toJSON` arm. - a `RegExp` emitted `{"field0":null}` (its `RegExpHeader` slots read as fields); Node emits `{}`. - a `Promise` fell through to the `StringHeader` fallback and emitted `""`; Node emits `{}`. Detected by GC type alongside Map/Set/Error. Validation: a node-vs-perry differential over a value matrix — functions (bare, in arrays, as properties), dates, regexps, Map/Set/Error, Promise, WeakMap/WeakSet/ WeakRef, sync + async generators, buffers, typed arrays, boxed primitives, sparse arrays, replacer functions, key whitelists, deep nesting, circular detection — is byte-identical to Node in both the plain and pretty forms, where 8 cases previously diverged and several faulted. perry-runtime: 1264 passed / 0 failed. On a large esbuild-bundled CLI app this removes a reproducible SIGSEGV: 42/42 runs clean, from 5/25 crashing. Known gaps left for follow-ups (pre-existing, none of them faults): `ArrayBuffer`/ `DataView` serialize as a Buffer rather than `{}`; a `Proxy` yields `null` rather than its target's properties; `new URL(...)` throws "Converting circular structure to JSON" instead of emitting `href`; `Headers`/`TextEncoder` yield `null`, and `AbortController`/`AbortSignal` expose internals, where Node emits `{}`. --- crates/perry-runtime/src/json/replacer.rs | 83 ++++++++++++++- crates/perry-runtime/src/json/stringify.rs | 113 +++++++++++++++++++-- 2 files changed, 181 insertions(+), 15 deletions(-) diff --git a/crates/perry-runtime/src/json/replacer.rs b/crates/perry-runtime/src/json/replacer.rs index 80d8f7117..6fa7b3107 100644 --- a/crates/perry-runtime/src/json/replacer.rs +++ b/crates/perry-runtime/src/json/replacer.rs @@ -359,7 +359,13 @@ pub(crate) unsafe fn stringify_object_with_replacer_pretty( let obj = ptr as *const crate::ObjectHeader; let num_fields = (*obj).field_count; - let keys_arr = (*obj).keys_array; + let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { + // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that + // reached here via a static TYPE_OBJECT hint). Node serializes those as + // `{}`; walking the slot as an ArrayHeader would fault. + buf.push_str("{}"); + return; + }; let keys_len = (*keys_arr).length; let keys_elements = (keys_arr as *const u8).add(std::mem::size_of::()) as *const f64; @@ -719,6 +725,21 @@ pub(crate) unsafe fn stringify_value_pretty( buf.push_str("null"); return; } + // #2089: a Date is a NaN-boxed `DateCell` pointer — emit `toJSON()` (ISO + // string, or `null` for an Invalid Date) per ECMA-262 25.5.2, before any + // object/array deref of the small cell. The plain path has always done + // this; the pretty path did not, so `JSON.stringify(new Date(), null, 2)` + // walked the cell as a plain object and produced `""` instead of the ISO + // string — silently corrupting every indented JSON file with a date in it. + if crate::date::is_date_cell_addr(ptr as usize) { + let s_ptr = crate::date::js_date_to_json(value); + if let Some(s) = str_from_header(s_ptr) { + write_escaped_string(buf, s); + } else { + buf.push_str("null"); + } + return; + } // #3857: a boxed primitive wrapper (`new String`/`Number`/`Boolean`, // `Object(1n)`) serializes as its underlying primitive. Must run before // the `is_object_pointer` probes below, which would deref the wrapper @@ -749,9 +770,24 @@ pub(crate) unsafe fn stringify_value_pretty( buf.push_str(std::str::from_utf8(raw).unwrap_or("null")); return; } + // A RegExp has no enumerable own properties, so Node serializes it as `{}`. + // Perry's `RegExpHeader` is not an `ObjectHeader`, so without this the + // generic object walk below read its internal slots as fields and emitted + // `{"field0":null}`. Detected by the header magic (never a raw deref). + if crate::regex::regex_header_has_magic(ptr as *const crate::regex::RegExpHeader) { + buf.push_str("{}"); + return; + } if matches!( gc_obj_type(ptr), - crate::gc::GC_TYPE_MAP | crate::gc::GC_TYPE_SET | crate::gc::GC_TYPE_ERROR + crate::gc::GC_TYPE_MAP + | crate::gc::GC_TYPE_SET + | crate::gc::GC_TYPE_ERROR + // A Promise has no enumerable own properties either — Node emits `{}`. + // Perry's PromiseHeader is not an ObjectHeader, so the generic walk + // below read its slots as fields (it fell all the way through to the + // StringHeader fallback and emitted `""`). + | crate::gc::GC_TYPE_PROMISE ) { buf.push_str("{}"); return; @@ -812,6 +848,15 @@ pub(crate) unsafe fn stringify_object_pretty( indent: &str, depth: usize, ) { + // Same deref-safety gate the plain path applies in `is_object_pointer`: the + // `field_count` / `keys_array` reads below load straight through `ptr`, so an + // in-range-but-unmapped garbage address (a denormal double that survived the + // tag probes) SIGSEGVs here. Require a genuinely GC-tracked allocation first + // and emit `null` otherwise, rather than faulting inside JSON.stringify. + if !super::stringify::ptr_is_tracked_heap_object(ptr) { + buf.push_str("null"); + return; + } // Circular reference check if STRINGIFY_STACK.with(|s| s.borrow().contains(&(ptr as usize))) { let msg = "Converting circular structure to JSON"; @@ -836,7 +881,13 @@ pub(crate) unsafe fn stringify_object_pretty( let obj = ptr as *const crate::ObjectHeader; let num_fields = (*obj).field_count; - let keys_arr = (*obj).keys_array; + let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { + // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that + // reached here via a static TYPE_OBJECT hint). Node serializes those as + // `{}`; walking the slot as an ArrayHeader would fault. + buf.push_str("{}"); + return; + }; let keys_len = (*keys_arr).length; let keys_elements = (keys_arr as *const u8).add(std::mem::size_of::()) as *const f64; @@ -930,6 +981,13 @@ pub(crate) unsafe fn stringify_array_pretty( indent: &str, depth: usize, ) { + // Same gate as `stringify_object_pretty`: this is the fall-through branch for + // a pointer that failed the object probes, so a corrupted pointer lands here + // and the `(*arr).length` read below would fault. + if !super::stringify::ptr_is_tracked_heap_object(ptr) { + buf.push_str("null"); + return; + } let arr = ptr as *const crate::ArrayHeader; let len = (*arr).length; let elements = (arr as *const u8).add(std::mem::size_of::()) as *const f64; @@ -948,7 +1006,16 @@ pub(crate) unsafe fn stringify_array_pretty( let elem = *elements.add(i as usize); let elem_bits = elem.to_bits(); // TAG_HOLE: sparse-array holes serialize as null, same as undefined. - if elem_bits == TAG_UNDEFINED || elem_bits == crate::value::TAG_HOLE { + // A function element also serializes as `null` (JSON.stringify only drops + // a function when it is an object *property*; in an array it becomes null). + // Every other array path already did this — without it here, the pretty + // printer fell through to `stringify_value_pretty`, which read the closure's + // pointer bits as a string payload: `[function(){}]` came out as `[""]` + // and, for most closures, dereferenced unmapped memory and segfaulted. + if elem_bits == TAG_UNDEFINED + || elem_bits == crate::value::TAG_HOLE + || is_closure_value(elem_bits) + { buf.push_str("null"); } else { stringify_value_pretty(elem, TYPE_UNKNOWN, buf, indent, inner_indent_count); @@ -989,7 +1056,13 @@ pub(crate) unsafe fn stringify_object_with_array_replacer( let obj = ptr as *const crate::ObjectHeader; let num_fields = (*obj).field_count; - let keys_arr = (*obj).keys_array; + let Some(keys_arr) = super::stringify::object_keys_array_checked(obj) else { + // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that + // reached here via a static TYPE_OBJECT hint). Node serializes those as + // `{}`; walking the slot as an ArrayHeader would fault. + buf.push_str("{}"); + return; + }; let keys_len = (*keys_arr).length; let keys_elements = (keys_arr as *const u8).add(std::mem::size_of::()) as *const f64; diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index 08859ffc6..f3703944d 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -12,19 +12,52 @@ use std::fmt::Write as FmtWrite; // ─── JSON.stringify ─────────────────────────────────────────────────────────── #[inline] +/// True only when `ptr` is an address the GC actually tracks — an arena +/// allocation (nursery/old/longlived per the page-map) or a registered malloc +/// object. Both checks are dereference-FREE (page-metadata / registry lookups), +/// so this rejects a forged pointer before any field read. +/// +/// A magnitude check alone is not enough here: a type-erased `JSON.stringify` +/// walk can reach `is_object_pointer` with a primitive `number` whose f64 bits +/// land INSIDE the heap magnitude window (~2–5 TB, e.g. `0x0000_0347_0000_0000`) +/// yet point at an UNMAPPED page — `is_valid_obj_ptr` accepts it and the +/// subsequent `(*obj).keys_array` read then SIGSEGVs. Mirrors the `path.rs` / +/// `current_heap_header_for_user_ptr` Unknown→malloc rule. +#[inline] +pub(super) unsafe fn ptr_is_tracked_heap_object(ptr: *const u8) -> bool { + if crate::value::addr_class::is_handle_band(ptr as usize) { + return false; + } + let gc_header = (ptr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + !matches!( + crate::arena::classify_heap_generation(ptr as usize), + crate::arena::HeapGeneration::Unknown + ) || crate::gc::gc_malloc_header_is_tracked(gc_header) +} + pub(crate) unsafe fn is_object_pointer(ptr: *const u8) -> bool { // A small-handle-band id (revocable-Proxy id, fetch/zlib/stream handle) is // never a real ObjectHeader; reading its `keys_array` field would deref // unmapped memory (#4904/#1843 pattern). Reject by magnitude before any load. - if crate::value::addr_class::is_handle_band(ptr as usize) { + // The upper end matters too: a plausible-but-unmapped in-range garbage address + // (a primitive number whose f64 bits land in the heap window) would likewise + // SIGSEGV on the `keys_array` read below — require a genuinely GC-tracked + // allocation before dereferencing anything. + if !ptr_is_tracked_heap_object(ptr) { return false; } let obj = ptr as *const crate::ObjectHeader; let potential_keys_ptr = (*obj).keys_array as u64; - let top_16_bits = potential_keys_ptr >> 48; - let is_likely_heap_pointer = top_16_bits == 0 || top_16_bits == 1; - let looks_like_valid_pointer = - is_likely_heap_pointer && potential_keys_ptr > 0x10000 && (potential_keys_ptr & 0x7) == 0; + // `ptr` being GC-tracked only proves the *allocation* is real — not that it is + // an `ObjectHeader`. A Promise / WeakMap / ArrayBuffer / any other GC layout + // reaches here too (e.g. via a static TYPE_OBJECT hint), and then this slot is + // some unrelated field read as a pointer. The alignment/magnitude heuristic + // below is far too weak to catch that — a garbage word like 0x223af100 is + // 8-aligned and in range, and the `(*keys_arr).length` load below then faults. + // Require the *keys array itself* to be a tracked allocation before loading it. + let looks_like_valid_pointer = potential_keys_ptr > 0x10000 + && (potential_keys_ptr & 0x7) == 0 + && ptr_is_tracked_heap_object(potential_keys_ptr as *const u8); if looks_like_valid_pointer { let keys_arr = (*obj).keys_array; @@ -58,14 +91,39 @@ pub(crate) unsafe fn is_object_pointer(ptr: *const u8) -> bool { /// to disambiguate an empty object from a corrupted pointer after the /// `keys_len > 0` `is_object_pointer` probe fails. pub(crate) unsafe fn object_has_no_own_keys(ptr: *const u8) -> bool { + // Same deref-safety gate as `is_object_pointer`: this is called on the same + // value right after that probe returns false (to tell an empty object apart + // from a corrupted pointer), so an unmapped in-range garbage address must be + // rejected here too before the `keys_array` field read. + if !ptr_is_tracked_heap_object(ptr) { + return false; + } let keys = (*(ptr as *const crate::ObjectHeader)).keys_array; if keys.is_null() { return true; } - let kp = keys as u64; - let top_16 = kp >> 48; - let looks_valid = (top_16 == 0 || top_16 == 1) && kp > 0x10000 && (kp & 0x7) == 0; - looks_valid && (*keys).length == 0 + // Same reasoning as `is_object_pointer`: a tracked `ptr` does not prove an + // `ObjectHeader` layout, so this slot may be an unrelated field. Require the + // keys array itself to be a tracked allocation before loading its length. + if !ptr_is_tracked_heap_object(keys as *const u8) { + return false; + } + (*keys).length == 0 +} + +/// The object's keys array, but only when it is genuinely a tracked heap +/// allocation. A GC allocation that is not an `ObjectHeader` (a Promise, WeakMap, +/// ArrayBuffer, ...) can still reach the object walkers — via a static +/// `TYPE_OBJECT` hint from codegen — and its bytes at this offset are some other +/// field. Loading that as an `ArrayHeader` faults. Walkers bail to `{}` on `None`. +pub(super) unsafe fn object_keys_array_checked( + obj: *const crate::ObjectHeader, +) -> Option<*const crate::ArrayHeader> { + let keys = (*obj).keys_array as *const crate::ArrayHeader; + if keys.is_null() || !ptr_is_tracked_heap_object(keys as *const u8) { + return None; + } + Some(keys) } #[inline] @@ -738,6 +796,13 @@ pub(crate) unsafe fn stringify_value(value: f64, type_hint: u32, buf: &mut Strin // as "{}" (their contents aren't enumerable own props). buf.push_str("{}"); } + // A Promise has no enumerable own properties — Node emits "{}". Its + // `PromiseHeader` is not the JSObject keys/values layout, so falling + // through to the structural heuristics below read its slots as a + // StringHeader and emitted `""`. + crate::gc::GC_TYPE_PROMISE => { + buf.push_str("{}"); + } _ => { // Unknown/untagged pointer: fall back to the structural // heuristics for safety (e.g. pointers to non-GC-tracked @@ -852,6 +917,14 @@ pub(crate) unsafe fn stringify_value_depth( stringify_value_depth(prim, TYPE_UNKNOWN, buf, depth); return; } + // A RegExp has no enumerable own properties, so Node serializes it as `{}`. + // Perry's `RegExpHeader` is not an `ObjectHeader`, so without this the + // generic object walk read its internal slots as fields and emitted + // `{"field0":null}`. Detected by the header magic (never a raw deref). + if crate::regex::regex_header_has_magic(ptr as *const crate::regex::RegExpHeader) { + buf.push_str("{}"); + return; + } // #2089: a Date is a NaN-boxed `DateCell` pointer. JSON.stringify must // emit `toJSON()` → the ISO string (or `null` for an Invalid Date) per // ECMA-262 25.5.2. Check before any object/array handling so the small @@ -928,6 +1001,13 @@ pub(crate) unsafe fn stringify_value_depth( // serialize as "{}" and must not reach the object catch-all. buf.push_str("{}"); } + // A Promise has no enumerable own properties — Node emits "{}". Its + // `PromiseHeader` is not the JSObject keys/values layout, so falling + // through to the structural heuristics below read its slots as a + // StringHeader and emitted `""`. + crate::gc::GC_TYPE_PROMISE => { + buf.push_str("{}"); + } _ => { if is_object_pointer(ptr) { stringify_object_inner(ptr, buf, depth); @@ -1081,7 +1161,13 @@ pub(crate) unsafe fn stringify_object_inner(ptr: *const u8, buf: &mut String, de } } } - let keys_arr = (*obj).keys_array; + let Some(keys_arr) = object_keys_array_checked(obj) else { + // Not an ObjectHeader after all (a Promise / WeakMap / ArrayBuffer that + // reached here via a static TYPE_OBJECT hint). Node serializes those as + // `{}`; walking the slot as an ArrayHeader would fault. + buf.push_str("{}"); + return; + }; let keys_len = (*keys_arr).length; // Root the object for the enumeration below and re-derive the keys/field // buffers from the CURRENT header on every access: a user getter @@ -1894,6 +1980,13 @@ pub(crate) unsafe fn stringify_array_depth(ptr: *const u8, buf: &mut String, dep // must not reach the object catch-all (segfault). buf.push_str("{}"); } + // A Promise has no enumerable own properties — Node emits "{}". Its + // `PromiseHeader` is not the JSObject keys/values layout, so falling + // through to the structural heuristics below read its slots as a + // StringHeader and emitted `""`. + crate::gc::GC_TYPE_PROMISE => { + buf.push_str("{}"); + } _ => { if is_object_pointer(elem_ptr) { stringify_object_inner(elem_ptr, buf, depth); From 48d1d58ce318f73fc91b7bdd84546d034e50118f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 13 Jul 2026 08:47:13 +0200 Subject: [PATCH 2/4] style: cargo fmt (lint gate) --- crates/perry-runtime/src/json/stringify.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index f3703944d..33c755657 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -1980,13 +1980,13 @@ pub(crate) unsafe fn stringify_array_depth(ptr: *const u8, buf: &mut String, dep // must not reach the object catch-all (segfault). buf.push_str("{}"); } - // A Promise has no enumerable own properties — Node emits "{}". Its - // `PromiseHeader` is not the JSObject keys/values layout, so falling - // through to the structural heuristics below read its slots as a - // StringHeader and emitted `""`. - crate::gc::GC_TYPE_PROMISE => { - buf.push_str("{}"); - } + // A Promise has no enumerable own properties — Node emits "{}". Its + // `PromiseHeader` is not the JSObject keys/values layout, so falling + // through to the structural heuristics below read its slots as a + // StringHeader and emitted `""`. + crate::gc::GC_TYPE_PROMISE => { + buf.push_str("{}"); + } _ => { if is_object_pointer(elem_ptr) { stringify_object_inner(elem_ptr, buf, depth); From af3256216fc758235cef17d872e1bcc3a6da9998 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 12:16:34 +0200 Subject: [PATCH 3/4] refactor(json): split the scalar emitters out of stringify.rs (2000-line cap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR pushed stringify.rs to 2041 lines and the file-size lint gate rejected it. Moved the number formatter, the string escaper and the BigInt path into stringify_scalars.rs — a coherent group with no dependency on the traversal around them — and re-exported them, so no call site changes. stringify.rs 2041 -> 1787 stringify_scalars.rs 269 Pure code move; no behaviour change. Mirrors the existing stringify_api.rs / stringify_buffer.rs split in the same directory. --- crates/perry-runtime/src/json/mod.rs | 1 + crates/perry-runtime/src/json/stringify.rs | 264 +---------------- .../src/json/stringify_scalars.rs | 268 ++++++++++++++++++ 3 files changed, 274 insertions(+), 259 deletions(-) create mode 100644 crates/perry-runtime/src/json/stringify_scalars.rs diff --git a/crates/perry-runtime/src/json/mod.rs b/crates/perry-runtime/src/json/mod.rs index c9b15c84a..2bed768ae 100644 --- a/crates/perry-runtime/src/json/mod.rs +++ b/crates/perry-runtime/src/json/mod.rs @@ -30,6 +30,7 @@ mod simd; mod stringify; mod stringify_api; mod stringify_buffer; +mod stringify_scalars; mod stringify_tojson_probe; // Public FFI re-exports — preserve the `crate::json::js_json_*` path used by diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index 33c755657..b5e64c54b 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -9,6 +9,11 @@ use super::*; use crate::{js_string_from_bytes, JSValue, StringHeader}; use std::fmt::Write as FmtWrite; +pub(crate) use super::stringify_scalars::{ + bigint_apply_to_json, serialize_bigint, throw_bigint_serialize, write_escaped_string, + write_number, +}; + // ─── JSON.stringify ─────────────────────────────────────────────────────────── #[inline] @@ -126,265 +131,6 @@ pub(super) unsafe fn object_keys_array_checked( Some(keys) } -#[inline] -pub(crate) unsafe fn write_number(buf: &mut String, value: f64) { - // A BigInt's NaN-boxed bits ARE an IEEE NaN, so it would otherwise fall into - // the `is_nan()` → "null" arm below. BigInt is unserializable (modulo a - // `toJSON`), so funnel it through the throwing serializer. Centralizes the - // BigInt rule for every object-field / array-element numeric fallback that - // reaches here (test262 JSON/stringify/value-bigint `{x: 0n}`). - if (value.to_bits() & 0xFFFF_0000_0000_0000) == BIGINT_TAG { - serialize_bigint(value, buf); - return; - } - // An int32 is NaN-boxed (INT32_TAG = 0x7FFE); its bits ARE an IEEE NaN, so it - // would otherwise fall into the `is_nan()` → "null" arm below and silently - // drop the value. Decode and emit the signed integer. Integer columns from - // the sqlite binding (and other int32-tagged numbers) funnel here; numeric - // literals are stored as plain f64 doubles by codegen and never take this - // branch. Mirrors the BigInt funnel above. - if (value.to_bits() & 0xFFFF_0000_0000_0000) == INT32_TAG { - let n = (value.to_bits() & INT32_MASK) as u32 as i32; - let mut itoa_buf = itoa::Buffer::new(); - buf.push_str(itoa_buf.format(n)); - return; - } - // #2089: a Date is now a NaN-boxed `DateCell` pointer, handled in - // `stringify_value`/`stringify_value_depth` before this numeric funnel — - // so no Date detection is needed here anymore. - if value.is_nan() || value.is_infinite() { - // JSON has no NaN/Infinity literal; the spec serializes them as null. - buf.push_str("null"); - } else if value.fract() == 0.0 && value.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT { - // Fast path for in-range integers (the overwhelming majority of JSON - // numbers); identical to ECMAScript NumberToString below 2^53. Above it - // the exact integer can carry more digits than the shortest round-trip - // (`2**58`), so those fall through to `js_format_f64` in the else (#6127). - let mut itoa_buf = itoa::Buffer::new(); - buf.push_str(itoa_buf.format(value as i64)); - } else { - // ECMAScript Number::toString (spec 6.1.6.1.20): fixed notation for an - // exponent in -6..=20, else exponential with an `e+`/`e-` sign. `ryu` - // emits shortest round-trip digits but its own notation (`1e20`, - // `1e-6`, `1e21`), so JSON.stringify diverged from `String(n)` and - // Node. Reuse the shared JS formatter so `JSON.stringify(1e20)` is - // `100000000000000000000` (not `1e20`) and `1e21` is `1e+21`. - buf.push_str(&crate::string::js_format_f64(value)); - } -} - -#[inline] -pub(crate) unsafe fn write_escaped_string(buf: &mut String, s: &str) { - let bytes = s.as_bytes(); - // Fast path: scan for any escape-triggering byte. JSON output is - // overwhelmingly escape-free (ASCII identifiers, simple values), so - // a straight-line SIMD-friendly scan + one `push_str` beats the - // scalar per-byte escape loop. Needs_escape fires for `"`, `\`, or - // any control byte (< 0x20). - // Also trip the slow path for WTF-8 lone surrogate sequences - // (issue #1182): a lead byte of 0xED followed by 0xA0..=0xBF means - // we have a 3-byte encoding of U+D800..=U+DFFF and need to emit a - // `\uXXXX` escape rather than the raw (invalid-UTF-8) bytes. - let needs_escape = bytes - .iter() - .any(|&b| b < 0x20 || b == b'"' || b == b'\\' || b == 0xED); - if !needs_escape { - buf.reserve(bytes.len() + 2); - buf.push('"'); - buf.push_str(s); - buf.push('"'); - return; - } - - buf.push('"'); - let mut start = 0; - // Issue #548: `s` reaches us via `str_from_header`, which uses - // `from_utf8_unchecked` — a misclassified pointer (e.g. an - // ArrayHeader interpreted as a StringHeader through the GC-type - // fallback heuristic) can produce a `&str` whose bytes are not - // valid UTF-8. The original `&s[start..i]` slice operation - // panics in `core::str::is_char_boundary` whenever `i` lands - // mid-multibyte (or on a stray continuation byte). Switching to - // byte-level `extend_from_slice` writes the raw bytes through and - // never inspects char boundaries; the JSON output stays - // byte-identical for valid UTF-8 inputs and degrades gracefully - // (non-UTF-8 bytes pass through verbatim) instead of aborting the - // whole process. The String we hand back is technically - // ill-formed in the worst case, but every consumer in this - // codebase treats stringify output as a byte stream — and an - // ill-formed result is strictly preferable to a SIGABRT. - let buf_vec = buf.as_mut_vec(); - let mut i = 0; - while i < bytes.len() { - let b = bytes[i]; - // WTF-8 surrogate handling (issue #1182). A 0xED 0xA0..=0xBF - // 0x80..=0xBF triple encodes a code unit in U+D800..=U+DFFF. - // Two cases mirror Node's JSON.stringify: - // - // * A high surrogate (0xA0..=0xAF mid byte) immediately - // followed by a low surrogate (0xB0..=0xBF mid byte) is - // a *valid* UTF-16 pair — re-encode it as the 4-byte - // UTF-8 of the astral codepoint, no escape. This is the - // only way `'\uD83D' + '\uDC4D'` round-trips through - // `dec.end()` + concat as 👍 instead of two escapes. - // - // * Any remaining (lone) surrogate triple emits the - // `\uXXXX` escape. - if b == 0xED - && i + 2 < bytes.len() - && (0xA0..=0xBF).contains(&bytes[i + 1]) - && (0x80..=0xBF).contains(&bytes[i + 2]) - { - let high_cu: u32 = (((b & 0x0F) as u32) << 12) - | (((bytes[i + 1] & 0x3F) as u32) << 6) - | ((bytes[i + 2] & 0x3F) as u32); - // Valid pair? Need the next 3 bytes to be a low surrogate - // (0xED 0xB0..=0xBF 0x80..=0xBF) directly adjacent. - let pair_low = if (0xD800..=0xDBFF).contains(&high_cu) - && i + 5 < bytes.len() - && bytes[i + 3] == 0xED - && (0xB0..=0xBF).contains(&bytes[i + 4]) - && (0x80..=0xBF).contains(&bytes[i + 5]) - { - let low_cu: u32 = (((bytes[i + 3] & 0x0F) as u32) << 12) - | (((bytes[i + 4] & 0x3F) as u32) << 6) - | ((bytes[i + 5] & 0x3F) as u32); - Some(low_cu) - } else { - None - }; - if start < i { - buf_vec.extend_from_slice(&bytes[start..i]); - } - if let Some(low_cu) = pair_low { - let cp = 0x10000 + ((high_cu - 0xD800) << 10) + (low_cu - 0xDC00); - if let Some(c) = char::from_u32(cp) { - let mut tmp = [0u8; 4]; - let s = c.encode_utf8(&mut tmp); - buf_vec.extend_from_slice(s.as_bytes()); - i += 6; - start = i; - continue; - } - } - buf_vec.extend_from_slice(format!("\\u{:04x}", high_cu).as_bytes()); - i += 3; - start = i; - continue; - } - let escape = match b { - b'"' => Some("\\\""), - b'\\' => Some("\\\\"), - b'\n' => Some("\\n"), - b'\r' => Some("\\r"), - b'\t' => Some("\\t"), - 0x08 => Some("\\b"), - 0x0c => Some("\\f"), - 0..=0x1f => { - if start < i { - buf_vec.extend_from_slice(&bytes[start..i]); - } - buf_vec.extend_from_slice(format!("\\u{:04x}", b).as_bytes()); - start = i + 1; - i += 1; - continue; - } - _ => None, - }; - if let Some(esc) = escape { - if start < i { - buf_vec.extend_from_slice(&bytes[start..i]); - } - buf_vec.extend_from_slice(esc.as_bytes()); - start = i + 1; - } - i += 1; - } - if start < bytes.len() { - buf_vec.extend_from_slice(&bytes[start..]); - } - buf_vec.push(b'"'); -} - -/// ECMA-262 SerializeJSONProperty step 2 for a BigInt: `GetV(value, "toJSON")` -/// resolves through `BigInt.prototype`. If a callable `toJSON` is installed -/// (e.g. a userland `BigInt.prototype.toJSON`), invoke it with `this` bound to -/// the BigInt and return the (serializable) result; otherwise `None` so the -/// caller throws. Unlike objects, a primitive BigInt never reaches -/// `object_get_to_json` (that helper only walks `GC_TYPE_OBJECT` layouts), so -/// the toJSON application lives here. -pub(crate) unsafe fn bigint_apply_to_json(value: f64) -> Option { - let proto = crate::object::builtin_prototype_value("BigInt"); - let proto_bits = proto.to_bits(); - if (proto_bits & 0xFFFF_0000_0000_0000) != POINTER_TAG { - return None; - } - let proto_ptr = (proto_bits & POINTER_MASK) as *const crate::ObjectHeader; - let scope = crate::gc::RuntimeHandleScope::new(); - let value_handle = scope.root_nanbox_f64(value); - let key = js_string_from_bytes(b"toJSON".as_ptr(), 6); - let key_handle = scope.root_string_ptr(key); - // `GetV(value, "toJSON")` (spec) resolves "toJSON" on `BigInt.prototype` - // but the accessor's `this` must be the BigInt VALUE, not the prototype - // object `js_object_get_field_by_name` derives its receiver from. - // `accessor_receiver_override_begin` is the same one-shot override - // `resolve_inherited_field` uses for inherited-getter receivers; stash - // the real BigInt value so a `toJSON` installed via - // `Object.defineProperty(BigInt.prototype, "toJSON", { get() {...} })` - // observes the correct receiver (test262 - // JSON/stringify/value-bigint-tojson-receiver). - let prev_override = - crate::object::accessor_receiver_override_begin(value_handle.get_nanbox_f64()); - let method = crate::object::js_object_get_field_by_name( - proto_ptr, - key_handle.get_raw_const_ptr::(), - ); - crate::object::accessor_receiver_override_end(prev_override); - let method_bits = method.bits(); - if (method_bits & 0xFFFF_0000_0000_0000) != POINTER_TAG { - return None; - } - let method_ptr = (method_bits & POINTER_MASK) as usize; - if !crate::closure::is_closure_ptr(method_ptr) { - return None; - } - let recv = value_handle.get_nanbox_f64(); - let empty_str = js_string_from_bytes(b"".as_ptr(), 0); - let key_f64_arg = f64::from_bits(STRING_TAG | (empty_str as u64 & POINTER_MASK)); - let prev_this = crate::object::js_implicit_this_set(recv); - let result = crate::closure::js_native_call_value(f64::from_bits(method_bits), &key_f64_arg, 1); - crate::object::js_implicit_this_set(prev_this); - // The user callback may have installed/removed `Object.prototype.toJSON`. - invalidate_object_proto_tojson_state(); - Some(result) -} - -/// Serialize a BigInt: apply `BigInt.prototype.toJSON` if present, else throw a -/// TypeError. If `toJSON` returns another BigInt the value remains -/// unserializable and we throw. -pub(crate) unsafe fn serialize_bigint(value: f64, buf: &mut String) { - if let Some(converted) = bigint_apply_to_json(value) { - if (converted.to_bits() & 0xFFFF_0000_0000_0000) == BIGINT_TAG { - throw_bigint_serialize(); - } - stringify_value(converted, TYPE_UNKNOWN, buf); - return; - } - throw_bigint_serialize(); -} - -/// ECMA-262 SerializeJSONProperty step for a BigInt value: throw a TypeError. -/// A `toJSON`/replacer that converts the BigInt runs earlier in the walk, so -/// any BigInt reaching a serializer is unconvertible. -pub(crate) fn throw_bigint_serialize() -> ! { - let msg = "Do not know how to serialize a BigInt"; - let msg_ptr = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); - let err_ptr = crate::error::js_typeerror_new(msg_ptr); - crate::exception::js_throw(f64::from_bits( - POINTER_TAG | (err_ptr as u64 & POINTER_MASK), - )) -} - /// Check if a NaN-boxed value is a closure (function). #[inline] pub(crate) unsafe fn is_closure_value(bits: u64) -> bool { diff --git a/crates/perry-runtime/src/json/stringify_scalars.rs b/crates/perry-runtime/src/json/stringify_scalars.rs new file mode 100644 index 000000000..3ca486e60 --- /dev/null +++ b/crates/perry-runtime/src/json/stringify_scalars.rs @@ -0,0 +1,268 @@ +//! Scalar emitters for `JSON.stringify`: number formatting, string escaping, +//! and the BigInt serialization path (which always throws, per spec). +//! +//! Split out of `stringify.rs` to stay under the 2000-line CI cap. Pure code +//! move — no behaviour change. + +use super::*; +use crate::JSValue; +use std::fmt::Write as FmtWrite; + +#[inline] +pub(crate) unsafe fn write_number(buf: &mut String, value: f64) { + // A BigInt's NaN-boxed bits ARE an IEEE NaN, so it would otherwise fall into + // the `is_nan()` → "null" arm below. BigInt is unserializable (modulo a + // `toJSON`), so funnel it through the throwing serializer. Centralizes the + // BigInt rule for every object-field / array-element numeric fallback that + // reaches here (test262 JSON/stringify/value-bigint `{x: 0n}`). + if (value.to_bits() & 0xFFFF_0000_0000_0000) == BIGINT_TAG { + serialize_bigint(value, buf); + return; + } + // An int32 is NaN-boxed (INT32_TAG = 0x7FFE); its bits ARE an IEEE NaN, so it + // would otherwise fall into the `is_nan()` → "null" arm below and silently + // drop the value. Decode and emit the signed integer. Integer columns from + // the sqlite binding (and other int32-tagged numbers) funnel here; numeric + // literals are stored as plain f64 doubles by codegen and never take this + // branch. Mirrors the BigInt funnel above. + if (value.to_bits() & 0xFFFF_0000_0000_0000) == INT32_TAG { + let n = (value.to_bits() & INT32_MASK) as u32 as i32; + let mut itoa_buf = itoa::Buffer::new(); + buf.push_str(itoa_buf.format(n)); + return; + } + // #2089: a Date is now a NaN-boxed `DateCell` pointer, handled in + // `stringify_value`/`stringify_value_depth` before this numeric funnel — + // so no Date detection is needed here anymore. + if value.is_nan() || value.is_infinite() { + // JSON has no NaN/Infinity literal; the spec serializes them as null. + buf.push_str("null"); + } else if value.fract() == 0.0 && value.abs() < crate::builtins::INT_EXACT_FASTPATH_LIMIT { + // Fast path for in-range integers (the overwhelming majority of JSON + // numbers); identical to ECMAScript NumberToString below 2^53. Above it + // the exact integer can carry more digits than the shortest round-trip + // (`2**58`), so those fall through to `js_format_f64` in the else (#6127). + let mut itoa_buf = itoa::Buffer::new(); + buf.push_str(itoa_buf.format(value as i64)); + } else { + // ECMAScript Number::toString (spec 6.1.6.1.20): fixed notation for an + // exponent in -6..=20, else exponential with an `e+`/`e-` sign. `ryu` + // emits shortest round-trip digits but its own notation (`1e20`, + // `1e-6`, `1e21`), so JSON.stringify diverged from `String(n)` and + // Node. Reuse the shared JS formatter so `JSON.stringify(1e20)` is + // `100000000000000000000` (not `1e20`) and `1e21` is `1e+21`. + buf.push_str(&crate::string::js_format_f64(value)); + } +} + +#[inline] +pub(crate) unsafe fn write_escaped_string(buf: &mut String, s: &str) { + let bytes = s.as_bytes(); + // Fast path: scan for any escape-triggering byte. JSON output is + // overwhelmingly escape-free (ASCII identifiers, simple values), so + // a straight-line SIMD-friendly scan + one `push_str` beats the + // scalar per-byte escape loop. Needs_escape fires for `"`, `\`, or + // any control byte (< 0x20). + // Also trip the slow path for WTF-8 lone surrogate sequences + // (issue #1182): a lead byte of 0xED followed by 0xA0..=0xBF means + // we have a 3-byte encoding of U+D800..=U+DFFF and need to emit a + // `\uXXXX` escape rather than the raw (invalid-UTF-8) bytes. + let needs_escape = bytes + .iter() + .any(|&b| b < 0x20 || b == b'"' || b == b'\\' || b == 0xED); + if !needs_escape { + buf.reserve(bytes.len() + 2); + buf.push('"'); + buf.push_str(s); + buf.push('"'); + return; + } + + buf.push('"'); + let mut start = 0; + // Issue #548: `s` reaches us via `str_from_header`, which uses + // `from_utf8_unchecked` — a misclassified pointer (e.g. an + // ArrayHeader interpreted as a StringHeader through the GC-type + // fallback heuristic) can produce a `&str` whose bytes are not + // valid UTF-8. The original `&s[start..i]` slice operation + // panics in `core::str::is_char_boundary` whenever `i` lands + // mid-multibyte (or on a stray continuation byte). Switching to + // byte-level `extend_from_slice` writes the raw bytes through and + // never inspects char boundaries; the JSON output stays + // byte-identical for valid UTF-8 inputs and degrades gracefully + // (non-UTF-8 bytes pass through verbatim) instead of aborting the + // whole process. The String we hand back is technically + // ill-formed in the worst case, but every consumer in this + // codebase treats stringify output as a byte stream — and an + // ill-formed result is strictly preferable to a SIGABRT. + let buf_vec = buf.as_mut_vec(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + // WTF-8 surrogate handling (issue #1182). A 0xED 0xA0..=0xBF + // 0x80..=0xBF triple encodes a code unit in U+D800..=U+DFFF. + // Two cases mirror Node's JSON.stringify: + // + // * A high surrogate (0xA0..=0xAF mid byte) immediately + // followed by a low surrogate (0xB0..=0xBF mid byte) is + // a *valid* UTF-16 pair — re-encode it as the 4-byte + // UTF-8 of the astral codepoint, no escape. This is the + // only way `'\uD83D' + '\uDC4D'` round-trips through + // `dec.end()` + concat as 👍 instead of two escapes. + // + // * Any remaining (lone) surrogate triple emits the + // `\uXXXX` escape. + if b == 0xED + && i + 2 < bytes.len() + && (0xA0..=0xBF).contains(&bytes[i + 1]) + && (0x80..=0xBF).contains(&bytes[i + 2]) + { + let high_cu: u32 = (((b & 0x0F) as u32) << 12) + | (((bytes[i + 1] & 0x3F) as u32) << 6) + | ((bytes[i + 2] & 0x3F) as u32); + // Valid pair? Need the next 3 bytes to be a low surrogate + // (0xED 0xB0..=0xBF 0x80..=0xBF) directly adjacent. + let pair_low = if (0xD800..=0xDBFF).contains(&high_cu) + && i + 5 < bytes.len() + && bytes[i + 3] == 0xED + && (0xB0..=0xBF).contains(&bytes[i + 4]) + && (0x80..=0xBF).contains(&bytes[i + 5]) + { + let low_cu: u32 = (((bytes[i + 3] & 0x0F) as u32) << 12) + | (((bytes[i + 4] & 0x3F) as u32) << 6) + | ((bytes[i + 5] & 0x3F) as u32); + Some(low_cu) + } else { + None + }; + if start < i { + buf_vec.extend_from_slice(&bytes[start..i]); + } + if let Some(low_cu) = pair_low { + let cp = 0x10000 + ((high_cu - 0xD800) << 10) + (low_cu - 0xDC00); + if let Some(c) = char::from_u32(cp) { + let mut tmp = [0u8; 4]; + let s = c.encode_utf8(&mut tmp); + buf_vec.extend_from_slice(s.as_bytes()); + i += 6; + start = i; + continue; + } + } + buf_vec.extend_from_slice(format!("\\u{:04x}", high_cu).as_bytes()); + i += 3; + start = i; + continue; + } + let escape = match b { + b'"' => Some("\\\""), + b'\\' => Some("\\\\"), + b'\n' => Some("\\n"), + b'\r' => Some("\\r"), + b'\t' => Some("\\t"), + 0x08 => Some("\\b"), + 0x0c => Some("\\f"), + 0..=0x1f => { + if start < i { + buf_vec.extend_from_slice(&bytes[start..i]); + } + buf_vec.extend_from_slice(format!("\\u{:04x}", b).as_bytes()); + start = i + 1; + i += 1; + continue; + } + _ => None, + }; + if let Some(esc) = escape { + if start < i { + buf_vec.extend_from_slice(&bytes[start..i]); + } + buf_vec.extend_from_slice(esc.as_bytes()); + start = i + 1; + } + i += 1; + } + if start < bytes.len() { + buf_vec.extend_from_slice(&bytes[start..]); + } + buf_vec.push(b'"'); +} + +/// ECMA-262 SerializeJSONProperty step 2 for a BigInt: `GetV(value, "toJSON")` +/// resolves through `BigInt.prototype`. If a callable `toJSON` is installed +/// (e.g. a userland `BigInt.prototype.toJSON`), invoke it with `this` bound to +/// the BigInt and return the (serializable) result; otherwise `None` so the +/// caller throws. Unlike objects, a primitive BigInt never reaches +/// `object_get_to_json` (that helper only walks `GC_TYPE_OBJECT` layouts), so +/// the toJSON application lives here. +pub(crate) unsafe fn bigint_apply_to_json(value: f64) -> Option { + let proto = crate::object::builtin_prototype_value("BigInt"); + let proto_bits = proto.to_bits(); + if (proto_bits & 0xFFFF_0000_0000_0000) != POINTER_TAG { + return None; + } + let proto_ptr = (proto_bits & POINTER_MASK) as *const crate::ObjectHeader; + let scope = crate::gc::RuntimeHandleScope::new(); + let value_handle = scope.root_nanbox_f64(value); + let key = js_string_from_bytes(b"toJSON".as_ptr(), 6); + let key_handle = scope.root_string_ptr(key); + // `GetV(value, "toJSON")` (spec) resolves "toJSON" on `BigInt.prototype` + // but the accessor's `this` must be the BigInt VALUE, not the prototype + // object `js_object_get_field_by_name` derives its receiver from. + // `accessor_receiver_override_begin` is the same one-shot override + // `resolve_inherited_field` uses for inherited-getter receivers; stash + // the real BigInt value so a `toJSON` installed via + // `Object.defineProperty(BigInt.prototype, "toJSON", { get() {...} })` + // observes the correct receiver (test262 + // JSON/stringify/value-bigint-tojson-receiver). + let prev_override = + crate::object::accessor_receiver_override_begin(value_handle.get_nanbox_f64()); + let method = crate::object::js_object_get_field_by_name( + proto_ptr, + key_handle.get_raw_const_ptr::(), + ); + crate::object::accessor_receiver_override_end(prev_override); + let method_bits = method.bits(); + if (method_bits & 0xFFFF_0000_0000_0000) != POINTER_TAG { + return None; + } + let method_ptr = (method_bits & POINTER_MASK) as usize; + if !crate::closure::is_closure_ptr(method_ptr) { + return None; + } + let recv = value_handle.get_nanbox_f64(); + let empty_str = js_string_from_bytes(b"".as_ptr(), 0); + let key_f64_arg = f64::from_bits(STRING_TAG | (empty_str as u64 & POINTER_MASK)); + let prev_this = crate::object::js_implicit_this_set(recv); + let result = crate::closure::js_native_call_value(f64::from_bits(method_bits), &key_f64_arg, 1); + crate::object::js_implicit_this_set(prev_this); + // The user callback may have installed/removed `Object.prototype.toJSON`. + invalidate_object_proto_tojson_state(); + Some(result) +} + +/// Serialize a BigInt: apply `BigInt.prototype.toJSON` if present, else throw a +/// TypeError. If `toJSON` returns another BigInt the value remains +/// unserializable and we throw. +pub(crate) unsafe fn serialize_bigint(value: f64, buf: &mut String) { + if let Some(converted) = bigint_apply_to_json(value) { + if (converted.to_bits() & 0xFFFF_0000_0000_0000) == BIGINT_TAG { + throw_bigint_serialize(); + } + stringify_value(converted, TYPE_UNKNOWN, buf); + return; + } + throw_bigint_serialize(); +} + +/// ECMA-262 SerializeJSONProperty step for a BigInt value: throw a TypeError. +/// A `toJSON`/replacer that converts the BigInt runs earlier in the walk, so +/// any BigInt reaching a serializer is unconvertible. +pub(crate) fn throw_bigint_serialize() -> ! { + let msg = "Do not know how to serialize a BigInt"; + let msg_ptr = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32); + let err_ptr = crate::error::js_typeerror_new(msg_ptr); + crate::exception::js_throw(f64::from_bits( + POINTER_TAG | (err_ptr as u64 & POINTER_MASK), + )) +} From 16e41cc3301042ca4dea4184a5f994d6b5c6e318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 14 Jul 2026 13:08:53 +0200 Subject: [PATCH 4/4] fix(json): read the GcHeader through addr_class::try_read_gc_header The address-classification ratchet rejects re-casting `addr - GC_HEADER_SIZE` to a GcHeader, and it is right to: the raw cast here only guarded the handle band. A small-buffer slab address is heap-plausible but carries no header, so the cast would have read the previous slab entry's data bytes as a fake header and asked the malloc registry about it. `try_read_gc_header` layers the magnitude and small-buf-slab guards on top of the handle-band check. Arena-resident objects still short-circuit on the page map with no header read at all. --- crates/perry-runtime/src/json/stringify.rs | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/json/stringify.rs b/crates/perry-runtime/src/json/stringify.rs index b5e64c54b..51dc291ec 100644 --- a/crates/perry-runtime/src/json/stringify.rs +++ b/crates/perry-runtime/src/json/stringify.rs @@ -30,14 +30,28 @@ pub(crate) use super::stringify_scalars::{ /// `current_heap_header_for_user_ptr` Unknown→malloc rule. #[inline] pub(super) unsafe fn ptr_is_tracked_heap_object(ptr: *const u8) -> bool { - if crate::value::addr_class::is_handle_band(ptr as usize) { + let addr = ptr as usize; + if crate::value::addr_class::is_handle_band(addr) { return false; } - let gc_header = (ptr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; - !matches!( - crate::arena::classify_heap_generation(ptr as usize), + // Arena-resident (nursery/old/longlived) is decided by the page map alone — + // no header read at all. + if !matches!( + crate::arena::classify_heap_generation(addr), crate::arena::HeapGeneration::Unknown - ) || crate::gc::gc_malloc_header_is_tracked(gc_header) + ) { + return true; + } + // Otherwise the only way to be tracked is a registered malloc object, which + // requires a real GcHeader at `addr - GC_HEADER_SIZE`. Route through + // `addr_class::try_read_gc_header` rather than re-casting: it layers the + // magnitude guard AND the small-buffer-slab guard on top of the handle-band + // check. A slab address is heap-plausible but carries NO header, so a raw + // cast would read the previous slab entry's data bytes as a fake header. + match crate::value::addr_class::try_read_gc_header(addr) { + Some(header) => crate::gc::gc_malloc_header_is_tracked(header), + None => false, + } } pub(crate) unsafe fn is_object_pointer(ptr: *const u8) -> bool {