#11430 (fixed by #11468 and #11486) had one root cause, found in two places. Since #10762, String(n), `${n}` and n.toString() return small values as SSO strings: the characters live inline in the NaN-box, with no heap StringHeader behind them. A native entry that unboxes a string argument with bits & POINTER_MASK (0x0000_FFFF_FFFF_FFFF) and casts the result to *const StringHeader turns those inline characters into an address, then segfaults or reads garbage. Code that first checks tag == 0x7FFF (heap string only) avoids the crash but treats the SSO value as "not a string", so it returns a wrong answer.
Both sites found so far were fixed only locally:
The pattern is systematic and there are more candidates. This is a grep on main at c1d93bb, not an audit; each hit still needs a look to decide whether an SSO value can reach it.
Runtime and stdlib: masked bits cast to *const StringHeader (count per file, tests excluded)
| file |
hits |
perry-runtime/src/proxy/put_value.rs |
7 |
perry-runtime/src/json/replacer.rs |
7 |
perry-runtime/src/json/stringify.rs |
4 |
perry-runtime/src/object/buffer_dispatch.rs |
4 (the hasOwnProperty / propertyIsEnumerable key and key-export format readers; #11468 fixed only the write arms) |
perry-stdlib/src/fetch_blob.rs |
3 |
perry-runtime/src/thread.rs |
3 |
perry-runtime/src/string/concat.rs |
3 |
perry-runtime/src/string/char_ops.rs |
3 |
perry-runtime/src/array/subclass.rs, array/named_props.rs |
3 each |
perry-runtime/src/{map,set}.rs, value/dynamic_object.rs, symbol/constructors.rs, json/{stringify_shape_template,stringify_primitive_object,stringify_flat,raw_json}.rs |
2 each |
Heap-only tag checks (== 0x7FFF) in files that also read a StringHeader, which gives wrong answers rather than crashes: string/concat.rs, object/class_registry/class_meta.rs, array/{from_concat,generic,flat_clone,indexing_keyed}.rs, typed_feedback.rs, date.rs, proxy.rs, value/{dyn_index,dynamic_object}.rs, {map,set}.rs, typedarray/mod.rs, buffer/u8_codec.rs, and in perry-stdlib common/dispatch/sqlite.rs, fetch_blob.rs, webcrypto/util.rs.
Codegen: unbox_to_i64 passed to natives whose parameter is a string (the shape #11486 fixed in the crypto arms). The largest non-crypto users: lower_array_method.rs (28), expr/arrays_finds.rs (19), expr/logical_collections.rs (15), expr/url_main.rs (13), expr/instance_misc1.rs (13), expr/misc_methods.rs (12), lower_call/native/native_tui_layout_branch.rs (11), native_instance_branch.rs (8), lower_call/namespace_call.rs (7), expr/string_regex_proc.rs (7), expr/os_uri_dates.rs (7). Most of these unbox arrays or objects, which is fine. Only the ones whose runtime parameter is a StringHeader are affected, for example url_main.rs and string_regex_proc.rs.
Suggested guard. Add a ratchet in the style of string_payload_access_inventory.py that counts, per crate:
<mask> as *const …StringHeader casts (and (bits & POINTER_MASK) as usize as *const StringHeader) outside value/nanbox.rs.
(… >> 48) == 0x7FFF string-tag checks that are not paired with is_short_string() in the same function.
- In perry-codegen,
unbox_to_i64( whose result is passed to a runtime function declared with a string parameter. This would need a small table of which js_* params are strings; the runtime_decls modules already name them.
New string arguments should go through js_ffi_arg_ptr (scratch copy, for natives that only read during the call) or js_get_string_pointer_unified / OwnedStringBytes (a heap or owned copy). The baseline would lock in today's counts, so each fix lowers it and new code cannot add to it.
A cheap way to find the live ones is a differential sweep: take existing gap tests that pass a string literal to a builtin, replace the literal with String(<short number>) or a template, and compare against Node. #11468 and #11486 each had a program that crashed outright on main this way.
#11430 (fixed by #11468 and #11486) had one root cause, found in two places. Since #10762,
String(n),`${n}`andn.toString()return small values as SSO strings: the characters live inline in the NaN-box, with no heapStringHeaderbehind them. A native entry that unboxes a string argument withbits & POINTER_MASK(0x0000_FFFF_FFFF_FFFF) and casts the result to*const StringHeaderturns those inline characters into an address, then segfaults or reads garbage. Code that first checkstag == 0x7FFF(heap string only) avoids the crash but treats the SSO value as "not a string", so it returns a wrong answer.Both sites found so far were fixed only locally:
Buffer.writeand the<encoding>Writefamily, plusindexOf/lastIndexOf/includesneedles (fix(runtime): Buffer write/indexOf accept an SSO string; parameterised pg queries no longer segfault (#11430) #11468).hash.update, thecreateHmackey,pbkdf2Sync/hkdfSyncinputs, cipherupdate(fix(codegen,runtime): crypto natives accept an SSO string argument; hash.update(String(n)) no longer segfaults (#11430 follow-up) #11486, via the newjs_ffi_arg_ptr).The pattern is systematic and there are more candidates. This is a grep on main at c1d93bb, not an audit; each hit still needs a look to decide whether an SSO value can reach it.
Runtime and stdlib: masked bits cast to
*const StringHeader(count per file, tests excluded)perry-runtime/src/proxy/put_value.rsperry-runtime/src/json/replacer.rsperry-runtime/src/json/stringify.rsperry-runtime/src/object/buffer_dispatch.rshasOwnProperty/propertyIsEnumerablekey and key-exportformatreaders; #11468 fixed only the write arms)perry-stdlib/src/fetch_blob.rsperry-runtime/src/thread.rsperry-runtime/src/string/concat.rsperry-runtime/src/string/char_ops.rsperry-runtime/src/array/subclass.rs,array/named_props.rsperry-runtime/src/{map,set}.rs,value/dynamic_object.rs,symbol/constructors.rs,json/{stringify_shape_template,stringify_primitive_object,stringify_flat,raw_json}.rsHeap-only tag checks (
== 0x7FFF) in files that also read aStringHeader, which gives wrong answers rather than crashes:string/concat.rs,object/class_registry/class_meta.rs,array/{from_concat,generic,flat_clone,indexing_keyed}.rs,typed_feedback.rs,date.rs,proxy.rs,value/{dyn_index,dynamic_object}.rs,{map,set}.rs,typedarray/mod.rs,buffer/u8_codec.rs, and in perry-stdlibcommon/dispatch/sqlite.rs,fetch_blob.rs,webcrypto/util.rs.Codegen:
unbox_to_i64passed to natives whose parameter is a string (the shape #11486 fixed in the crypto arms). The largest non-crypto users:lower_array_method.rs(28),expr/arrays_finds.rs(19),expr/logical_collections.rs(15),expr/url_main.rs(13),expr/instance_misc1.rs(13),expr/misc_methods.rs(12),lower_call/native/native_tui_layout_branch.rs(11),native_instance_branch.rs(8),lower_call/namespace_call.rs(7),expr/string_regex_proc.rs(7),expr/os_uri_dates.rs(7). Most of these unbox arrays or objects, which is fine. Only the ones whose runtime parameter is aStringHeaderare affected, for exampleurl_main.rsandstring_regex_proc.rs.Suggested guard. Add a ratchet in the style of
string_payload_access_inventory.pythat counts, per crate:<mask> as *const …StringHeadercasts (and(bits & POINTER_MASK) as usize as *const StringHeader) outsidevalue/nanbox.rs.(… >> 48) == 0x7FFFstring-tag checks that are not paired withis_short_string()in the same function.unbox_to_i64(whose result is passed to a runtime function declared with a string parameter. This would need a small table of whichjs_*params are strings; theruntime_declsmodules already name them.New string arguments should go through
js_ffi_arg_ptr(scratch copy, for natives that only read during the call) orjs_get_string_pointer_unified/OwnedStringBytes(a heap or owned copy). The baseline would lock in today's counts, so each fix lowers it and new code cannot add to it.A cheap way to find the live ones is a differential sweep: take existing gap tests that pass a string literal to a builtin, replace the literal with
String(<short number>)or a template, and compare against Node. #11468 and #11486 each had a program that crashed outright on main this way.