diff --git a/crates/perry-runtime/src/array/indexing.rs b/crates/perry-runtime/src/array/indexing.rs index ad9403b95c..c20f7ecd9e 100644 --- a/crates/perry-runtime/src/array/indexing.rs +++ b/crates/perry-runtime/src/array/indexing.rs @@ -393,6 +393,30 @@ fn array_get_property_by_key(arr: *const ArrayHeader, key: *const crate::StringH } #[no_mangle] +/// Reported length of an object's keys/property array, capped at its physical +/// capacity. +/// +/// Object property walks (the wide-key field-get index and `Object.assign`'s +/// source enumeration) size their work by the keys array's length. A dense +/// keys array's logical length can never exceed its capacity, so for a +/// well-formed array this is a no-op. But when a keys array is malformed and +/// `js_array_length` reports a bogus, oversized value (observed: a pointer- +/// sized length ~= the keys pointer's own low bits, far beyond the real key +/// count), an unclamped `for i in 0..len` / `HashMap::with_capacity(len)` turns +/// a single missing-property read or `Object.assign` into a multi-GB / minutes- +/// long spin. Capping to capacity bounds that work to physically-present slots. +/// +/// FOR DENSE KEYS/PROPERTY ARRAYS ONLY — general JS arrays may have +/// `length > capacity` (sparse), where this cap would be incorrect. +pub(crate) unsafe fn keys_array_len_capped_to_capacity(arr: *const ArrayHeader) -> usize { + let raw = js_array_length(arr) as usize; + if arr.is_null() { + raw + } else { + raw.min((*arr).capacity as usize) + } +} + pub extern "C" fn js_array_length(arr: *const ArrayHeader) -> u32 { // #5135: a Proxy typed (statically) as an array (immer drafts) reaches here // with the masked proxy id. Read `length` through the proxy `get` trap @@ -1625,3 +1649,35 @@ fn canonical_index_of_set_key(idx: f64) -> Option { None } } + +#[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" + ); + } +} diff --git a/crates/perry-runtime/src/array/mod.rs b/crates/perry-runtime/src/array/mod.rs index b1e4511def..65240c41c2 100644 --- a/crates/perry-runtime/src/array/mod.rs +++ b/crates/perry-runtime/src/array/mod.rs @@ -75,8 +75,9 @@ pub use self::immutable::{ pub(crate) use self::indexing::{ array_has_own_index, array_iteration_is_exotic, array_proto_iterator_modified, array_prototype_addr, array_prototype_has_index_flag, array_spec_get, array_spec_has_index, - note_array_proto_iterator_write, note_object_prototype_index_write, object_prototype_addr, - object_prototype_addr_matches, object_prototype_has_index_flag, + keys_array_len_capped_to_capacity, note_array_proto_iterator_write, + note_object_prototype_index_write, object_prototype_addr, object_prototype_addr_matches, + object_prototype_has_index_flag, }; pub use self::indexing::{ js_array_get_element, js_array_get_element_f64, js_array_get_f64, js_array_get_f64_unchecked, diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 38a6153340..0cbb44411c 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1245,7 +1245,12 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) } else { let src_keys = (*src).keys_array; if !src_keys.is_null() && (src_keys as usize) >= 0x10000 { - let key_count = crate::array::js_array_length(src_keys) as usize; + // Cap the key count at the keys array's capacity: a malformed keys + // array can report a bogus, pointer-sized length, and an unclamped + // `0..key_count` copy loop turns Object.assign / object spread into a + // minutes-long spin (each `js_array_get` on the phantom tail walks + // the slow sparse path). Same guard as the wide-key field-get walk. + let key_count = crate::array::keys_array_len_capped_to_capacity(src_keys); // Use the public [[Get]] path, not raw field slots, so accessors run // and abrupt completions propagate the way Object.assign requires. for i in 0..key_count { 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 7546e5bdbf..d9a31d81e5 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 @@ -1574,7 +1574,11 @@ pub(crate) fn get_field_by_name_object_tail( }; let keys_id = keys as usize; - let key_count = crate::array::js_array_length(keys) as usize; + // Clamp the keys length to capacity so a bogus/oversized length can't + // drive the wide-key map build or the linear scan below into unbounded + // work (see `keys_array_len_capped_to_capacity`). No-op for well-formed + // arrays. + let key_count = crate::array::keys_array_len_capped_to_capacity(keys); // Thread-local inline cache: fixed-size direct-mapped cache (no allocation, no HashMap) // Each entry stores (keys_ptr, key_hash, field_index). Copied-minor