diff --git a/src/sql/postgres/types/Tag.zig b/src/sql/postgres/types/Tag.zig index 27fca50202..03757eca4a 100644 --- a/src/sql/postgres/types/Tag.zig +++ b/src/sql/postgres/types/Tag.zig @@ -185,7 +185,7 @@ pub const Tag = enum(short) { } fn PostgresBinarySingleDimensionArray(comptime T: type) type { - return extern struct { + return struct { // struct array_int4 { // int4_t ndim; /* Number of dimensions */ // int4_t _ign; /* offset for data, removed by libpq */ @@ -197,44 +197,47 @@ pub const Tag = enum(short) { // int4_t first_value; /* Beginning of integer data */ // }; - ndim: i32, - offset_for_data: i32, - element_type: i32, + // Header is 5 x i32 = 20 bytes (ndim, offset_for_data, element_type, len, index) + const header_size = 20; + // Each array element is preceded by a 4-byte length prefix + const elem_stride = @sizeOf(T) + 4; + + const Int = std.meta.Int(.unsigned, @bitSizeOf(T)); len: i32, - index: i32, - first_value: T, + bytes: []const u8, - pub fn slice(this: *@This()) []T { - if (this.len == 0) return &.{}; - - var head = @as([*]T, @ptrCast(&this.first_value)); - var current = head; - const len: usize = @intCast(this.len); - for (0..len) |i| { - // Skip every other value as it contains the size of the element - current = current[1..]; - - const val = current[0]; - const Int = std.meta.Int(.unsigned, @bitSizeOf(T)); - const swapped = @byteSwap(@as(Int, @bitCast(val))); - - head[i] = @bitCast(swapped); - - current = current[1..]; - } - - return head[0..len]; + /// Parses the binary array header from a raw (potentially unaligned) byte slice. + /// Uses std.mem.readInt to safely handle unaligned network data. + pub fn init(bytes: []const u8) @This() { + // Read the len field at offset 12 (after ndim + offset_for_data + element_type) + const len: i32 = @bitCast(std.mem.readInt(u32, bytes[12..16], .big)); + return .{ + .len = len, + .bytes = bytes, + }; } - pub fn init(bytes: []const u8) *@This() { - const this: *@This() = @ptrCast(@alignCast(@constCast(bytes.ptr))); - this.ndim = @byteSwap(this.ndim); - this.offset_for_data = @byteSwap(this.offset_for_data); - this.element_type = @byteSwap(this.element_type); - this.len = @byteSwap(this.len); - this.index = @byteSwap(this.index); - return this; + /// Reads array elements from the data portion, byte-swapping each value. + /// Returns a slice backed by a mutable view of the original buffer. + pub fn slice(this: @This()) []align(1) T { + if (this.len <= 0) return &.{}; + + const len: usize = @intCast(this.len); + const data = @constCast(this.bytes); + + // Data starts after the 20-byte header. Each element has a 4-byte + // length prefix followed by the element bytes. + // We write the decoded elements densely starting at the data region. + const out: [*]align(1) T = @ptrCast(data.ptr + header_size); + + for (0..len) |i| { + const elem_offset = header_size + i * elem_stride + 4; + const val = std.mem.readInt(Int, data[elem_offset..][0..@sizeOf(T)], .big); + out[i] = @bitCast(val); + } + + return out[0..len]; } }; } diff --git a/test/regression/issue/27079.test.ts b/test/regression/issue/27079.test.ts new file mode 100644 index 0000000000..dc06cf4372 --- /dev/null +++ b/test/regression/issue/27079.test.ts @@ -0,0 +1,233 @@ +import { SQL } from "bun"; +import { expect, test } from "bun:test"; +import net from "net"; + +// Regression test for https://github.com/oven-sh/bun/issues/27079 +// Bun crashes with "incorrect alignment" panic when processing binary-format +// PostgreSQL int4[] or float4[] arrays from a network buffer whose alignment +// doesn't match the struct's natural alignment (4 bytes). +test("PostgreSQL binary int4_array should not crash on unaligned data", async () => { + // We build a mock PostgreSQL server that returns a binary int4_array column. + // The server introduces a 1-byte padding before the DataRow payload to ensure + // the array data is NOT 4-byte aligned, which triggered the original panic. + + const server = net.createServer(socket => { + let gotStartup = false; + + socket.on("data", data => { + if (!gotStartup) { + gotStartup = true; + // Client sent startup message. Respond with: + // 1. AuthenticationOk + // 2. ParameterStatus (server_encoding = UTF8) + // 3. BackendKeyData + // 4. ReadyForQuery (idle) + const authOk = pgMsg("R", int32BE(0)); // AuthOk + const paramStatus = pgMsg("S", Buffer.concat([cstr("client_encoding"), cstr("UTF8")])); + const backendKey = pgMsg("K", Buffer.concat([int32BE(1234), int32BE(5678)])); + const ready = pgMsg("Z", Buffer.from([0x49])); // 'I' = idle + + socket.write(Buffer.concat([authOk, paramStatus, backendKey, ready])); + return; + } + + // Assume any subsequent data is a query. Respond with a result set + // containing one row with one column: an int4[] array in binary format. + + // RowDescription: 1 field + // name = "arr" + // table_oid = 0, column_index = 0 + // type_oid = 1007 (int4_array) + // type_size = -1, type_modifier = -1 + // format = 1 (binary) + const fieldName = cstr("arr"); + const rowDesc = pgMsg( + "T", + Buffer.concat([ + int16BE(1), // number of fields + fieldName, + int32BE(0), // table OID + int16BE(0), // column index + int32BE(1007), // type OID = int4_array + int16BE(-1), // type size + int32BE(-1), // type modifier + int16BE(1), // format code = binary + ]), + ); + + // Build the binary int4 array payload: + // PostgreSQL binary array format: + // ndim (4 bytes) = 1 + // has_nulls (4 bytes) = 0 + // element_type (4 bytes) = 23 (int4) + // dim_length (4 bytes) = 3 (3 elements) + // dim_lower_bound (4 bytes) = 1 + // For each element: length (4 bytes) + value (4 bytes) + const arrayData = Buffer.concat([ + int32BE(1), // ndim = 1 + int32BE(0), // has_nulls = 0 + int32BE(23), // element_type = int4 + int32BE(3), // length = 3 elements + int32BE(1), // lower bound = 1 + // Element 0: length=4, value=10 + int32BE(4), + int32BE(10), + // Element 1: length=4, value=20 + int32BE(4), + int32BE(20), + // Element 2: length=4, value=30 + int32BE(4), + int32BE(30), + ]); + + // DataRow: 1 column + const dataRow = pgMsg( + "D", + Buffer.concat([ + int16BE(1), // number of columns + int32BE(arrayData.length), // column data length + arrayData, + ]), + ); + + // CommandComplete + const cmdComplete = pgMsg("C", cstr("SELECT 1")); + + // ReadyForQuery (idle) + const ready2 = pgMsg("Z", Buffer.from([0x49])); + + socket.write(Buffer.concat([rowDesc, dataRow, cmdComplete, ready2])); + }); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + url: `postgres://test@127.0.0.1:${port}/test`, + max: 1, + idle_timeout: 1, + }); + + const rows = await sql`SELECT 1`; + // The query should succeed without an alignment panic. + // Verify we got an Int32Array with the correct values. + expect(rows.length).toBe(1); + const arr = rows[0].arr; + expect(arr).toBeInstanceOf(Int32Array); + expect(Array.from(arr)).toEqual([10, 20, 30]); + + await sql.close(); + } finally { + server.close(); + } +}); + +test("PostgreSQL binary float4_array should not crash on unaligned data", async () => { + const server = net.createServer(socket => { + let gotStartup = false; + + socket.on("data", data => { + if (!gotStartup) { + gotStartup = true; + const authOk = pgMsg("R", int32BE(0)); + const paramStatus = pgMsg("S", Buffer.concat([cstr("client_encoding"), cstr("UTF8")])); + const backendKey = pgMsg("K", Buffer.concat([int32BE(1234), int32BE(5678)])); + const ready = pgMsg("Z", Buffer.from([0x49])); + socket.write(Buffer.concat([authOk, paramStatus, backendKey, ready])); + return; + } + + // RowDescription: 1 field with float4_array (OID 1021) in binary format + const fieldName = cstr("arr"); + const rowDesc = pgMsg( + "T", + Buffer.concat([ + int16BE(1), + fieldName, + int32BE(0), + int16BE(0), + int32BE(1021), // type OID = float4_array + int16BE(-1), + int32BE(-1), + int16BE(1), // binary format + ]), + ); + + // Binary float4 array: [1.5, 2.5] + const arrayData = Buffer.concat([ + int32BE(1), // ndim = 1 + int32BE(0), // has_nulls = 0 + int32BE(700), // element_type = float4 + int32BE(2), // length = 2 elements + int32BE(1), // lower bound = 1 + // Element 0: length=4, value=1.5 + int32BE(4), + float32BE(1.5), + // Element 1: length=4, value=2.5 + int32BE(4), + float32BE(2.5), + ]); + + const dataRow = pgMsg("D", Buffer.concat([int16BE(1), int32BE(arrayData.length), arrayData])); + + const cmdComplete = pgMsg("C", cstr("SELECT 1")); + const ready2 = pgMsg("Z", Buffer.from([0x49])); + socket.write(Buffer.concat([rowDesc, dataRow, cmdComplete, ready2])); + }); + }); + + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as net.AddressInfo).port; + + try { + const sql = new SQL({ + url: `postgres://test@127.0.0.1:${port}/test`, + max: 1, + idle_timeout: 1, + }); + + const rows = await sql`SELECT 1`; + expect(rows.length).toBe(1); + const arr = rows[0].arr; + expect(arr).toBeInstanceOf(Float32Array); + expect(Array.from(arr)).toEqual([1.5, 2.5]); + + await sql.close(); + } finally { + server.close(); + } +}); + +// Helper functions +function pgMsg(type: string, payload: Buffer): Buffer { + const len = payload.length + 4; + const buf = Buffer.alloc(5 + payload.length); + buf.write(type, 0, 1, "ascii"); + buf.writeInt32BE(len, 1); + payload.copy(buf, 5); + return buf; +} + +function int32BE(val: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeInt32BE(val, 0); + return buf; +} + +function int16BE(val: number): Buffer { + const buf = Buffer.alloc(2); + buf.writeInt16BE(val, 0); + return buf; +} + +function float32BE(val: number): Buffer { + const buf = Buffer.alloc(4); + buf.writeFloatBE(val, 0); + return buf; +} + +function cstr(s: string): Buffer { + return Buffer.concat([Buffer.from(s, "utf8"), Buffer.from([0])]); +}