Repository navigation
other native entry points taking strings may mis-unbox SSO strings (as #11430/#11486 did) #11519
Description
#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:
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).- Every crypto native:
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)
| 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 …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.- 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 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) 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.
Activity
fix(runtime,stdlib,codegen,ext-net): more short-string (SSO) entry po…
fix(stdlib,codegen): StringDecoder and EvalError/URIError short-strin…
perf(runtime,codegen): keep the heap-string path first in url get_str…
perf(runtime): keep url get_string_content's heap path inlinable; SSO…
fix(runtime,stdlib,codegen,ext): short (SSO) strings at native string…
proggeramlug commentedon Sep 28, 2026
Progress: #11627 landed. About 40 native entry points that mis-unboxed short (SSO) strings are fixed: crashes in Date.parse, child_process, JSON.parse with a reviver and URLSearchParams, hangs from short
node:httpevent names, and wrong answers. A new lint gate,scripts/sso_unbox_inventory.py, keeps new entry points from regressing. Keeping this open for any remaining sites the inventory lists.