diff --git a/changelog.d/11468-sso-buffer-write.md b/changelog.d/11468-sso-buffer-write.md new file mode 100644 index 0000000000..fcc7cb907a --- /dev/null +++ b/changelog.d/11468-sso-buffer-write.md @@ -0,0 +1 @@ +**fix(runtime): Buffer `write` / `indexOf` accept a short (SSO) string, so parameterised node-postgres queries no longer segfault (#11430).** Since #10762, `String(n)`, `` `${n}` `` and `n.toString()` return small values as SSO strings, whose characters live inline in the NaN-box with no heap header. `buf.write(s)` and the `Write` family masked such a value into a `StringHeader` pointer and dereferenced the inline bytes. node-postgres binds every parameter through `buffer.write(String(value), …)`, so any parameterised query crashed in `js_buffer_write_len`: 0 of 20 runs on main, 20 of 20 with this fix, against a live PostgreSQL 16.15. `indexOf` / `lastIndexOf` / `includes` with an SSO needle returned -1; they now materialize the needle first. The new gap test is `test_gap_11430_buffer_write_short_string`. diff --git a/crates/perry-runtime/src/buffer/cmp.rs b/crates/perry-runtime/src/buffer/cmp.rs index 24b503d4af..0905e21c63 100644 --- a/crates/perry-runtime/src/buffer/cmp.rs +++ b/crates/perry-runtime/src/buffer/cmp.rs @@ -193,6 +193,24 @@ fn buffer_last_index_of_bytes(buf: *const BufferHeader, needle: &[u8], start: i3 } } +/// The needle bytes of an SSO (inline short string) needle under `encoding`, +/// or `None` when `needle` is not an SSO string. +fn sso_needle_bytes(needle: f64, encoding: i32) -> Option> { + if !crate::value::JSValue::from_bits(needle.to_bits()).is_short_string() { + return None; + } + let str_ptr = + crate::value::js_get_string_pointer_unified(needle) as usize as *const StringHeader; + if str_ptr.is_null() { + return None; + } + let owned = unsafe { crate::string::OwnedStringBytes::copy_from_header(str_ptr) }; + Some(super::from::buffer_string_bytes_for_encoding( + owned.as_bytes(), + encoding, + )) +} + fn buffer_search_needle_with_encoding( buf: *const BufferHeader, needle: f64, @@ -203,6 +221,11 @@ fn buffer_search_needle_with_encoding( } let needle_bits = needle.to_bits(); let top16 = needle_bits >> 48; + // #11430: an SSO short string carries its bytes inline (no heap header), + // so it must be materialized before the heap-string branch can read it. + if let Some(bytes) = sso_needle_bytes(needle, encoding) { + return Some(bytes); + } let raw_ptr = if top16 >= 0x7FF8 { (needle_bits & 0x0000_FFFF_FFFF_FFFF) as usize @@ -263,6 +286,9 @@ pub extern "C" fn js_buffer_index_of_enc( } let needle_bits = needle.to_bits(); let top16 = needle_bits >> 48; + if let Some(bytes) = sso_needle_bytes(needle, encoding) { + return buffer_index_of_bytes(buf, &bytes, start); + } // Buffer needle (POINTER_TAG-boxed or raw) let raw_ptr = if top16 >= 0x7FF8 { diff --git a/crates/perry-runtime/src/object/buffer_dispatch.rs b/crates/perry-runtime/src/object/buffer_dispatch.rs index 84c0ffe3d3..d1b5363125 100644 --- a/crates/perry-runtime/src/object/buffer_dispatch.rs +++ b/crates/perry-runtime/src/object/buffer_dispatch.rs @@ -18,6 +18,20 @@ fn is_buffer_dispatch_string(value: f64) -> bool { jsval.is_string() || jsval.is_short_string() } +/// The `StringHeader` for a string argument `is_buffer_dispatch_string` +/// accepted. #11430: that predicate admits SSO short strings, whose NaN-box +/// carries the characters inline — there is no heap header behind the low 48 +/// bits. Masking them into a pointer (what the `write` arms did) dereferenced +/// the inline bytes as an address. `String(7)` returns SSO since #10762, so +/// node-postgres' bind of any short numeric parameter +/// (`writer.addInt32PrefixedString(String(value))` → `buffer.write(...)`) +/// segfaulted. `js_get_string_pointer_unified` materializes an SSO value onto +/// the heap and returns a heap string's header unchanged. +fn buffer_dispatch_string_ptr(value: f64) -> *const crate::string::StringHeader { + crate::value::js_get_string_pointer_unified(value) as usize + as *const crate::string::StringHeader +} + fn buffer_dispatch_i32(value: f64) -> i32 { let jsval = JSValue::from_bits(value.to_bits()); if jsval.is_int32() { @@ -820,13 +834,7 @@ pub unsafe fn dispatch_buffer_method( ); } let enc = fixed_slice_write_encoding(method_name).unwrap_or(0); - let str_bits = args[0].to_bits(); - let str_addr = if (str_bits >> 48) >= 0x7FF8 { - str_bits & 0x0000_FFFF_FFFF_FFFF - } else { - str_bits - }; - let str_ptr = str_addr as *const crate::string::StringHeader; + let str_ptr = buffer_dispatch_string_ptr(args[0]); let offset = if args.len() >= 2 { arg_i32(1) } else { 0 }; let max_len = if args.len() >= 3 { arg_i32(2) @@ -846,13 +854,7 @@ pub unsafe fn dispatch_buffer_method( "ERR_INVALID_ARG_TYPE", ); } - let str_bits = args[0].to_bits(); - let str_addr = if (str_bits >> 48) >= 0x7FF8 { - str_bits & 0x0000_FFFF_FFFF_FFFF - } else { - str_bits - }; - let str_ptr = str_addr as *const crate::string::StringHeader; + let str_ptr = buffer_dispatch_string_ptr(args[0]); let (offset, max_len, enc) = buffer_write_args((*buf_ptr).length as i32, &args[1..]); crate::buffer::js_buffer_write_len(buf_ptr, str_ptr, offset, max_len, enc) as f64 } diff --git a/test-files/test_gap_11430_buffer_write_short_string.ts b/test-files/test_gap_11430_buffer_write_short_string.ts new file mode 100644 index 0000000000..04d164ec4e --- /dev/null +++ b/test-files/test_gap_11430_buffer_write_short_string.ts @@ -0,0 +1,51 @@ +// #11430: Buffer methods must accept a SHORT string value. Since #10762, +// `String(n)`, `` `${n}` `` and `n.toString()` return small numbers as SSO +// (inline short strings): the characters live in the NaN-box itself, with no +// heap header behind them. `buf.write(s)` and the `Write` family +// masked the value into a pointer anyway and read the inline bytes as an +// address — a segfault. node-postgres binds every parameter as +// `writer.addInt32PrefixedString(String(value))` → `buffer.write(...)`, so any +// parameterised pg query with a short value crashed. `indexOf` / +// `lastIndexOf` with an SSO needle returned -1. +const s = String(7); +const t = String(123456); +const neg = (-42).toString(); +const tpl = `${3.5}`; + +const b = Buffer.alloc(12); +console.log("write:", b.write(s), b.write(t, 1), b.write(s, 8, 2), b.write(t, 9, 2, "latin1"), b.toString("hex")); +console.log("write neg/tpl:", Buffer.alloc(6).write(neg, 0, "utf8"), Buffer.alloc(6).write(tpl, 2)); +console.log("utf8Write:", Buffer.alloc(4).utf8Write(s, 1), "latin1Write:", Buffer.alloc(4).latin1Write(t, 0, 2)); +console.log("hexWrite:", Buffer.alloc(4).hexWrite(String(12), 0)); +console.log("indexOf:", Buffer.from("xx7yy7").indexOf(s), Buffer.from("xx7yy7").lastIndexOf(s), Buffer.from("x123456").includes(t)); +console.log("indexOf enc:", Buffer.from("xx7yy").indexOf(s, 0, "latin1")); +console.log("from/byteLength/fill:", Buffer.from(t).toString("hex"), Buffer.byteLength(s), Buffer.alloc(3).fill(s).toString()); + +// node-postgres' Writer.addInt32PrefixedString (pg-protocol 1.x), verbatim shape. +class Writer { + buffer = Buffer.allocUnsafe(16); + offset = 5; + ensure(size: number) { + if (this.buffer.length - this.offset < size) { + const old = this.buffer; + this.buffer = Buffer.allocUnsafe(old.length + (old.length >> 1) + size); + old.copy(this.buffer); + } + } + addInt32PrefixedString(value: string) { + const len = Buffer.byteLength(value); + this.ensure(4 + len); + const buffer = this.buffer; + let offset = this.offset; + buffer[offset++] = (len >>> 24) & 0xff; + buffer[offset++] = (len >>> 16) & 0xff; + buffer[offset++] = (len >>> 8) & 0xff; + buffer[offset++] = len & 0xff; + buffer.write(value, offset, "utf-8"); + this.offset = offset + len; + return this; + } +} +const w = new Writer(); +for (const v of [7, 42, 123456, -1, 0]) w.addInt32PrefixedString(String(v)); +console.log("pg bind:", w.buffer.subarray(5, w.offset).toString("hex"));