diff --git a/changelog.d/7379-assign-regexp-source.md b/changelog.d/7379-assign-regexp-source.md new file mode 100644 index 0000000000..778b9fd0e0 --- /dev/null +++ b/changelog.d/7379-assign-regexp-source.md @@ -0,0 +1,23 @@ +**Fixed** `Object.assign({}, /re/)` and `{ ...\/re\/ }` type-confused the RegExp +source as a plain object, reading `ObjectHeader.keys_array` out of a +`RegExpHeader`'s fields and calling `js_array_length` on the resulting garbage +pointer. + +`js_object_assign_one` skips exotic sources (Map/Set/Promise/Date/WeakMap) whose +header layout is not an `ObjectHeader`, and classifies them by GC type. A RegExp +is allocated as `gc_malloc(GC_TYPE_OBJECT)`, so it passes the `== GC_TYPE_OBJECT` +test that excludes the others and falls into the plain-object key walk. The slot +that lands at `ObjectHeader.keys_array`'s offset in a `RegExpHeader` is not a +keys array, so `js_array_length` reads a `GcHeader` at `garbage - 8`. + +Unprotected this silently walks unrelated memory and usually still prints the +right answer; under `#7341`'s from-space quarantine the address is a retired +protected page and the process takes SIGBUS. This is 1 of the 8 catches that +remained open after #7373-#7376, and the only one whose root cause was a type +confusion rather than a rooting-order defect. + +Per CopyDataProperties a RegExp exposes no own enumerable string keys through +this path, so skipping it matches Node: `Object.assign({}, /x/g)` is `{}`. +Verified with `test_gap_object_assign_collection`, which is now byte-identical +to Node **under quarantine**; 40 regex/assign/spread/object gap tests and 43 +regex unit tests unchanged. diff --git a/crates/perry-runtime/src/object/alloc.rs b/crates/perry-runtime/src/object/alloc.rs index 8a5cca6483..7e34f35522 100644 --- a/crates/perry-runtime/src/object/alloc.rs +++ b/crates/perry-runtime/src/object/alloc.rs @@ -1468,6 +1468,29 @@ pub unsafe extern "C" fn js_object_assign_one(target_f64: f64, source_f64: f64) }; let source_is_array = source_obj_type == crate::gc::GC_TYPE_ARRAY; + // #7341: a RegExp source must be skipped here, and the exotic guard above + // cannot do it. That guard classifies by GC type, and a RegExp is literally + // `gc_malloc(GC_TYPE_OBJECT)` (see `regex.rs`) — so unlike Map/Set/Date it + // passes `== GC_TYPE_OBJECT` and falls into the plain-object arm, where + // `(*src).keys_array` reads a `RegExpHeader` at `ObjectHeader`'s field + // offset. That is a type confusion: the slot it lands on is not a keys + // array, and `js_array_length` then reads a GcHeader at `garbage - 8`. + // Under from-space quarantine that address is a retired protected page and + // the process dies; unprotected it silently walks unrelated memory. + // + // Per CopyDataProperties a RegExp exposes no own enumerable string keys + // through this path (`source`/`flags`/`lastIndex` are prototype accessors + // or non-enumerable), so skipping contributes nothing and matches Node: + // `Object.assign({}, /x/g)` is `{}`. Any own expandos a user attached live + // in the exotic-expando side table, which this raw walk never read anyway. + // + // `is_regex_pointer` is the bounds-checked magic probe, safe on arbitrary + // payloads. Repro: `Object.assign({}, /x/g)` under + // PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_HEAP_LIMIT=8. + if crate::regex::is_regex_pointer(src_raw as *const u8) { + return target_f64; + } + // #7200: EVERYTHING BELOW RUNS WITH USER CODE IN THE WINDOW. // // Both copy loops reach a `[[Get]]` that short-circuits into