Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/11468-sso-buffer-write.md
Original file line number Diff line number Diff line change
@@ -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 `<encoding>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`.
26 changes: 26 additions & 0 deletions crates/perry-runtime/src/buffer/cmp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<u8>> {
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,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
30 changes: 16 additions & 14 deletions crates/perry-runtime/src/object/buffer_dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
51 changes: 51 additions & 0 deletions test-files/test_gap_11430_buffer_write_short_string.ts
Original file line number Diff line number Diff line change
@@ -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 `<encoding>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"));
Loading