From 2bf938522699b948e55e8c1f28eafd36677b6cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 00:03:31 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(runtime):=20#9192=20=E2=80=94=20an=20ar?= =?UTF-8?q?ray=20with=20a=20non-array=20[[Prototype]]=20inherited=20nothin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Object.setPrototypeOf(arr, {7: "inherited", foo: "bar"})` recorded the retarget — latching the process-wide array-index deoptimisation for it — and then declined to consult it. `array_custom_array_prototype` (array/indexing.rs) accepted a recorded `[[Prototype]]` only when the prototype was ITSELF a `GC_TYPE_ARRAY`, and the named-property fallback (`array_prototype_property_value`) hardcoded `Array.prototype`. So a retargeted array inherited NOTHING from its new prototype while still inheriting everything from the old one: `a[7]` and `a.foo` were `undefined`, `7 in a` was `false`, and `typeof a.map` was still `"function"`. Silent wrong values, no crash. Perry paid the full deoptimisation for a case it did not implement. Measured against `node --experimental-strip-types` with a 136-check differential probe: 47 divergences before, 19 after — and 8 of the 19 are pre-existing gaps that reproduce identically with an ARRAY prototype (the shape that "already worked"), so they are not this bug. What changed, all behind the existing recorded-prototype gate so an array with the default chain is untouched: * `array/indexing.rs` — one classification (`ArrayCustomProto::{Null, Array, Other}`) replaces the array-only probe, and drives `array_spec_get`, `array_spec_has_index`, `array_oob_prototype_get` (the hot OOB/hole read) and `array_spec_set`. The `Array` lane is bit-for-bit the old one (test262 copyWithin/coerced-values-start-change-*); `Other` resolves through the generic object machinery with the array as the receiver, so a prototype index accessor sees the right `this` and further hops (`Object.create(Array.prototype)`) are walked; `Null` inherits nothing and suppresses the implicit `Array.prototype`/`Object.prototype` tail. * `field_get_set/accessors.rs` — `array_prototype_property_value` consults a recorded prototype before falling back to `Array.prototype`. This is what makes `a.foo` resolve AND `typeof a.map` become `"undefined"`, and it also fixes named properties on an ARRAY prototype, which never worked either. * `field_get_set/has_property.rs` — `prototype_value_has_property`, the `[[HasProperty]]` an `ArrayHeader` receiver cannot reach through `ordinary_has_property`. * `field_get_set/get_field_by_name_tail.rs` — `arr.__proto__` is the array's `[[Prototype]]`; `arr.constructor` resolves through a recorded chain instead of short-circuiting to the global `Array`. * `native_call_method/handle_methods.rs` — `arr.first()` dispatches through the recorded chain (the ES5 `MyList.prototype = Object.create( Array.prototype)` idiom), reusing the Wall-10 handle-prototype walker. * `symbol/get.rs` — the explicit-prototype symbol walk accepts an array receiver, so `arr[SYM]` inherits from a retargeted prototype. Fixture: `test-files/test_gap_9192_array_object_prototype.ts`, byte-compared against node. It FAILS on unmodified main (14 of its 36 lines diverge, every group) and passes identically with the fix. The only existing coverage of array prototype retargeting, `test_gap_typed_arrays.ts:38`, uses an array as the prototype — the one shape that already worked. Tests: `cargo test -p perry-runtime --lib` 2852 passed / 0 failed; gap suite 592/603 with 0 compile failures and 0 crashes (the 11 failures are 5 pre-existing snapshot entries, 5 node-side missing-npm-module failures in this worktree, and one suite flake that is byte-identical when re-run standalone). Claude-Session: https://claude.ai/code/session_01TE3JXAYXtdnKcLu8TCFWR6 --- changelog.d/9192-array-object-prototype.md | 19 ++ crates/perry-runtime/src/array/indexing.rs | 250 +++++++++++++++--- .../perry-runtime/src/object/field_get_set.rs | 4 +- .../src/object/field_get_set/accessors.rs | 36 +++ .../field_get_set/get_field_by_name_tail.rs | 31 +++ .../src/object/field_get_set/has_property.rs | 35 +++ .../native_call_method/handle_methods.rs | 19 ++ crates/perry-runtime/src/symbol/get.rs | 40 ++- .../test_gap_9192_array_object_prototype.ts | 150 +++++++++++ 9 files changed, 546 insertions(+), 38 deletions(-) create mode 100644 changelog.d/9192-array-object-prototype.md create mode 100644 test-files/test_gap_9192_array_object_prototype.ts diff --git a/changelog.d/9192-array-object-prototype.md b/changelog.d/9192-array-object-prototype.md new file mode 100644 index 0000000000..027f15fe48 --- /dev/null +++ b/changelog.d/9192-array-object-prototype.md @@ -0,0 +1,19 @@ +### fix(runtime): an array retargeted to a non-array prototype inherits from it + +`Object.setPrototypeOf(arr, someObject)` recorded the new `[[Prototype]]` — +and paid the process-wide array-index deoptimisation for it — but the lookup +paths then declined to consult it: the index probe accepted a recorded +prototype only when the prototype was itself an array, and the named-property +fallback hardcoded `Array.prototype`. A retargeted array therefore inherited +nothing from its new prototype while still inheriting everything from the old +one, with no error: `a[7]` and `a.foo` were `undefined`, `7 in a` was `false`, +and `typeof a.map` was still `"function"`. + +Index reads/`in`/writes, named reads, `in` on a name, `arr.constructor`, +`arr.__proto__`, symbol-keyed reads, and method dispatch (`arr.first()`, the +ES5 `MyList.prototype = Object.create(Array.prototype)` idiom) now all resolve +through the recorded chain, with the array bound as the receiver so a +prototype accessor observes the right `this`. `Object.setPrototypeOf(arr, +null)` correctly inherits nothing at all. Named properties on an *array* +prototype, which never resolved either, are fixed by the same change. Arrays +with the default prototype are untouched. Fixes #9192. diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 72fce1cc91..907f39553c 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -85,13 +85,23 @@ pub(crate) fn note_array_index_write(arr: usize) { #[inline] unsafe fn array_oob_prototype_get(receiver: usize, index: u32) -> f64 { const TAG_UNDEFINED_F64: f64 = f64::from_bits(0x7FFC_0000_0000_0001u64); - // A custom array [[Prototype]] (Object.setPrototypeOf(arr, otherArray)) - // replaces the default chain — gated on a global relaxed flag. + // A custom [[Prototype]] (`Object.setPrototypeOf(arr, p)`) replaces the + // default chain — gated on a global relaxed flag. #9192: `p` need not be an + // array; a plain object / `Object.create(Array.prototype)` result answers + // the whole lookup through the generic resolver. if crate::object::prototype_chain::array_static_proto_recorded() { - if let Some(proto_arr) = array_custom_array_prototype(receiver as *const ArrayHeader) { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return js_array_get_f64(proto_arr, index); + let arr = receiver as *const ArrayHeader; + match array_custom_prototype(arr) { + Some(ArrayCustomProto::Null) => return TAG_UNDEFINED_F64, + Some(ArrayCustomProto::Other(bits)) => { + return array_object_proto_index_get(arr, bits, index).unwrap_or(TAG_UNDEFINED_F64) } + Some(ArrayCustomProto::Array(proto_arr)) => { + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return js_array_get_f64(proto_arr, index); + } + } + None => {} } } if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { @@ -221,13 +231,23 @@ pub(crate) fn array_spec_has_index(arr: *const ArrayHeader, index: u32) -> bool if array_has_own_index(arr, index) { return true; } - // An explicit `Object.setPrototypeOf(arr, otherArray)` replaces the - // default chain — consult that array's own indices first (test262 - // copyWithin/coerced-values-start-change-*). - if let Some(proto_arr) = array_custom_array_prototype(arr) { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return true; + // An explicit `Object.setPrototypeOf(arr, p)` REPLACES the default + // chain. A real-array `p` keeps the original lane (its own indices + // first, then the implicit `Array.prototype` tail below — test262 + // copyWithin/coerced-values-start-change-*). #9192: any other `p` + // answers the whole question by itself, so the default-chain tail must + // not run after it. + match array_custom_prototype(arr) { + Some(ArrayCustomProto::Null) => return false, + Some(ArrayCustomProto::Other(bits)) => { + return array_object_proto_index_has(bits, index) + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return true; + } } + None => {} } if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { let proto = array_prototype_addr(); @@ -247,12 +267,53 @@ pub(crate) fn array_spec_has_index(arr: *const ArrayHeader, index: u32) -> bool } } -/// A custom `[[Prototype]]` installed on `arr` via `Object.setPrototypeOf` -/// that happens to be a real array — `null` otherwise. -unsafe fn array_custom_array_prototype(arr: *const ArrayHeader) -> Option<*const ArrayHeader> { +/// How a recorded custom `[[Prototype]]` on a real array must be consulted. +/// +/// #9192: before this classification the array index paths accepted a recorded +/// prototype ONLY when it was itself a `GC_TYPE_ARRAY`; every other shape (a +/// plain object, an `Object.create(Array.prototype)` result, a class prototype) +/// was recorded — latching the process-wide index deopt — and then silently +/// ignored, so the array inherited nothing at all. +pub(crate) enum ArrayCustomProto { + /// `Object.setPrototypeOf(arr, null)`: nothing is inherited, and the + /// implicit `Array.prototype` → `Object.prototype` chain is gone too. + Null, + /// The recorded prototype is itself a real array — the original lane, kept + /// bit-for-bit (test262 copyWithin/coerced-values-start-change-*). + Array(*const ArrayHeader), + /// Any other object: resolved through the generic object machinery with the + /// array as the receiver, so prototype accessors see the right `this` and + /// further hops (`Object.create(Array.prototype)`, proxies) are walked. + Other(u64), +} + +/// Classify the `[[Prototype]]` an explicit `Object.setPrototypeOf` / +/// `__proto__` / `Reflect.setPrototypeOf` recorded for `arr`. `None` when the +/// array still carries the default `Array.prototype` chain. +pub(crate) unsafe fn array_custom_prototype(arr: *const ArrayHeader) -> Option { let bits = crate::object::prototype_chain::object_static_prototype(arr as usize)?; - // The recorded proto may be NaN-boxed (0x7FFD) or a RAW untagged pointer - // (module-level arrays are stored as raw I64s). + if bits == crate::value::TAG_NULL { + return Some(ArrayCustomProto::Null); + } + if let Some(proto_arr) = array_custom_array_prototype_from_bits(arr, bits) { + return Some(ArrayCustomProto::Array(proto_arr)); + } + // A Proxy prototype keeps its existing dedicated handling in the `in` / + // property-get arms; routing it through the generic resolver here as well + // would invoke the `has` trap twice, which is observable. + if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { + return None; + } + // A pointer-shaped record that is not a real array is the #9192 case. A + // record that is not pointer-shaped at all (a stale/garbage entry) is + // reported as "no custom prototype", exactly as before. + pointer_bits_of_recorded_prototype(bits).map(|_| ArrayCustomProto::Other(bits)) +} + +/// The heap address a recorded prototype's bits name, if they are pointer +/// shaped at all. The record may be NaN-boxed (0x7FFD) or a RAW untagged +/// pointer (module-level arrays are stored as raw I64s). +fn pointer_bits_of_recorded_prototype(bits: u64) -> Option { let raw = if (bits >> 48) == 0x7FFD { (bits & crate::value::POINTER_MASK) as usize } else if (bits >> 48) == 0 && bits > 0x10000 { @@ -260,9 +321,29 @@ unsafe fn array_custom_array_prototype(arr: *const ArrayHeader) -> Option<*const } else { return None; }; + if raw == 0 { + None + } else { + Some(raw) + } +} + +/// A custom `[[Prototype]]` installed on `arr` via `Object.setPrototypeOf` +/// that happens to be a real array — `None` for every other recorded shape +/// (which [`array_custom_prototype`] reports as [`ArrayCustomProto::Other`]). +unsafe fn array_custom_array_prototype_from_bits( + arr: *const ArrayHeader, + bits: u64, +) -> Option<*const ArrayHeader> { + let raw = pointer_bits_of_recorded_prototype(bits)?; if raw < crate::gc::GC_HEADER_SIZE + 0x1000 || raw == arr as usize { return None; } + // A Proxy prototype is a small registered id, not a heap allocation — the + // GC-header read below would deref a fake pointer. + if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { + return None; + } // #5625: the recorded prototype may be a *grown* array whose stored pointer // was left FORWARDED by `js_array_grow` — its first 8 bytes now hold the // forwarding pointer to the live head instead of length+capacity. (A real @@ -285,6 +366,92 @@ unsafe fn array_custom_array_prototype(arr: *const ArrayHeader) -> Option<*const } } +/// #9192: `[[Get]]`(index) through a NON-array custom `[[Prototype]]`, binding +/// `arr` as the receiver so an inherited index accessor sees the array as +/// `this`. `None` when the whole (replaced) chain lacks the index. +/// +/// Everything here allocates — `index.to_string()` interns a key, and the +/// resolver can run a user getter — so the array and the prototype are rooted +/// and re-read across the call. +unsafe fn array_object_proto_index_get( + arr: *const ArrayHeader, + proto_bits: u64, + index: u32, +) -> Option { + // The caller may still hold a pre-grow forwarding stub; the receiver an + // inherited accessor observes must be the live head. + let arr = clean_arr_ptr(arr); + if arr.is_null() { + return None; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(arr as i64)); + let proto = scope.root_heap_word_u64(proto_bits); + let key = index.to_string(); + let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + if key_hdr.is_null() { + return None; + } + let key_handle = scope.root_nanbox_f64(crate::value::nanbox_string_key(key_hdr)); + let receiver_addr = crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as usize; + let key_ptr = crate::value::js_nanbox_get_pointer(key_handle.get_nanbox_f64()) + as *const crate::StringHeader; + crate::object::prototype_chain::resolve_inherited_field_from_prototype( + receiver_addr, + proto.get_heap_word_u64(), + key_ptr, + ) + .map(|v| f64::from_bits(v.bits())) +} + +/// #9192: the first object in a NON-array custom `[[Prototype]]` chain that +/// owns `key` with a descriptor — the owner whose accessor / attributes the +/// spec `Set` must observe before creating an own element on the array. A plain +/// writable data property carries no side-table entry and correctly reports no +/// owner: the Set then creates the own element, as the spec requires. +unsafe fn array_object_proto_index_owner(proto_bits: u64, key: &str) -> usize { + let mut bits = proto_bits; + for _ in 0..64 { + if bits == crate::value::TAG_NULL { + return 0; + } + if crate::proxy::js_proxy_is_proxy(f64::from_bits(bits)) != 0 { + return 0; + } + let Some(addr) = pointer_bits_of_recorded_prototype(bits) else { + return 0; + }; + if !crate::object::is_valid_obj_ptr(addr as *const u8) { + return 0; + } + if crate::object::get_accessor_descriptor(addr, key).is_some() + || crate::object::get_property_attrs(addr, key).is_some() + { + return addr; + } + match crate::object::prototype_chain::object_static_prototype(addr) { + Some(next) => bits = next, + None => return 0, + } + } + 0 +} + +/// #9192: `[[HasProperty]]`(index) through a NON-array custom `[[Prototype]]`. +unsafe fn array_object_proto_index_has(proto_bits: u64, index: u32) -> bool { + let scope = crate::gc::RuntimeHandleScope::new(); + let proto = scope.root_heap_word_u64(proto_bits); + let key = index.to_string(); + let key_hdr = crate::string::js_string_from_bytes(key.as_ptr(), key.len() as u32); + if key_hdr.is_null() { + return false; + } + let key_handle = scope.root_nanbox_f64(crate::value::nanbox_string_key(key_hdr)); + let key_ptr = crate::value::js_nanbox_get_pointer(key_handle.get_nanbox_f64()) + as *const crate::StringHeader; + crate::object::prototype_value_has_property(proto.get_heap_word_u64(), key_ptr) +} + /// Spec `[[Get]]`(O, ToString(index)) for an ordinary Array receiver: own value /// (firing index accessors via `js_array_get_f64`) or, for an absent own index, /// the inherited `Array.prototype[index]`. Returns `undefined` when absent. @@ -301,10 +468,19 @@ pub(crate) fn array_spec_get(arr: *const ArrayHeader, index: u32) -> f64 { if array_has_own_index(arr, index) { return js_array_get_f64(arr, index); } - if let Some(proto_arr) = array_custom_array_prototype(arr) { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return array_inherited_index_get(proto_arr, index, receiver.get_nanbox_f64()); + // #9192: see `array_spec_has_index` — a non-array custom prototype + // replaces the default chain outright. + match array_custom_prototype(arr) { + Some(ArrayCustomProto::Null) => return TAG_UNDEFINED_F64, + Some(ArrayCustomProto::Other(bits)) => { + return array_object_proto_index_get(arr, bits, index).unwrap_or(TAG_UNDEFINED_F64) + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { + return array_inherited_index_get(proto_arr, index, receiver.get_nanbox_f64()); + } } + None => {} } if ARRAY_PROTO_HAS_INDEX.load(Ordering::Relaxed) { let proto = array_prototype_addr(); @@ -353,12 +529,26 @@ pub(crate) fn array_spec_set(arr: *mut ArrayHeader, index: u32, value: f64) -> * ); } - let mut inherited_owner = - array_custom_array_prototype(arr_handle.get_raw_mut_ptr::()) - .filter(|proto| array_has_own_index(*proto, index)) - .map(|proto| proto as usize) - .unwrap_or(0); - if inherited_owner == 0 { + // #9192: a non-array custom `[[Prototype]]` owns the whole answer — its + // chain supplies the inherited accessor / non-writable attributes, and + // the implicit `Array.prototype` / `Object.prototype` tail below must + // not run. An explicit null prototype inherits nothing at all. + let mut default_chain = true; + let mut inherited_owner = 0usize; + match array_custom_prototype(arr_handle.get_raw_mut_ptr::()) { + Some(ArrayCustomProto::Null) => default_chain = false, + Some(ArrayCustomProto::Other(bits)) => { + default_chain = false; + inherited_owner = array_object_proto_index_owner(bits, &key); + } + Some(ArrayCustomProto::Array(proto_arr)) => { + if array_has_own_index(proto_arr, index) { + inherited_owner = proto_arr as usize; + } + } + None => {} + } + if inherited_owner == 0 && default_chain { let proto = array_prototype_addr(); inherited_owner = if proto != 0 && proto != arr_handle.get_raw_mut_ptr::() as usize @@ -917,11 +1107,11 @@ pub extern "C" fn js_array_get_f64(arr: *const ArrayHeader, index: u32) -> f64 { // are gated (registry lookup / relaxed atomic) so the dense hot path // is unchanged. if raw.to_bits() == crate::value::TAG_HOLE { - if let Some(proto_arr) = array_custom_array_prototype(arr) { - if index < (*proto_arr).length && array_has_own_index(proto_arr, index) { - return js_array_get_f64(proto_arr, index); - } - } + // #9192: the custom-`[[Prototype]]` probe this arm used to inline + // accepted only a real-array prototype. `array_oob_prototype_get` + // now classifies every recorded shape (array / ordinary object / + // explicit null) behind the same latch, so the duplicate probe is + // gone and a plain-object prototype fills the hole too. return array_oob_prototype_get(arr as usize, index); } raw diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index 681b664fd8..abc3e06d81 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -277,8 +277,8 @@ pub(crate) use get_field_by_name_async::async_resource_property; pub(crate) use get_field_by_name_tail::get_field_by_name_object_tail; pub(super) use has_property::native_module_own_field_by_key; pub(crate) use has_property::{ - closure_dynamic_prop_by_key, reified_function_method_name, wide_key_index_lookup, - wide_key_index_note_hit, WIDE_KEY_INDEX_MIN_KEYS, + closure_dynamic_prop_by_key, prototype_value_has_property, reified_function_method_name, + wide_key_index_lookup, wide_key_index_note_hit, WIDE_KEY_INDEX_MIN_KEYS, }; pub use has_property::{js_in_operator, js_object_has_property}; #[cfg(test)] diff --git a/crates/perry-runtime/src/object/field_get_set/accessors.rs b/crates/perry-runtime/src/object/field_get_set/accessors.rs index d622fca9dc..0b187801e1 100644 --- a/crates/perry-runtime/src/object/field_get_set/accessors.rs +++ b/crates/perry-runtime/src/object/field_get_set/accessors.rs @@ -687,6 +687,42 @@ pub(crate) unsafe fn array_prototype_property_value( let name_copy = super::HeapKeyBytes::copy_of(name.as_bytes()); let name: &str = std::str::from_utf8_unchecked(name_copy.as_bytes()); + // #9192: an explicit `Object.setPrototypeOf(arr, p)` REPLACES the implicit + // `Array.prototype` chain this function otherwise hardcodes. Before the fix + // a retargeted array both failed to inherit `p`'s named properties AND kept + // inheriting `Array.prototype`'s — `Object.setPrototypeOf(a, {foo:1})` left + // `a.foo` undefined while `typeof a.map` stayed `"function"` (node: `1` and + // `"undefined"`). `null` inherits nothing at all. + // + // The two callers that pass a NON-array receiver here + // (`array_subclass_prototype_field` and the `fill` fallback in + // `native_call_method`) already require the absence of a recorded + // prototype, so this branch is reachable only for a retargeted array. + if let Some(proto_bits) = super::super::prototype_chain::object_static_prototype(receiver_addr) + { + if proto_bits == crate::value::TAG_NULL { + return None; + } + let scope = crate::gc::RuntimeHandleScope::new(); + let receiver_h = + scope.root_nanbox_f64(crate::value::js_nanbox_pointer(receiver_addr as i64)); + let proto_h = scope.root_heap_word_u64(proto_bits); + let key = crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32); + if key.is_null() { + return None; + } + let key_h = scope.root_nanbox_f64(crate::value::nanbox_string_key(key)); + let receiver_addr = + crate::value::js_nanbox_get_pointer(receiver_h.get_nanbox_f64()) as usize; + let key = crate::value::js_nanbox_get_pointer(key_h.get_nanbox_f64()) + as *const crate::StringHeader; + return super::super::prototype_chain::resolve_inherited_field_from_prototype( + receiver_addr, + proto_h.get_heap_word_u64(), + key, + ); + } + let scope = crate::gc::RuntimeHandleScope::new(); let receiver_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(receiver_addr as i64)); let ctor = super::super::js_get_global_this_builtin_value(b"Array".as_ptr(), 5); diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index a0908f467a..7f2a59b027 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -889,6 +889,27 @@ pub(crate) fn get_field_by_name_object_tail( if key_bytes == b"length" { return JSValue::number(crate::array::js_array_length(arr) as f64); } + // #9192: `arr.__proto__` IS the array's `[[Prototype]]` (the + // spec models it as an `Object.prototype` accessor returning + // `[[GetPrototypeOf]](this)`) — the same shape the closure arm + // above resolves off the static-prototype side table. Without + // it a retargeted array reported the WRONG object here while + // `Object.getPrototypeOf(arr)` reported the right one. + if key_bytes == b"__proto__" { + // `__proto__` itself lives on `Object.prototype`, so an + // array whose chain no longer reaches it (an explicit null + // prototype) has no such property at all. + if crate::object::prototype_chain::object_static_prototype(obj as usize) + == Some(crate::value::TAG_NULL) + { + return JSValue::undefined(); + } + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let proto = crate::object::object_ops::js_object_get_prototype_of( + f64::from_bits(receiver.to_bits()), + ); + return JSValue::from_bits(proto.to_bits()); + } // date-fns / drizzle / lodash duck-typing path: // `arr.constructor === Array`, `new arr.constructor(...)`, // etc. expect a non-undefined function-typed value that @@ -906,6 +927,16 @@ pub(crate) fn get_field_by_name_object_tail( if let Some(v) = crate::array::array_named_property_get(arr, key) { return JSValue::from_bits(v.to_bits()); } + // A recorded custom `[[Prototype]]` replaces the whole + // implicit chain, so `constructor` must be resolved through + // it (a plain `{}` prototype answers `Object`, not `Array`) + // rather than short-circuiting to the global `Array`. #9192. + if crate::object::prototype_chain::object_static_prototype(obj as usize) + .is_some() + { + return array_prototype_property_value("constructor", obj as usize) + .unwrap_or_else(JSValue::undefined); + } let v = js_get_global_this_builtin_value(b"Array".as_ptr(), 5); return JSValue::from_bits(v.to_bits()); } diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 8da38582cd..97d4883177 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1251,6 +1251,41 @@ unsafe fn ordinary_has_property( ordinary_object_prototype_property_value(last_valid, key).is_some() } +/// #9192: ECMA-262 `[[HasProperty]]` on a value that is serving as some other +/// object's recorded `[[Prototype]]`. +/// +/// The array index / named-key `in` arms need this: their receiver is an +/// `ArrayHeader`, so they cannot enter [`ordinary_has_property`]'s object walk +/// on the receiver itself, but the recorded prototype they must consult is an +/// ordinary object (or another array — the walk handles both). `TAG_NULL` +/// answers `false`: `Object.setPrototypeOf(arr, null)` inherits nothing. +pub(crate) unsafe fn prototype_value_has_property( + proto_bits: u64, + key: *const crate::StringHeader, +) -> bool { + const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; + if proto_bits == TAG_NULL || key.is_null() { + return false; + } + let proto_val = f64::from_bits(proto_bits); + if crate::proxy::js_proxy_is_proxy(proto_val) != 0 { + let key_val = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); + return crate::value::js_is_truthy(crate::proxy::js_proxy_has(proto_val, key_val)) != 0; + } + let top16 = proto_bits >> 48; + let proto_ptr = if top16 == 0x7FFD { + (proto_bits & crate::value::POINTER_MASK) as usize + } else if top16 == 0 && proto_bits > 0x10000 { + proto_bits as usize + } else { + return false; + }; + if proto_ptr == 0 || !super::super::is_valid_obj_ptr(proto_ptr as *const u8) { + return false; + } + ordinary_has_property(proto_ptr as *const ObjectHeader, key) +} + /// Get a field by its string key name /// Returns the field value or undefined if the key is not found pub(crate) unsafe fn closure_dynamic_prop_by_key( diff --git a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs index d9f8e2a538..613d7968fe 100644 --- a/crates/perry-runtime/src/object/native_call_method/handle_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/handle_methods.rs @@ -227,6 +227,25 @@ pub(super) unsafe fn dispatch_handle( return Some(result); } } + // #9192: `Object.setPrototypeOf(arr, p)` REPLACES the implicit + // `Array.prototype` chain, so a method that lives on `p` — the + // ES5 subclass idiom `MyList.prototype = Object.create( + // Array.prototype); MyList.prototype.first = …` — is the one + // `arr.first()` must call. Without this the built-in arms below + // never matched the name and the call fell through to the + // tower's non-object tail, which answers the null-object stub. + // Same walker Wall 10 uses for `Object.setPrototypeOf(handle, + // proto)`; it costs one side-table probe that answers `None` + // for every array with the default prototype. + if let Some(result) = dispatch_handle_proto_method( + crate::array::clean_arr_ptr(arr) as usize, + f64::from_bits(jsval.bits()), + method_name, + args_ptr, + args_len, + ) { + return Some(result); + } // #6658: an explicit `thisArg` (2nd argument) must bind the // callback's `this`. The dense helpers the arms below dispatch // to deliberately bind `undefined` (spec: absent thisArg) and diff --git a/crates/perry-runtime/src/symbol/get.rs b/crates/perry-runtime/src/symbol/get.rs index b7f339b35a..ba6aa95351 100644 --- a/crates/perry-runtime/src/symbol/get.rs +++ b/crates/perry-runtime/src/symbol/get.rs @@ -279,6 +279,34 @@ unsafe fn req_handle_symbol_fallback(obj_f64: f64, sym_f64: f64) -> Option } unsafe fn object_header_ptr_from_value_bits(bits: u64) -> Option { + let (raw, obj_type) = heap_ptr_and_type_from_value_bits(bits)?; + if obj_type == crate::gc::GC_TYPE_OBJECT { + Some(raw) + } else { + None + } +} + +/// #9192: [`object_header_ptr_from_value_bits`] for the RECEIVER position, +/// which may be a real array (`Object.setPrototypeOf(arr, {[S]: v})`). Only the +/// ADDRESS is used here — as the key of the recorded-`[[Prototype]]` lookup — +/// so an `ArrayHeader` is safe, while the chain HOPS still require a genuine +/// `GC_TYPE_OBJECT` before anything is dereferenced as one. +unsafe fn receiver_ptr_from_value_bits(bits: u64) -> Option { + let (raw, obj_type) = heap_ptr_and_type_from_value_bits(bits)?; + if obj_type == crate::gc::GC_TYPE_OBJECT + || obj_type == crate::gc::GC_TYPE_ARRAY + || obj_type == crate::gc::GC_TYPE_LAZY_ARRAY + { + Some(raw) + } else { + None + } +} + +/// Validate a value's bits as a live tracked heap allocation and report its +/// address together with its GC type byte. +unsafe fn heap_ptr_and_type_from_value_bits(bits: u64) -> Option<(usize, u8)> { let top16 = bits >> 48; let raw = if top16 == 0x7FFD { (bits & POINTER_MASK) as usize @@ -304,11 +332,7 @@ unsafe fn object_header_ptr_from_value_bits(bits: u64) -> Option { if !tracked_malloc && !(arena_payload && arena_header) { return None; } - if (*gc_header).obj_type == crate::gc::GC_TYPE_OBJECT { - Some(raw) - } else { - None - } + Some((raw, (*gc_header).obj_type)) } /// Walk the explicit static prototype chain to find an inherited symbol property. @@ -320,7 +344,11 @@ pub(crate) unsafe fn inherited_symbol_property(obj_f64: f64, sym_f64: f64) -> Op unsafe fn resolve_explicit_object_prototype_symbol(obj_f64: f64, sym_f64: f64) -> Option { const TAG_NULL: u64 = 0x7FFC_0000_0000_0002; - let mut owner = object_header_ptr_from_value_bits(obj_f64.to_bits())?; + // #9192: the receiver may be a real ARRAY with a retargeted `[[Prototype]]` + // (`Object.setPrototypeOf(arr, {[S]: v})`). Its address is only a lookup + // key here, so accept it; every chain HOP below still demands a real + // `GC_TYPE_OBJECT` before dereferencing. + let mut owner = receiver_ptr_from_value_bits(obj_f64.to_bits())?; let mut visited_buf = [0usize; 16]; let mut visited_len = 0usize; let mut visited_overflow: Option> = None; diff --git a/test-files/test_gap_9192_array_object_prototype.ts b/test-files/test_gap_9192_array_object_prototype.ts new file mode 100644 index 0000000000..2eafd9f17e --- /dev/null +++ b/test-files/test_gap_9192_array_object_prototype.ts @@ -0,0 +1,150 @@ +// #9192: `Object.setPrototypeOf(array, )` — the array must +// inherit from that object. Perry recorded the retarget (paying the +// process-wide index deoptimisation for it) and then declined to consult it: +// `array_custom_array_prototype` accepted a recorded `[[Prototype]]` only when +// the prototype was ITSELF a `GC_TYPE_ARRAY`, and the named-property fallback +// (`array_prototype_property_value`) hardcoded `Array.prototype`. So a +// retargeted array inherited NOTHING from its new prototype while still +// inheriting everything from the old one — silent wrong values, no crash. +// +// The one fixture that covered prototype retargeting on an array +// (test_gap_typed_arrays.ts) uses an ARRAY as the prototype, the single shape +// that already worked. This covers the shapes that did not: a plain object, the +// ES5 `Object.create(Array.prototype)` subclass idiom, a deeper chain, an +// accessor-bearing prototype, `null`, and the three ways to install one +// (`Object.setPrototypeOf`, `__proto__`, `Reflect.setPrototypeOf`). +// +// Run: node --experimental-strip-types test_gap_9192_array_object_prototype.ts + +// --- a plain object prototype: indexed AND named inheritance --- +const protoA: any = { 7: "inherited", foo: "bar" }; +const a: any = [1, 2, 3]; +Object.setPrototypeOf(a, protoA); + +console.log("A elem:", a[7], a["7"], a[2]); +console.log("A named:", a.foo); +console.log("A in:", 7 in a, "7" in a, "foo" in a, 2 in a, 9 in a); +console.log( + "A hasOwn:", + Object.prototype.hasOwnProperty.call(a, 7), + Object.prototype.hasOwnProperty.call(a, "foo"), + Object.prototype.hasOwnProperty.call(a, 0), +); +console.log("A length/isArray:", a.length, Array.isArray(a)); +console.log("A keys:", Object.keys(a).join(",")); +console.log("A ownNames:", Object.getOwnPropertyNames(a).join(",")); +console.log("A json:", JSON.stringify(a)); +console.log("A proto identity:", Object.getPrototypeOf(a) === protoA, a.__proto__ === protoA); +console.log("A ownDesc:", Object.getOwnPropertyDescriptor(a, 7), Object.getOwnPropertyDescriptor(a, "foo")); + +// `Array.prototype` is no longer on the chain, so its methods are gone. +console.log("A methods gone:", typeof a.map, typeof a.push, typeof a.join); +// ...but a borrowed one still works on the array-like receiver. +console.log("A borrowed:", Array.prototype.join.call(a, "-")); + +// An own write shadows the inherited property without disturbing the prototype. +a[7] = "own"; +a.foo = "mine"; +console.log("A shadowed:", a[7], a.foo, protoA[7], protoA.foo); +console.log("A hasOwn after write:", Object.prototype.hasOwnProperty.call(a, 7)); + +// --- the ES5 array-subclass idiom --- +function MyList(this: any) {} +MyList.prototype = Object.create(Array.prototype); +MyList.prototype.tag = "mylist"; +MyList.prototype.first = function (this: any) { + return this[0]; +}; +MyList.prototype[5] = "fifth"; + +const b: any = [10, 20]; +Object.setPrototypeOf(b, MyList.prototype); +console.log("B inherited:", b.tag, b.first(), b[5], 5 in b); +console.log("B instanceof:", b instanceof (MyList as any), b instanceof Array, Array.isArray(b)); +// `Array.prototype` is still on the chain here, so its methods keep working. +console.log("B array methods:", b.map((x: number) => x + 1).join(","), b.join("-")); +b.push(30); +console.log("B after push:", b.length, b[2], JSON.stringify(b), b.slice().join(",")); +console.log("B keys:", Object.keys(b).join(",")); +console.log("B proto identity:", Object.getPrototypeOf(b) === MyList.prototype); + +// --- a deeper chain that still reaches Array.prototype --- +const mid: any = Object.create(Array.prototype); +mid.mid = "M"; +const top: any = Object.create(mid); +top.top = "T"; +top[8] = "eight"; +const j: any = [0]; +Object.setPrototypeOf(j, top); +console.log("J chain:", j.top, j.mid, j[8], 8 in j, typeof j.map); +console.log("J map:", j.map((x: number) => x + 1).join(",")); + +// --- an accessor on the prototype, at an index and at a name --- +const protoG: any = {}; +let getterCalls = 0; +let getterThisIsG = false; +Object.defineProperty(protoG, "9", { + configurable: true, + get(this: any) { + getterCalls++; + getterThisIsG = this === g; + return "acc9"; + }, +}); +Object.defineProperty(protoG, "named", { + configurable: true, + get() { + return "accNamed"; + }, +}); +const g: any = [1]; +Object.setPrototypeOf(g, protoG); +console.log("G accessor:", g[9], getterCalls, getterThisIsG, g.named); + +// --- a null prototype inherits nothing at all --- +const c: any = [1, 2]; +Object.setPrototypeOf(c, null); +console.log("C basics:", c[0], c.length, Array.isArray(c), Object.getPrototypeOf(c)); +console.log("C nothing inherited:", typeof c.map, "toString" in c, 0 in c, 5 in c); +console.log("C keys/json:", Object.keys(c).join(","), JSON.stringify(c)); +c[3] = 7; +console.log("C after write:", c.length, c[3]); + +// --- restoring Array.prototype restores the default behaviour --- +const h: any = [1, 2]; +Object.setPrototypeOf(h, { 5: "tmp" }); +console.log("H retargeted:", h[5]); +Object.setPrototypeOf(h, Array.prototype); +console.log("H restored:", h[5], h.map((x: number) => x * 3).join(","), Object.getPrototypeOf(h) === Array.prototype); + +// --- an ARRAY prototype (the shape that already worked) must keep working, +// including its NAMED properties, which did NOT work before #9192. +const protoI: any = []; +protoI[6] = "protoSix"; +protoI.tagged = "arrProto"; +const i2: any = [1, 2]; +Object.setPrototypeOf(i2, protoI); +console.log("I array proto:", i2[6], 6 in i2, i2.tagged, i2.length, typeof i2.map); + +// --- `__proto__` and `Reflect.setPrototypeOf` install the same link --- +const d: any = [1]; +const protoD: any = { 3: "d3", zap: "zop" }; +d.__proto__ = protoD; +console.log("D __proto__:", d[3], d.zap, 3 in d, Object.getPrototypeOf(d) === protoD); + +const e: any = [1]; +const protoE: any = { 4: "e4", zip: "zup" }; +console.log("E reflect set:", Reflect.setPrototypeOf(e, protoE)); +console.log("E reflect read:", e[4], e.zip, Reflect.has(e, "zip"), Reflect.has(e, "4"), Reflect.get(e, "4")); +console.log("E ownKeys:", Reflect.ownKeys(e).map(String).join(",")); + +// --- a hole reads through the replaced chain --- +const k: any = [0, , 2]; +Object.setPrototypeOf(k, { 1: "holeFill" }); +console.log("K hole:", k[1], 1 in k); + +// --- `Object.create(Array.prototype)` itself is NOT an array --- +const f: any = Object.create(Array.prototype); +f[0] = "x"; +f.length = 1; +console.log("F not an array:", Array.isArray(f), f.length, f.join("-"), f[0]); From fa07cb5aae8ffbe6407fe6ef7db8fe5aa76f3314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 31 Aug 2026 02:05:40 +0200 Subject: [PATCH 2/2] fix(runtime): pair the new prototype reads with the handle-band predicate; split two files at the cap The raw-pointer branch used a 0x10000 floor where HANDLE_BAND_MAX is 0x100000, so it admitted handle-band values and handed them to a dereference; both new is_valid_obj_ptr guards were unpaired (#6279). Splits array/indexing.rs and get_field_by_name_tail.rs, which crossed the 2000-line cap. --- crates/perry-runtime/src/array/indexing.rs | 91 ++++--------------- .../src/array/keys_len_cap_tests.rs | 55 +++++++++++ crates/perry-runtime/src/array/mod.rs | 8 ++ .../src/array/strict_dense_test_helpers.rs | 24 +++++ .../perry-runtime/src/object/field_get_set.rs | 3 + .../field_get_set/array_retargeted_proto.rs | 41 +++++++++ .../field_get_set/get_field_by_name_tail.rs | 21 +---- .../src/object/field_get_set/has_property.rs | 12 ++- 8 files changed, 164 insertions(+), 91 deletions(-) create mode 100644 crates/perry-runtime/src/array/keys_len_cap_tests.rs create mode 100644 crates/perry-runtime/src/array/strict_dense_test_helpers.rs create mode 100644 crates/perry-runtime/src/object/field_get_set/array_retargeted_proto.rs diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index 907f39553c..2e6a8590e1 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -56,6 +56,14 @@ pub(crate) fn test_element_accessor_calls() -> u64 { ELEMENT_ACCESSOR_CALLS.with(std::cell::Cell::get) } +// The two strict-dense store helpers live in `strict_dense_test_helpers` +// (2000-line cap). Re-exported by name so `super::indexing::…` paths in the +// existing test modules keep resolving — a glob would not propagate. +#[cfg(test)] +pub(crate) use super::strict_dense_test_helpers::{ + test_strict_dense_number_store, test_strict_dense_pointer_overwrite, +}; + pub(crate) fn object_prototype_has_index_flag() -> bool { OBJECT_PROTO_HAS_INDEX.load(Ordering::Relaxed) } @@ -421,7 +429,12 @@ unsafe fn array_object_proto_index_owner(proto_bits: u64, key: &str) -> usize { let Some(addr) = pointer_bits_of_recorded_prototype(bits) else { return 0; }; - if !crate::object::is_valid_obj_ptr(addr as *const u8) { + // Pair the band predicate with the validity check (#6279): a handle + // value sits below HANDLE_BAND_MAX and would otherwise be dereferenced + // as if it were an object pointer. + if !crate::value::addr_class::is_above_handle_band(addr as usize) + || !crate::object::is_valid_obj_ptr(addr as *const u8) + { return 0; } if crate::object::get_accessor_descriptor(addr, key).is_some() @@ -615,7 +628,10 @@ unsafe fn array_inherited_index_get( js_array_get_f64(proto_arr, index) } -fn array_get_property_by_key(arr: *const ArrayHeader, key: *const crate::StringHeader) -> f64 { +pub(crate) fn array_get_property_by_key( + arr: *const ArrayHeader, + key: *const crate::StringHeader, +) -> f64 { // #7891: an erased Array declaration can feed this ABI a heap StringHeader. // The receiver arrived unboxed and no longer carries STRING_TAG, so recover // its runtime kind from the GC header before ordinary by-name lookup. A @@ -1381,7 +1397,7 @@ fn array_strict_index_write_guard_resolved(clean: *mut ArrayHeader, index: u32, /// `f64` the raw-f64 layout stores), cannot be a heap pointer (no barrier, no /// pointer-mask update), and keeps a pointer-free or tag-scanned layout valid. #[inline] -unsafe fn try_strict_dense_number_store( +pub(crate) unsafe fn try_strict_dense_number_store( arr: *mut ArrayHeader, index: u32, value: f64, @@ -1496,14 +1512,6 @@ unsafe fn try_strict_dense_number_store( } /// Exercised by the unit tests: `true` when the fast lane answered the store. -#[cfg(test)] -pub(crate) fn test_strict_dense_number_store( - arr: *mut ArrayHeader, - index: u32, - value: f64, -) -> bool { - unsafe { try_strict_dense_number_store(arr, index, value) }.is_some() -} /// The strict store's third exact lane: an in-range overwrite of a slot that /// holds a heap pointer with another heap pointer — `column[index] = record` @@ -1529,7 +1537,7 @@ pub(crate) fn test_strict_dense_number_store( /// # Safety /// `arr` is decoded and validated before any dereference, exactly as in /// [`try_strict_dense_number_store`]. -unsafe fn try_strict_dense_pointer_overwrite( +pub(crate) unsafe fn try_strict_dense_pointer_overwrite( arr: *mut ArrayHeader, index: u32, value: f64, @@ -1600,14 +1608,6 @@ unsafe fn try_strict_dense_pointer_overwrite( /// Exercised by the unit tests: `true` when the pointer-overwrite lane /// answered the store. -#[cfg(test)] -pub(crate) fn test_strict_dense_pointer_overwrite( - arr: *mut ArrayHeader, - index: u32, - value: f64, -) -> bool { - unsafe { try_strict_dense_pointer_overwrite(arr, index, value) }.is_some() -} #[no_mangle] pub extern "C" fn js_array_set_f64_extend_strict( @@ -1996,54 +1996,3 @@ unsafe fn js_array_set_f64_extend_resolved( arr } } - -#[cfg(test)] -mod keys_len_cap_tests { - use super::{js_array_length, keys_array_len_capped_to_capacity}; - - #[test] - fn keys_len_capped_bounds_bogus_length_to_capacity() { - // Freshly-allocated array: well-formed (length 0 <= capacity), so the - // cap is a no-op and returns the real length. - let arr = crate::array::js_array_alloc(8); - let capacity = unsafe { (*arr).capacity } as usize; - assert!(capacity >= 8); - assert_eq!(unsafe { keys_array_len_capped_to_capacity(arr) }, 0); - - // Simulate a malformed keys array whose length field reports a bogus, - // pointer-sized value — the pathology the object property walks guard - // against. Un-capped, callers would iterate/allocate ~645M slots. - unsafe { - (*arr).length = 645_115_168; - } - assert_eq!( - js_array_length(arr) as usize, - 645_115_168, - "sanity: js_array_length reflects the forged length" - ); - assert_eq!( - unsafe { keys_array_len_capped_to_capacity(arr) }, - capacity, - "cap must bound a bogus oversized length to the array's capacity" - ); - } -} - -#[cfg(test)] -mod claimed_array_string_receiver_tests { - use super::array_get_property_by_key; - - #[test] - fn numeric_string_key_reads_a_heap_string_before_by_name_fallback() { - let receiver = crate::string::js_string_from_bytes(b"ss".as_ptr(), 2); - let zero = crate::string::js_string_from_bytes(b"0".as_ptr(), 1); - let indexed = array_get_property_by_key(receiver.cast(), zero); - assert_eq!( - crate::builtins::jsvalue_string_content(indexed).as_deref(), - Some("s") - ); - - let length = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); - assert_eq!(array_get_property_by_key(receiver.cast(), length), 2.0); - } -} diff --git a/crates/perry-runtime/src/array/keys_len_cap_tests.rs b/crates/perry-runtime/src/array/keys_len_cap_tests.rs new file mode 100644 index 0000000000..00c87a43d5 --- /dev/null +++ b/crates/perry-runtime/src/array/keys_len_cap_tests.rs @@ -0,0 +1,55 @@ +//! Test-only bounds and receiver checks split out of `indexing.rs` to keep +//! it under the 2000-line cap. The modules are unchanged; only their home +//! file moved. + +#[cfg(test)] +mod keys_len_cap_tests { + use crate::array::js_array_length; + use crate::array::keys_array_len_capped_to_capacity; + + #[test] + fn keys_len_capped_bounds_bogus_length_to_capacity() { + // Freshly-allocated array: well-formed (length 0 <= capacity), so the + // cap is a no-op and returns the real length. + let arr = crate::array::js_array_alloc(8); + let capacity = unsafe { (*arr).capacity } as usize; + assert!(capacity >= 8); + assert_eq!(unsafe { keys_array_len_capped_to_capacity(arr) }, 0); + + // Simulate a malformed keys array whose length field reports a bogus, + // pointer-sized value — the pathology the object property walks guard + // against. Un-capped, callers would iterate/allocate ~645M slots. + unsafe { + (*arr).length = 645_115_168; + } + assert_eq!( + js_array_length(arr) as usize, + 645_115_168, + "sanity: js_array_length reflects the forged length" + ); + assert_eq!( + unsafe { keys_array_len_capped_to_capacity(arr) }, + capacity, + "cap must bound a bogus oversized length to the array's capacity" + ); + } +} + +#[cfg(test)] +mod claimed_array_string_receiver_tests { + use crate::array::indexing::array_get_property_by_key; + + #[test] + fn numeric_string_key_reads_a_heap_string_before_by_name_fallback() { + let receiver = crate::string::js_string_from_bytes(b"ss".as_ptr(), 2); + let zero = crate::string::js_string_from_bytes(b"0".as_ptr(), 1); + let indexed = array_get_property_by_key(receiver.cast(), zero); + assert_eq!( + crate::builtins::jsvalue_string_content(indexed).as_deref(), + Some("s") + ); + + let length = crate::string::js_string_from_bytes(b"length".as_ptr(), 6); + assert_eq!(array_get_property_by_key(receiver.cast(), length), 2.0); + } +} diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index 31a15ec68e..089df242b5 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -13,6 +13,14 @@ mod header; mod header_gc_slots; mod immutable; mod indexing; +/// Keys-array length-cap bounds tests, split out of `indexing.rs` for the +/// 2000-line cap. +#[cfg(test)] +mod keys_len_cap_tests; +/// Test-only strict-dense store helpers, split out of `indexing.rs` for the +/// 2000-line cap. +#[cfg(test)] +mod strict_dense_test_helpers; #[cfg(test)] pub(crate) use indexing::test_element_accessor_calls; mod indexing_support; diff --git a/crates/perry-runtime/src/array/strict_dense_test_helpers.rs b/crates/perry-runtime/src/array/strict_dense_test_helpers.rs new file mode 100644 index 0000000000..8f26b6bbc8 --- /dev/null +++ b/crates/perry-runtime/src/array/strict_dense_test_helpers.rs @@ -0,0 +1,24 @@ +//! Test-only strict-dense store helpers, split out of `indexing.rs` to keep +//! it under the 2000-line cap. `#![cfg(test)]` at module level, so the +//! per-item `#[cfg(test)]` attributes the originals carried are dropped. + +#![cfg(test)] + +use super::indexing::{try_strict_dense_number_store, try_strict_dense_pointer_overwrite}; +use super::*; + +pub(crate) fn test_strict_dense_pointer_overwrite( + arr: *mut ArrayHeader, + index: u32, + value: f64, +) -> bool { + unsafe { try_strict_dense_pointer_overwrite(arr, index, value) }.is_some() +} + +pub(crate) fn test_strict_dense_number_store( + arr: *mut ArrayHeader, + index: u32, + value: f64, +) -> bool { + unsafe { try_strict_dense_number_store(arr, index, value) }.is_some() +} diff --git a/crates/perry-runtime/src/object/field_get_set.rs b/crates/perry-runtime/src/object/field_get_set.rs index abc3e06d81..40f0ff4f3d 100644 --- a/crates/perry-runtime/src/object/field_get_set.rs +++ b/crates/perry-runtime/src/object/field_get_set.rs @@ -197,6 +197,9 @@ pub(crate) fn is_fetch_subclass_body_method(name: &[u8]) -> bool { // ── Topical sub-modules (issue #1103: keep every file < 2000 lines) ── mod accessors; pub(crate) use accessors::scan_accessor_receiver_override_root_mut; +/// #9192 array prototype/constructor slots, split out of +/// `get_field_by_name_tail.rs` for the 2000-line cap. +mod array_retargeted_proto; mod buffer_own_prop; mod class_object_props; mod crypto_key; diff --git a/crates/perry-runtime/src/object/field_get_set/array_retargeted_proto.rs b/crates/perry-runtime/src/object/field_get_set/array_retargeted_proto.rs new file mode 100644 index 0000000000..159c7d1dec --- /dev/null +++ b/crates/perry-runtime/src/object/field_get_set/array_retargeted_proto.rs @@ -0,0 +1,41 @@ +//! #9192: array property slots that a retargeted `[[Prototype]]` changes. +//! +//! Split out of `get_field_by_name_tail.rs`, which is at the 2000-line cap. +//! Both slots answer off the static-prototype side table rather than the +//! implicit array chain, because a recorded custom `[[Prototype]]` replaces +//! that chain entirely. + +use super::accessors::array_prototype_property_value; +use crate::value::JSValue; + +/// `arr.__proto__` IS the array's `[[Prototype]]` — the spec models it as an +/// `Object.prototype` accessor returning `[[GetPrototypeOf]](this)`. Without +/// this a retargeted array reported the WRONG object while +/// `Object.getPrototypeOf(arr)` reported the right one. +pub(super) fn array_proto_slot(obj: *const crate::object::ObjectHeader) -> JSValue { + // `__proto__` itself lives on `Object.prototype`, so an array whose chain + // no longer reaches it (an explicit null prototype) has no such property. + if crate::object::prototype_chain::object_static_prototype(obj as usize) + == Some(crate::value::TAG_NULL) + { + return JSValue::undefined(); + } + let receiver = crate::value::js_nanbox_pointer(obj as i64); + let proto = + crate::object::object_ops::js_object_get_prototype_of(f64::from_bits(receiver.to_bits())); + JSValue::from_bits(proto.to_bits()) +} + +/// A recorded custom `[[Prototype]]` replaces the whole implicit chain, so +/// `constructor` must resolve through it — a plain `{}` prototype answers +/// `Object`, not `Array` — rather than short-circuiting to the global `Array`. +/// `None` means no retarget was recorded and the caller keeps its fast path. +pub(super) fn array_constructor_slot(obj: *const crate::object::ObjectHeader) -> Option { + crate::object::prototype_chain::object_static_prototype(obj as usize)?; + // SAFETY: the caller has already established `obj` as a live array + // header; this reads the recorded prototype's own properties. + Some( + unsafe { array_prototype_property_value("constructor", obj as usize) } + .unwrap_or_else(JSValue::undefined), + ) +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index 7f2a59b027..8c82bd50b5 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -896,19 +896,7 @@ pub(crate) fn get_field_by_name_object_tail( // it a retargeted array reported the WRONG object here while // `Object.getPrototypeOf(arr)` reported the right one. if key_bytes == b"__proto__" { - // `__proto__` itself lives on `Object.prototype`, so an - // array whose chain no longer reaches it (an explicit null - // prototype) has no such property at all. - if crate::object::prototype_chain::object_static_prototype(obj as usize) - == Some(crate::value::TAG_NULL) - { - return JSValue::undefined(); - } - let receiver = crate::value::js_nanbox_pointer(obj as i64); - let proto = crate::object::object_ops::js_object_get_prototype_of( - f64::from_bits(receiver.to_bits()), - ); - return JSValue::from_bits(proto.to_bits()); + return super::array_retargeted_proto::array_proto_slot(obj); } // date-fns / drizzle / lodash duck-typing path: // `arr.constructor === Array`, `new arr.constructor(...)`, @@ -931,11 +919,8 @@ pub(crate) fn get_field_by_name_object_tail( // implicit chain, so `constructor` must be resolved through // it (a plain `{}` prototype answers `Object`, not `Array`) // rather than short-circuiting to the global `Array`. #9192. - if crate::object::prototype_chain::object_static_prototype(obj as usize) - .is_some() - { - return array_prototype_property_value("constructor", obj as usize) - .unwrap_or_else(JSValue::undefined); + if let Some(v) = super::array_retargeted_proto::array_constructor_slot(obj) { + return v; } let v = js_get_global_this_builtin_value(b"Array".as_ptr(), 5); return JSValue::from_bits(v.to_bits()); diff --git a/crates/perry-runtime/src/object/field_get_set/has_property.rs b/crates/perry-runtime/src/object/field_get_set/has_property.rs index 97d4883177..4c05ef7b69 100644 --- a/crates/perry-runtime/src/object/field_get_set/has_property.rs +++ b/crates/perry-runtime/src/object/field_get_set/has_property.rs @@ -1275,12 +1275,20 @@ pub(crate) unsafe fn prototype_value_has_property( let top16 = proto_bits >> 48; let proto_ptr = if top16 == 0x7FFD { (proto_bits & crate::value::POINTER_MASK) as usize - } else if top16 == 0 && proto_bits > 0x10000 { + } else if top16 == 0 && crate::value::addr_class::is_above_handle_band(proto_bits as usize) { + // The literal floor here was 0x10000, an order of magnitude BELOW + // HANDLE_BAND_MAX (0x100000), so this raw-pointer branch admitted + // handle-band values and handed them to a dereference. proto_bits as usize } else { return false; }; - if proto_ptr == 0 || !super::super::is_valid_obj_ptr(proto_ptr as *const u8) { + // Band predicate before the validity check (#6279): a handle value is + // below HANDLE_BAND_MAX and must not reach a dereference. + if proto_ptr == 0 + || !crate::value::addr_class::is_above_handle_band(proto_ptr as usize) + || !super::super::is_valid_obj_ptr(proto_ptr as *const u8) + { return false; } ordinary_has_property(proto_ptr as *const ObjectHeader, key)