diff --git a/test/js/sql/postgres-binary-array-bounds.test.ts b/test/js/sql/postgres-binary-array-bounds.test.ts index d4c3ea11129b..f6294788168c 100644 --- a/test/js/sql/postgres-binary-array-bounds.test.ts +++ b/test/js/sql/postgres-binary-array-bounds.test.ts @@ -1,57 +1,35 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // A malicious or buggy Postgres server can send a binary-format int4[]/float4[] // DataRow whose header `len` field exceeds the actual column byte length. // The binary array parser must validate `len` against the column's byte length // before iterating; otherwise slice() reads and writes past the read buffer. import { SQL } from "bun"; import { expect, test } from "bun:test"; -import net from "net"; - -function pkt(type: string, body: Buffer): Buffer { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} - -function int16(n: number): Buffer { - const b = Buffer.alloc(2); - b.writeInt16BE(n, 0); - return b; -} - -function int32(n: number): Buffer { +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgReadyForQuery, + pgRowDescription, +} from "./wire-frames"; + +// Big-endian Int32 encoder for assembling the hostile *column payload* (binary +// array bytes inside a DataRow) — these are not wire frames; the frames +// themselves come from ./wire-frames. +const i32 = (n: number): Buffer => { const b = Buffer.alloc(4); b.writeInt32BE(n, 0); return b; -} - -function cstr(s: string): Buffer { - return Buffer.concat([Buffer.from(s), Buffer.from([0])]); -} - -function rowDescription(cols: { name: string; oid: number; format: number }[]): Buffer { - const fields = Buffer.concat( - cols.map(c => - Buffer.concat([ - cstr(c.name), - int32(0), // table oid - int16(0), // column attr number - int32(c.oid), // type oid - int16(-1), // type size - int32(-1), // type modifier - int16(c.format), // format: 0=text, 1=binary - ]), - ), - ); - return pkt("T", Buffer.concat([int16(cols.length), fields])); -} - -function dataRowRaw(cols: Buffer[]): Buffer { - const body = Buffer.concat(cols.map(c => Buffer.concat([int32(c.length), c]))); - return pkt("D", Buffer.concat([int16(cols.length), body])); -} +}; -// Binary int4[] header: ndim, flags, elemtype, [len, lbound] per dim, then elements. +// Binary int4[]/float4[] column payload header — PostgreSQL array_send(): +// Int32 ndim, Int32 flags, Int32 elemtype, then per dim: Int32 len, Int32 lbound. function binaryArrayHeader(opts: { ndim: number; flags: number; @@ -59,26 +37,16 @@ function binaryArrayHeader(opts: { len: number; lbound: number; }): Buffer { - return Buffer.concat([ - int32(opts.ndim), - int32(opts.flags), - int32(opts.elemtype), - int32(opts.len), - int32(opts.lbound), - ]); + return Buffer.concat([i32(opts.ndim), i32(opts.flags), i32(opts.elemtype), i32(opts.len), i32(opts.lbound)]); } -const authenticationOk = pkt("R", int32(0)); -const readyForQuery = pkt("Z", Buffer.from("I")); -const commandComplete = (tag: string) => pkt("C", cstr(tag)); - async function runMockQuery(columnBytes: Buffer, typeOid: number): Promise { - const server = net.createServer(socket => { + const { port, server } = await listeningServer(socket => { let startup = true; socket.on("data", data => { if (startup) { startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); return; } if (data[0] !== 0x51 /* 'Q' */) return; @@ -88,19 +56,16 @@ async function runMockQuery(columnBytes: Buffer, typeOid: number): Promise {}); }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as net.AddressInfo).port; - const sql = new SQL({ url: `postgres://u@127.0.0.1:${port}/db`, max: 1, @@ -134,8 +99,8 @@ const malformed: { name: string; oid: number; col: Buffer }[] = [ oid: INT4_ARRAY, col: Buffer.concat([ binaryArrayHeader({ ndim: 1, flags: 0, elemtype: INT4, len: 65536, lbound: 1 }), - int32(4), - int32(42), + i32(4), + i32(42), ]), }, { @@ -147,7 +112,7 @@ const malformed: { name: string; oid: number; col: Buffer }[] = [ // Only 16 bytes: ndim, flags, elemtype, len — missing lbound. name: "int4[] with ndim=1 but truncated header", oid: INT4_ARRAY, - col: Buffer.concat([int32(1), int32(0), int32(INT4), int32(1)]), + col: Buffer.concat([i32(1), i32(0), i32(INT4), i32(1)]), }, { name: "int4[] with len = INT32_MAX", @@ -175,12 +140,12 @@ test.each(malformed)("binary $name is rejected", async ({ oid, col }) => { test("well-formed binary int4[] still parses", async () => { const col = Buffer.concat([ binaryArrayHeader({ ndim: 1, flags: 0, elemtype: INT4, len: 3, lbound: 1 }), - int32(4), - int32(1), - int32(4), - int32(2), - int32(4), - int32(3), + i32(4), + i32(1), + i32(4), + i32(2), + i32(4), + i32(3), ]); const result: any = await runMockQuery(col, INT4_ARRAY); expect(result[0].arr).toEqual(new Int32Array([1, 2, 3])); diff --git a/test/js/sql/postgres-binary-numeric.test.ts b/test/js/sql/postgres-binary-numeric.test.ts index eaac32484118..adb0c08e7001 100644 --- a/test/js/sql/postgres-binary-numeric.test.ts +++ b/test/js/sql/postgres-binary-numeric.test.ts @@ -6,116 +6,44 @@ // walks the leading-zero region 4x too fast and, for weight <= -3, drops // leading "0000" groups — returning e.g. "0.000010000" for 1e-9. // -// Uses a minimal mock Postgres server so the test runs without Docker. The -// server replies to the simple 'Q' protocol but marks the result column as -// binary (format=1) so Bun's binary NUMERIC decoder is exercised. +// Runs against a real Postgres server. The default tagged-template path uses +// the extended protocol and requests binary result format for NUMERIC (OID +// 1700, see is_binary_format_supported in src/sql/postgres/types/Tag.rs), so +// Bun's binary NUMERIC decoder is exercised. import { SQL } from "bun"; import { expect, test } from "bun:test"; -import net from "net"; +import { describeWithContainer } from "harness"; -function pkt(type: string, body: Buffer): Buffer { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} -function i16(n: number): Buffer { - const b = Buffer.alloc(2); - b.writeInt16BE(n, 0); - return b; -} -function u16(n: number): Buffer { - const b = Buffer.alloc(2); - b.writeUInt16BE(n, 0); - return b; -} -function i32(n: number): Buffer { - const b = Buffer.alloc(4); - b.writeInt32BE(n, 0); - return b; -} -function cstr(s: string): Buffer { - return Buffer.concat([Buffer.from(s), Buffer.from([0])]); -} - -const NUMERIC_OID = 1700; - -function rowDescription(name: string): Buffer { - return pkt( - "T", - Buffer.concat([ - i16(1), // 1 column - cstr(name), - i32(0), // table oid - i16(0), // column attr number - i32(NUMERIC_OID), - i16(-1), // type size - i32(-1), // type modifier - i16(1), // format: 1 = binary - ]), - ); -} - -function dataRow(col: Buffer): Buffer { - return pkt("D", Buffer.concat([i16(1), i32(col.length), col])); -} - -// Encode a Postgres binary NUMERIC field. -function numeric(ndigits: number, weight: number, sign: number, dscale: number, digits: number[]): Buffer { - return Buffer.concat([i16(ndigits), i16(weight), u16(sign), i16(dscale), ...digits.map(u16)]); -} - -const authenticationOk = pkt("R", i32(0)); -const readyForQuery = pkt("Z", Buffer.from("I")); -const commandComplete = pkt("C", cstr("SELECT 1")); - -async function decodeNumeric(bytes: Buffer): Promise { - const server = net.createServer(socket => { - let startup = true; - socket.on("data", data => { - if (startup) { - startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); - return; - } - if (data[0] !== 0x51 /* 'Q' */) return; - socket.write(Buffer.concat([rowDescription("n"), dataRow(bytes), commandComplete, readyForQuery])); - }); - socket.on("error", () => {}); - }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as net.AddressInfo).port; - const sql = new SQL({ url: `postgres://u@127.0.0.1:${port}/db`, max: 1, idleTimeout: 5, connectionTimeout: 5 }); - try { - const [row]: any = await sql`select n`.simple(); - return row.n; - } finally { - await sql.close().catch(() => {}); - await new Promise(r => server.close(() => r())); - } -} - -// Wire-format encodings for each test value. weight/dscale/digits match what a -// real Postgres server sends (verified against psql). -const cases: { literal: string; bytes: Buffer }[] = [ +// Each literal is round-tripped through `''::numeric`. The server +// parses the text, encodes it as binary NUMERIC on the wire, and Bun's decoder +// must reproduce the exact same string. +const cases: string[] = [ // --- weight <= -3: previously corrupted -------------------------------- - { literal: "0.000000001", bytes: numeric(1, -3, 0x0000, 9, [1000]) }, - { literal: "0.000000000001", bytes: numeric(1, -3, 0x0000, 12, [1]) }, - { literal: "0.00000000123", bytes: numeric(1, -3, 0x0000, 11, [1230]) }, - { literal: "0.0000000000001", bytes: numeric(1, -4, 0x0000, 13, [1000]) }, - { literal: "-0.000000001", bytes: numeric(1, -3, 0x4000, 9, [1000]) }, - { literal: "0.00000000000000012345", bytes: numeric(2, -4, 0x0000, 20, [1, 2345]) }, + "0.000000001", + "0.000000000001", + "0.00000000123", + "0.0000000000001", + "-0.000000001", + "0.00000000000000012345", // --- boundary & previously-correct paths: must remain unchanged -------- - { literal: "0.00000001", bytes: numeric(1, -2, 0x0000, 8, [1]) }, - { literal: "0.0001", bytes: numeric(1, -1, 0x0000, 4, [1]) }, - { literal: "123.456", bytes: numeric(2, 0, 0x0000, 3, [123, 4560]) }, - { literal: "1000000", bytes: numeric(1, 1, 0x0000, 0, [100]) }, - { literal: "0", bytes: numeric(0, 0, 0x0000, 0, []) }, - { literal: "0.123456789012345", bytes: numeric(4, -1, 0x0000, 15, [1234, 5678, 9012, 3450]) }, - { literal: "12345678.000000009", bytes: numeric(5, 1, 0x0000, 9, [1234, 5678, 0, 0, 9000]) }, + "0.00000001", + "0.0001", + "123.456", + "1000000", + "0", + "0.123456789012345", + "12345678.000000009", ]; -test.each(cases)("binary NUMERIC decodes $literal", async ({ literal, bytes }) => { - expect(await decodeNumeric(bytes)).toBe(literal); +describeWithContainer("postgres", { image: "postgres_plain" }, container => { + test.each(cases)("binary NUMERIC decodes %s", async literal => { + await container.ready; + await using sql = new SQL({ + url: `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`, + max: 1, + }); + const [row] = await sql`SELECT ${literal}::numeric AS n`; + expect(row.n).toBe(literal); + }); }); diff --git a/test/js/sql/postgres-multi-statement-fields.test.ts b/test/js/sql/postgres-multi-statement-fields.test.ts index f73019b0e7b4..2da899b19fdc 100644 --- a/test/js/sql/postgres-multi-statement-fields.test.ts +++ b/test/js/sql/postgres-multi-statement-fields.test.ts @@ -7,147 +7,84 @@ // not leaked. import { SQL } from "bun"; import { expect, test } from "bun:test"; -import net from "net"; +import { describeWithContainer } from "harness"; +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgCString, + pgDataRow, + pgInt32, + pgRaw, + pgReadyForQuery, + pgRowDescription, +} from "./wire-frames"; -function pkt(type: string, body: Buffer): Buffer { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} - -function int16(n: number): Buffer { - const b = Buffer.alloc(2); - b.writeInt16BE(n, 0); - return b; -} - -function int32(n: number): Buffer { - const b = Buffer.alloc(4); - b.writeInt32BE(n, 0); - return b; -} - -function cstr(s: string): Buffer { - return Buffer.concat([Buffer.from(s), Buffer.from([0])]); -} - -function rowDescription(names: string[]): Buffer { - const fields = Buffer.concat( - names.map(name => - Buffer.concat([ - cstr(name), // column name - int32(0), // table oid - int16(0), // column attr number - int32(25), // type oid: text - int16(-1), // type size - int32(-1), // type modifier - int16(0), // format: text - ]), - ), - ); - return pkt("T", Buffer.concat([int16(names.length), fields])); -} - -function dataRow(values: string[]): Buffer { - const cols = Buffer.concat( - values.map(v => { - const bytes = Buffer.from(v); - return Buffer.concat([int32(bytes.length), bytes]); - }), - ); - return pkt("D", Buffer.concat([int16(values.length), cols])); -} +describeWithContainer("postgres", { image: "postgres_plain" }, container => { + const url = () => `postgres://bun_sql_test@${container.host}:${container.port}/bun_sql_test`; -const authenticationOk = pkt("R", int32(0)); -const readyForQuery = pkt("Z", Buffer.from("I")); -const commandComplete = (tag: string) => pkt("C", cstr(tag)); + test("simple query with multiple statements uses each RowDescription's column names", async () => { + await container.ready; + await using sql = new SQL({ url: url(), max: 1, idleTimeout: 5, connectionTimeout: 5 }); -test("simple query with multiple statements uses each RowDescription's column names", async () => { - const server = net.createServer(socket => { - let startup = true; - socket.on("data", data => { - if (startup) { - startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); - return; - } - if (data[0] !== 0x51 /* 'Q' */) return; - // Respond to the simple query with two result sets that have different - // column names and shapes, then a third with yet another shape. - socket.write( - Buffer.concat([ - rowDescription(["x"]), - dataRow(["1"]), - commandComplete("SELECT 1"), - rowDescription(["y"]), - dataRow(["2"]), - commandComplete("SELECT 1"), - rowDescription(["a", "b", "c"]), - dataRow(["3", "4", "5"]), - commandComplete("SELECT 1"), - readyForQuery, - ]), - ); - }); + // ::text mirrors the original wire fixture (type oid 25) so the decoded + // values stay strings and the assertion below is byte-identical to the + // pre-conversion mock-server test. + const result = + await sql`select '1'::text as x; select '2'::text as y; select '3'::text as a, '4'::text as b, '5'::text as c`.simple(); + expect(result).toEqual([[{ x: "1" }], [{ y: "2" }], [{ a: "3", b: "4", c: "5" }]]); }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as net.AddressInfo).port; + // NoticeResponse ('N') can arrive between result sets — RAISE NOTICE inside a + // DO block makes a real server emit one mid-stream. The protocol reader must + // consume exactly the message body so the following RowDescription stays + // correctly framed and the second result set decodes with its own column name. + test("NoticeResponse between result sets does not corrupt message framing", async () => { + await container.ready; + await using sql = new SQL({ url: url(), max: 1, idleTimeout: 5, connectionTimeout: 5 }); - const sql = new SQL({ - url: `postgres://u@127.0.0.1:${port}/db`, - max: 1, - idleTimeout: 5, - connectionTimeout: 5, + const result = + await sql`select '1'::text as x; do $$ begin raise notice 'relation exists, skipping'; end $$; select '2'::text as y`.simple(); + // The DO block contributes its own (rowless) CommandComplete, hence the + // empty middle entry. The load-bearing checks are unchanged: {x:"1"} and + // {y:"2"} — a mis-framed NoticeResponse reader would corrupt or drop the + // third result set, and a stale field cache would surface {x:"2"}. + expect(result).toEqual([[{ x: "1" }], [], [{ y: "2" }]]); }); - - try { - const result = await sql`select 1 as x; select 2 as y; select 3 as a, 4 as b, 5 as c`.simple(); - expect(result).toEqual([[{ x: "1" }], [{ y: "2" }], [{ a: "3", b: "4", c: "5" }]]); - } finally { - await sql.close(); - server.close(); - } }); -// NotificationResponse ('A', sent by NOTIFY), NoticeResponse ('N', sent by -// RAISE NOTICE and server chatter like "relation exists, skipping") and unknown -// async messages can arrive between result sets. The protocol reader must -// consume exactly the message body so the following messages stay correctly -// framed. +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// +// NotificationResponse ('A', sent by NOTIFY), a degenerate empty NoticeResponse, +// and unknown async messages can arrive between result sets. A real Postgres +// will not emit a NotificationResponse mid-result-set (it defers to the +// ReadyForQuery boundary), nor a length-4 NoticeResponse with no field list, +// nor a NegotiateProtocolVersion mid-stream — so these stay mocked. The +// protocol reader must consume exactly the message body so the following +// messages stay correctly framed. for (const [name, asyncMessage] of [ - ["NotificationResponse", pkt("A", Buffer.concat([int32(4321), cstr("some_channel"), cstr("some payload")]))], - // NoticeResponse shares ErrorResponse's field-list format: repeated - // (field-type byte + cstring), closed by a single zero byte. It must be - // decoded and discarded without failing the query. + // PostgreSQL FE/BE protocol §55.7 NotificationResponse: Byte1('A') Int32(len) Int32(pid) String(channel) String(payload) [ - "NoticeResponse", - pkt( - "N", - Buffer.concat([ - Buffer.from("S"), - cstr("NOTICE"), - Buffer.from("C"), - cstr("00000"), - Buffer.from("M"), - cstr("relation exists, skipping"), - Buffer.from([0]), - ]), - ), + "NotificationResponse", + pgRaw("A", Buffer.concat([pgInt32(4321), pgCString("some_channel"), pgCString("some payload")])), ], // Degenerate notice: declared length 4, no field list at all. - ["empty NoticeResponse", pkt("N", Buffer.alloc(0))], + ["empty NoticeResponse", pgRaw("N", Buffer.alloc(0))], // 'v' = NegotiateProtocolVersion, which the client does not handle explicitly - ["unknown message type", pkt("v", Buffer.concat([int32(0), int32(0)]))], + ["unknown message type", pgRaw("v", Buffer.concat([pgInt32(0), pgInt32(0)]))], ] as const) { test(`${name} between result sets does not corrupt message framing`, async () => { - const server = net.createServer(socket => { + const { port, server } = await listeningServer(socket => { + socket.on("error", () => {}); let startup = true; socket.on("data", data => { if (startup) { startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); return; } if (data[0] !== 0x51 /* 'Q' */) return; @@ -155,22 +92,19 @@ for (const [name, asyncMessage] of [ // connection error instead of waiting for more data forever. socket.end( Buffer.concat([ - rowDescription(["x"]), - dataRow(["1"]), - commandComplete("SELECT 1"), + pgRowDescription([{ name: "x", typeOid: 25 }]), + pgDataRow([Buffer.from("1")]), + pgCommandComplete("SELECT 1"), asyncMessage, - rowDescription(["y"]), - dataRow(["2"]), - commandComplete("SELECT 1"), - readyForQuery, + pgRowDescription([{ name: "y", typeOid: 25 }]), + pgDataRow([Buffer.from("2")]), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), ]), ); }); }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = (server.address() as net.AddressInfo).port; - const sql = new SQL({ url: `postgres://u@127.0.0.1:${port}/db`, max: 1, @@ -182,8 +116,8 @@ for (const [name, asyncMessage] of [ const result = await sql`select 1 as x; select 2 as y`.simple(); expect(result).toEqual([[{ x: "1" }], [{ y: "2" }]]); } finally { - await sql.close(); - server.close(); + await sql.close().catch(() => {}); + await new Promise(resolve => server.close(() => resolve())); } }); } diff --git a/test/js/sql/postgres-tls-ctx-leak.test.ts b/test/js/sql/postgres-tls-ctx-leak.test.ts index 623ce6d24baf..b9ae187fb547 100644 --- a/test/js/sql/postgres-tls-ctx-leak.test.ts +++ b/test/js/sql/postgres-tls-ctx-leak.test.ts @@ -1,7 +1,13 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. + import { SQL } from "bun"; import { heapStats } from "bun:jsc"; import { expect, test } from "bun:test"; -import net from "net"; +import { listeningServer, pgAuthenticationOk, pgReadyForQuery, pgSSLResponse } from "./wire-frames"; // PostgresSQLConnection.deinit() must free the per-connection SSL SocketContext // (tls_ctx). Previously it freed tls_config but leaked tls_ctx, so every @@ -34,15 +40,13 @@ async function countPostgresConnectionsAfterGC(maxWait = 3000): Promise } test("Postgres connections with sslmode != disable are finalized after close", async () => { - // 'N' (SSL refused) + AuthenticationOk ('R', len=8, type=0) + ReadyForQuery ('Z', len=5, 'I') - const handshake = Buffer.from([0x4e, 0x52, 0, 0, 0, 8, 0, 0, 0, 0, 0x5a, 0, 0, 0, 5, 0x49]); + // 'N' (SSL refused) + AuthenticationOk + ReadyForQuery('I') + const handshake = Buffer.concat([pgSSLResponse("N"), pgAuthenticationOk(), pgReadyForQuery("I")]); - const server = net.createServer(socket => { + const { server, port } = await listeningServer(socket => { socket.once("data", () => socket.write(handshake)); socket.on("error", () => {}); }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const port = (server.address() as net.AddressInfo).port; try { async function once() { @@ -79,12 +83,10 @@ test("Postgres connections with sslmode != disable are finalized after close", a // so the connection fails before ever reaching `.connected`. Previously these // failed connections also stayed alive forever via hasPendingActivity. test("Postgres connections that fail TLS negotiation are finalized", async () => { - const server = net.createServer(socket => { - socket.once("data", () => socket.write("N")); + const { server, port } = await listeningServer(socket => { + socket.once("data", () => socket.write(pgSSLResponse("N"))); socket.on("error", () => {}); }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const port = (server.address() as net.AddressInfo).port; try { async function once() { diff --git a/test/js/sql/sql-close-pending-connection.test.ts b/test/js/sql/sql-close-pending-connection.test.ts index 7f2fd2209fc0..df35ab760a25 100644 --- a/test/js/sql/sql-close-pending-connection.test.ts +++ b/test/js/sql/sql-close-pending-connection.test.ts @@ -1,3 +1,9 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. + // https://github.com/oven-sh/bun/issues/32095 // // A forced pool close (`close({ timeout: "0" })`) must resolve even when a @@ -12,32 +18,16 @@ import { SQL } from "bun"; import { expect, test } from "bun:test"; -import net from "node:net"; +import { neverAnsweringServer } from "./wire-frames"; const drivers = [ ["postgres", "postgres://postgres@", "ERR_POSTGRES_CONNECTION_CLOSED"], ["mysql", "mysql://root@", "ERR_MYSQL_CONNECTION_CLOSED"], ] as const; -async function neverAnsweringServer(): Promise<{ - port: number; - server: net.Server; - sockets: net.Socket[]; - accepted: Promise; -}> { - const first = Promise.withResolvers(); - const sockets: net.Socket[] = []; - const server = net.createServer(socket => { - sockets.push(socket); - first.resolve(); - }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - return { port: (server.address() as net.AddressInfo).port, server, sockets, accepted: first.promise }; -} - for (const [name, scheme, closedCode] of drivers) { test(`${name}: forced close() resolves while a connection is mid-handshake`, async () => { - const { port, server, sockets, accepted } = await neverAnsweringServer(); + const { port, server, accepted } = await neverAnsweringServer(); try { const sql = new SQL({ url: `${scheme}127.0.0.1:${port}/db`, max: 1, connectionTimeout: 0 }); const queryError = sql`SELECT 1`.catch(e => e); @@ -47,13 +37,12 @@ for (const [name, scheme, closedCode] of drivers) { await sql.close({ timeout: "0" }); expect((await queryError).code).toBe(closedCode); } finally { - for (const socket of sockets) socket.destroy(); server.close(); } }); test(`${name}: forced close() resolves when called before the native handle is stored`, async () => { - const { port, server, sockets } = await neverAnsweringServer(); + const { port, server } = await neverAnsweringServer(); try { const sql = new SQL({ url: `${scheme}127.0.0.1:${port}/db`, max: 1, connectionTimeout: 0 }); const connectError = sql.connect().catch(e => e); @@ -62,7 +51,6 @@ for (const [name, scheme, closedCode] of drivers) { await sql.close({ timeout: "0" }); expect((await connectError).code).toBe(closedCode); } finally { - for (const socket of sockets) socket.destroy(); server.close(); } }); @@ -75,7 +63,7 @@ for (const [name, scheme, closedCode] of drivers) { // runs synchronously during that fill, so pool methods re-entered from it // used to dereference unassigned slots and throw a raw TypeError. test("pool scans tolerate unassigned connection slots during pool start", async () => { - const { port, server, sockets } = await neverAnsweringServer(); + const { port, server } = await neverAnsweringServer(); let passwordCalls = 0; const errors: unknown[] = []; const sql = new SQL({ @@ -110,7 +98,6 @@ test("pool scans tolerate unassigned connection slots during pool start", async } finally { // force an immediate close even with waiters queued await sql.close({ timeout: "0" }); - for (const socket of sockets) socket.destroy(); server.close(); } }); diff --git a/test/js/sql/sql-connect-error-reporting.test.ts b/test/js/sql/sql-connect-error-reporting.test.ts index 2e1053d147ea..da21d41e43da 100644 --- a/test/js/sql/sql-connect-error-reporting.test.ts +++ b/test/js/sql/sql-connect-error-reporting.test.ts @@ -1,3 +1,9 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // During database-server startup (e.g. a postgres/mysql docker container that // is still initializing), clients hit two socket-level failures that are not // protocol errors: the connection is refused outright, or an intermediary @@ -18,21 +24,8 @@ import { SQL } from "bun"; import { expect, test } from "bun:test"; -import net from "node:net"; - -async function listeningServer(onSocket: (socket: net.Socket) => void): Promise<{ port: number; server: net.Server }> { - const server = net.createServer(onSocket); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - return { port: (server.address() as net.AddressInfo).port, server }; -} - -async function closedPort(): Promise { - const server = net.createServer(); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const port = (server.address() as net.AddressInfo).port; - await new Promise(resolve => server.close(() => resolve())); - return port; -} +import type net from "node:net"; +import { closedPort, listeningServer, pgAuthenticationOk, pgErrorResponse, pgReadyForQuery } from "./wire-frames"; // connectionTimeout (seconds) bounds the connect-retry budget; keep it short // in tests that expect the failure to surface. @@ -49,15 +42,7 @@ async function connectError(url: string): Promise { } function postgresAuthOkAndReady(socket: net.Socket) { - const authOk = Buffer.alloc(9); - authOk.write("R", 0); - authOk.writeInt32BE(8, 1); - authOk.writeInt32BE(0, 5); - const ready = Buffer.alloc(6); - ready.write("Z", 0); - ready.writeInt32BE(5, 1); - ready.write("I", 5); - socket.write(Buffer.concat([authOk, ready])); + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); } test("postgres: connection refused is reported distinctly and fails fast", async () => { @@ -129,27 +114,14 @@ test("postgres: server ErrorResponse during startup is still surfaced (57P03)", const { port, server } = await listeningServer(socket => { connections++; socket.on("data", () => { - const fields: [string, string][] = [ - ["S", "FATAL"], - ["V", "FATAL"], - ["C", "57P03"], - ["M", "the database system is starting up"], - ]; - let len = 4; - for (const [, v] of fields) len += 1 + v.length + 1; - len += 1; - const buf = Buffer.alloc(1 + len); - let o = 0; - buf.write("E", o++); - buf.writeInt32BE(len, o); - o += 4; - for (const [k, v] of fields) { - buf.write(k, o++); - buf.write(v + "\0", o); - o += v.length + 1; - } - buf[o] = 0; - socket.end(buf); + socket.end( + pgErrorResponse({ + S: "FATAL", + V: "FATAL", + C: "57P03", + M: "the database system is starting up", + }), + ); }); }); try { diff --git a/test/js/sql/sql-mysql-auth-short-nonce.test.ts b/test/js/sql/sql-mysql-auth-short-nonce.test.ts index be4a92c7e378..a6d273317e25 100644 --- a/test/js/sql/sql-mysql-auth-short-nonce.test.ts +++ b/test/js/sql/sql-mysql-auth-short-nonce.test.ts @@ -1,85 +1,32 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // Regression: mysql_native_password.scramble() sliced nonce[0..8] and // nonce[8..20] with no length check. A malicious server can send an // AuthSwitchRequest whose plugin_data is shorter than 20 bytes, which flows // straight into scramble() as the nonce — OOB read (panic under safety // checks, silent heap over-read in release). With the fix the client rejects // with ERR_MYSQL_MISSING_AUTH_DATA before touching the buffer. -// -// Uses a minimal mock MySQL server so it can run without Docker. import { SQL } from "bun"; import { expect, test } from "bun:test"; -import { once } from "events"; -import net from "net"; - -function u16le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); -} -function u24le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); -} -function u32le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); -} -function packet(seq: number, payload: Buffer) { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); -} - -// Server capability flags (subset sufficient for the auth-switch path). -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; -const CLIENT_DEPRECATE_EOF = 1 << 24; -const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - -// Advertise caching_sha2_password in the initial handshake so the client -// has to follow the AuthSwitchRequest path to reach -// mysql_native_password.scramble() with the server-controlled plugin_data. -function handshakeV10() { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); // includes trailing NUL as part of 13 bytes - authData2[12] = 0; - const payload = Buffer.concat([ - Buffer.from([10]), // protocol version - Buffer.from("mock-5.7.0\0"), // server version NUL-terminated - u32le(1), // connection id - authData1, // auth-plugin-data-part-1 (8) - Buffer.from([0]), // filler - u16le(SERVER_CAPS & 0xffff), // capability flags lower - Buffer.from([0x2d]), // character set (utf8mb4_general_ci) - u16le(0x0002), // status flags (SERVER_STATUS_AUTOCOMMIT) - u16le((SERVER_CAPS >>> 16) & 0xffff), // capability flags upper - Buffer.from([21]), // length of auth-plugin-data - Buffer.alloc(10, 0), // reserved - authData2, // auth-plugin-data-part-2 (13 bytes) - Buffer.from("caching_sha2_password\0"), - ]); - return packet(0, payload); -} - -// AuthSwitchRequest: 0xfe, plugin_name NUL-terminated, plugin_data (rest of -// packet). Send only 4 bytes of plugin_data — well under the 20 bytes -// scramble() slices. -function authSwitchShortNonce(seq: number) { - return packet( - seq, - Buffer.concat([Buffer.from([0xfe]), Buffer.from("mysql_native_password\0"), Buffer.alloc(4, 0x63)]), - ); -} +import { listeningServer, mysqlAuthSwitchRequest, mysqlHandshakeV10 } from "./wire-frames"; test("MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read", async () => { let sawAuthSwitchResponse = false; - const server = net.createServer(socket => { + // Advertise caching_sha2_password in the initial handshake so the client has + // to follow the AuthSwitchRequest path to reach mysql_native_password.scramble() + // with the server-controlled plugin_data. + const greeting = mysqlHandshakeV10({ authPlugin: "caching_sha2_password" }); + + const { server, port } = await listeningServer(socket => { let buffered = Buffer.alloc(0); let sentAuthSwitch = false; - socket.write(handshakeV10()); + socket.write(greeting); socket.on("data", chunk => { buffered = Buffer.concat([buffered, chunk]); while (buffered.length >= 4) { @@ -88,9 +35,10 @@ test("MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejec const seq = buffered[3]; buffered = buffered.subarray(4 + len); if (!sentAuthSwitch) { - // Reply to HandshakeResponse41 with the short-nonce AuthSwitch. + // Reply to HandshakeResponse41 with the short-nonce AuthSwitch: only 4 + // bytes of plugin_data — well under the 20 bytes scramble() slices. sentAuthSwitch = true; - socket.write(authSwitchShortNonce(seq + 1)); + socket.write(mysqlAuthSwitchRequest(seq + 1, "mysql_native_password", Buffer.alloc(4, 0x63))); } else { // Pre-fix release builds OOB-read garbage into the scramble and // still send an AuthSwitchResponse; reaching here means the @@ -103,10 +51,6 @@ test("MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejec socket.on("error", () => {}); }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - try { // Non-empty password so scramble() proceeds past the empty-password early return. await using sql = new SQL({ url: `mysql://root:pw@127.0.0.1:${port}/db`, max: 1 }); diff --git a/test/js/sql/sql-mysql-cached-error.test.ts b/test/js/sql/sql-mysql-cached-error.test.ts index 82cb63fec515..8377a889e13a 100644 --- a/test/js/sql/sql-mysql-cached-error.test.ts +++ b/test/js/sql/sql-mysql-cached-error.test.ts @@ -3,155 +3,71 @@ // The statement is cached in the connection's statements map with status = .failed, so // re-running the same failing query would read the stale slice after subsequent packets // overwrote the buffer. -// -// Uses a minimal mock MySQL server so it can run without Docker. import { SQL } from "bun"; import { expect, test } from "bun:test"; -import { once } from "events"; -import net from "net"; - -function u16le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); -} -function u24le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); -} -function u32le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); -} -function packet(seq: number, payload: Buffer) { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); -} - -// Server capability flags (subset sufficient for the prepared-statement path). -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; -const CLIENT_DEPRECATE_EOF = 1 << 24; -const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - -function handshakeV10() { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); // includes trailing NUL as part of 13 bytes - authData2[12] = 0; - const payload = Buffer.concat([ - Buffer.from([10]), // protocol version - Buffer.from("mock-5.7.0\0"), // server version NUL-terminated - u32le(1), // connection id - authData1, // auth-plugin-data-part-1 (8) - Buffer.from([0]), // filler - u16le(SERVER_CAPS & 0xffff), // capability flags lower - Buffer.from([0x2d]), // character set (utf8mb4_general_ci) - u16le(0x0002), // status flags (SERVER_STATUS_AUTOCOMMIT) - u16le((SERVER_CAPS >>> 16) & 0xffff), // capability flags upper - Buffer.from([21]), // length of auth-plugin-data - Buffer.alloc(10, 0), // reserved - authData2, // auth-plugin-data-part-2 (13 bytes) - Buffer.from("mysql_native_password\0"), - ]); - return packet(0, payload); -} - -function okPacket(seq: number) { - // header, affected_rows (lenenc 0), last_insert_id (lenenc 0), status flags, warnings - return packet(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); -} - -function errorPacket(seq: number, errno: number, message: string) { - const payload = Buffer.concat([Buffer.from([0xff]), u16le(errno), Buffer.from("#42000"), Buffer.from(message)]); - return packet(seq, payload); -} - -const COM_STMT_PREPARE = 0x16; - -// Long enough to exceed the 15-byte inline storage so the message is heap-backed. -const ORIGINAL_MSG = "ORIGINAL syntax error: this message must survive across later packets ".padEnd(200, "A"); -const OVERWRITE_MSG = "".padEnd(ORIGINAL_MSG.length, "Z"); - -test("MySQL: cached failed prepared statement error_message is not a dangling slice", async () => { - let prepareCount = 0; - - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - - socket.write(handshakeV10()); - - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - - if (!authed) { - // HandshakeResponse41 from client → accept unconditionally. - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - - const cmd = payload[0]; - if (cmd === COM_STMT_PREPARE) { - // First prepare gets the real error; all others get a buffer-overwriting - // error of the same length filled with a different byte. - const msg = prepareCount === 0 ? ORIGINAL_MSG : OVERWRITE_MSG; - prepareCount++; - socket.write(errorPacket(seq + 1, 1064, msg)); - } else { - // COM_QUIT or anything else → close. - socket.end(); - } - } +import { describeWithContainer, isDockerEnabled } from "harness"; + +if (isDockerEnabled()) { + describeWithContainer("mysql", { image: "mysql_plain" }, container => { + test("MySQL: cached failed prepared statement error_message is not a dangling slice", async () => { + await container.ready; + await using sql = new SQL({ + url: `mysql://root@${container.host}:${container.port}/bun_sql_test`, + max: 1, + }); + + // Long bogus identifiers so the server's echoed error_message exceeds the 15-byte + // inline-string threshold and is heap-backed, and so the two messages differ at + // bytes the second packet would overwrite in the read buffer. MySQL truncates the + // "near '...'" clause to ~80 chars, so keep these short enough to appear in full. + const longA = Buffer.alloc(50, "A").toString(); + const longZ = Buffer.alloc(50, "Z").toString(); + + // First failing query → statement cached as .failed with error_message. + const err1 = await sql`wat ${1} ${sql.unsafe(longA)}`.catch((x: any) => x); + expect(err1).toBeInstanceOf(Error); + expect(err1.code).toBe("ERR_MYSQL_SYNTAX_ERROR"); + expect(err1.errno).toBe(1064); + expect(err1.message).toContain(longA); + + // Different failing query → server sends a different ERROR packet that overwrites + // the connection read buffer where err1's message slice used to point. + const errOverwrite = await sql`other ${1} ${sql.unsafe(longZ)}`.catch((x: any) => x); + expect(errOverwrite).toBeInstanceOf(Error); + expect(errOverwrite.message).toContain(longZ); + expect(errOverwrite.message).not.toBe(err1.message); + + // Same as the first failing query → hits the cached .failed statement and calls + // stmt.error_response.toJS(). Before the fix this read the overwritten buffer and + // returned bytes from errOverwrite's packet; after the fix it returns the original. + // Com_stmt_prepare (read via .simple() so the status query itself does not prepare) + // must not increment across this call — proving the third query was served from + // Bun's failed-statement cache, not re-prepared on the server. A fresh prepare + // would return an identical error for identical SQL and silently satisfy every + // assertion below without exercising the cached-slice path. + const [{ Value: preparesBefore }] = await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple(); + // err1 and errOverwrite each reached COM_STMT_PREPARE, so the counter is + // already non-zero here; if it were 0 the "no increment" check below would + // be vacuous because the prepared path was never taken. + expect(Number(preparesBefore)).toBeGreaterThan(0); + const err2 = await sql`wat ${1} ${sql.unsafe(longA)}`.catch((x: any) => x); + const [{ Value: preparesAfter }] = await sql.unsafe("SHOW SESSION STATUS LIKE 'Com_stmt_prepare'").simple(); + expect({ + code: err2.code, + errno: err2.errno, + sqlState: err2.sqlState, + message: err2.message, + preparesAfter: Number(preparesAfter), + }).toEqual({ + code: err1.code, + errno: err1.errno, + sqlState: err1.sqlState, + message: err1.message, + preparesAfter: Number(preparesBefore), + }); + expect(err2.message).toContain(longA); + expect(err2.message).not.toContain(longZ); }); }); - - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - - // First failing query → statement cached as .failed with error_message. - const err1 = await sql`wat ${1}`.catch((x: any) => x); - expect(err1.code).toBe("ERR_MYSQL_SYNTAX_ERROR"); - expect(err1.errno).toBe(1064); - expect(err1.message).toBe(ORIGINAL_MSG); - - // Different failing query → server sends a different ERROR packet that overwrites - // the connection read buffer where err1's message slice used to point. - const errOverwrite = await sql`other ${1}`.catch((x: any) => x); - expect(errOverwrite.message).toBe(OVERWRITE_MSG); - - // Same as the first failing query → hits the cached .failed statement and calls - // stmt.error_response.toJS(). Before the fix this read the overwritten buffer and - // returned OVERWRITE_MSG (ZZZ...); after the fix it returns the original message. - const err2 = await sql`wat ${1}`.catch((x: any) => x); - expect({ - code: err2.code, - errno: err2.errno, - sqlState: err2.sqlState, - message: err2.message, - }).toEqual({ - code: err1.code, - errno: err1.errno, - sqlState: err1.sqlState, - message: ORIGINAL_MSG, - }); - - // Only the first two queries should have reached the server; the third hit the cache. - expect(prepareCount).toBe(2); - } finally { - await new Promise(r => server.close(() => r())); - } -}); +} diff --git a/test/js/sql/sql-mysql-clean-reentry.test.ts b/test/js/sql/sql-mysql-clean-reentry.test.ts index 7ee1e0c0eddb..8ca1ef4b07e9 100644 --- a/test/js/sql/sql-mysql-clean-reentry.test.ts +++ b/test/js/sql/sql-mysql-clean-reentry.test.ts @@ -1,3 +1,9 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // MySQLRequestQueue.clean() iterated the live queue while running reject // callbacks. rejectWithJSValue() runs JS via event_loop.runCallback(), whose // exit() drains microtasks when the outer entered_event_loop_count is 0. @@ -6,11 +12,14 @@ // requests out from under the outer loop; when the outer loop resumed it // called LinearFifo.discard(1) on an empty fifo (debug assert -> panic) and // deref() on an already-deref'd request (release -> double free / UAF). -// -// Uses a minimal mock MySQL server so it can run without Docker. import { expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, tempDir } from "harness"; +import path from "node:path"; + +// Absolute path so the spawned fixture (which lives in a temp dir) can import +// the shared frame builders instead of inlining Buffer construction. +const wireFrames = path.join(import.meta.dir, "wire-frames.ts"); // The failure mode is a debug assert in LinearFifo.discard() (and a UAF under // ASAN); in release builds the underflow is UB and may not crash, so only run @@ -19,34 +28,16 @@ test.skipIf(!isDebug && !isASAN)( "MySQL: clean() is safe when reject callback re-enters via connection.close()", async () => { using dir = tempDir("mysql-clean-reentry", { - "fixture.js": /* js */ ` - const net = require("net"); - const { SQL } = require("bun"); - - function u16le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff]); } - function u24le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); } - function u32le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); } - function packet(seq, payload) { return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); } - - const SERVER_CAPS = (1 << 9) | (1 << 15) | (1 << 19) | (1 << 21) | (1 << 24); - function handshakeV10() { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - return packet(0, Buffer.concat([ - Buffer.from([10]), Buffer.from("mock-5.7.0\\0"), u32le(1), authData1, - Buffer.from([0]), u16le(SERVER_CAPS & 0xffff), Buffer.from([0x2d]), - u16le(0x0002), u16le((SERVER_CAPS >>> 16) & 0xffff), Buffer.from([21]), - Buffer.alloc(10, 0), authData2, Buffer.from("mysql_native_password\\0"), - ])); - } - function okPacket(seq) { return packet(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); } + "fixture.ts": /* js */ ` + import net from "node:net"; + import { SQL } from "bun"; + import { mysqlHandshakeV10, mysqlOkPacket } from ${JSON.stringify(wireFrames)}; let socketRef; const server = net.createServer(socket => { socketRef = socket; let buffered = Buffer.alloc(0), authed = false; - socket.write(handshakeV10()); + socket.write(mysqlHandshakeV10()); socket.on("data", chunk => { buffered = Buffer.concat([buffered, chunk]); while (buffered.length >= 4) { @@ -54,7 +45,7 @@ test.skipIf(!isDebug && !isASAN)( if (buffered.length < 4 + len) break; const seq = buffered[3]; buffered = buffered.subarray(4 + len); - if (!authed) { authed = true; socket.write(okPacket(seq + 1)); } + if (!authed) { authed = true; socket.write(mysqlOkPacket(seq + 1)); } // Never respond to queries -> they stay in the native request queue. } }); @@ -127,7 +118,7 @@ test.skipIf(!isDebug && !isASAN)( }); await using proc = Bun.spawn({ - cmd: [bunExe(), "fixture.js"], + cmd: [bunExe(), "fixture.ts"], env: { ...bunEnv, // A crash here writes a multi-GB core dump that outlives the default diff --git a/test/js/sql/sql-mysql-columns-realloc-oom.test.ts b/test/js/sql/sql-mysql-columns-realloc-oom.test.ts index 2dfcbe48164f..ac82f04955cb 100644 --- a/test/js/sql/sql-mysql-columns-realloc-oom.test.ts +++ b/test/js/sql/sql-mysql-columns-realloc-oom.test.ts @@ -1,3 +1,9 @@ +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. + // Regression test: MySQLConnection.handleResultSet frees statement.columns and then // `try alloc()`s a new slice sized by the server-provided field_count. If that alloc // fails, statement.columns was left pointing at the freed buffer, and the subsequent @@ -10,177 +16,107 @@ // leaves columns = &.{} and the process exits cleanly after rejecting the query. import { expect, test } from "bun:test"; import { bunEnv, bunExe } from "harness"; - -const fixture = /* js */ ` -const net = require("net"); -const { SQL } = require("bun"); - -function u16le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff]); } -function u24le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); } -function u32le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); } -function packet(seq, payload) { return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); } -function lenencStr(s) { - const b = Buffer.from(s); - if (b.length >= 251) throw new Error("too long for 1-byte lenenc"); - return Buffer.concat([Buffer.from([b.length]), b]); -} - -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; -const CLIENT_DEPRECATE_EOF = 1 << 24; -const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - -function handshakeV10() { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - const payload = Buffer.concat([ - Buffer.from([10]), - Buffer.from("mock-5.7.0\\0"), - u32le(1), - authData1, - Buffer.from([0]), - u16le(SERVER_CAPS & 0xffff), - Buffer.from([0x2d]), - u16le(0x0002), - u16le((SERVER_CAPS >>> 16) & 0xffff), - Buffer.from([21]), - Buffer.alloc(10, 0), - authData2, - Buffer.from("mysql_native_password\\0"), - ]); - return packet(0, payload); -} - -function okPacket(seq) { - return packet(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); -} - -// Minimal ColumnDefinition41 packet (used for both params and columns during prepare). -function columnDef(seq, name) { - const payload = Buffer.concat([ - lenencStr("def"), // catalog - lenencStr(""), // schema - lenencStr(""), // table - lenencStr(""), // org_table - lenencStr(name), // name - lenencStr(""), // org_name - Buffer.from([0x0c]), // length of fixed-length fields - u16le(0x2d), // character set - u32le(0), // column length - Buffer.from([0xfd]), // column type (VAR_STRING) - u16le(0), // flags - Buffer.from([0]), // decimals - Buffer.from([0, 0]), // filler - ]); - return packet(seq, payload); -} - -// COM_STMT_PREPARE response: OK header with 1 param, 1 column. -function stmtPrepareOK(seq) { - const payload = Buffer.concat([ - Buffer.from([0x00]), // status - u32le(1), // statement_id - u16le(1), // num_columns - u16le(1), // num_params - Buffer.from([0]), // reserved - u16le(0), // warning_count - ]); - return packet(seq, payload); -} - -// Result-set header claiming 2^64-1 columns via a length-encoded integer with -// a 0xFE prefix. Bun reads this as field_count, sees it differs from the 1 -// column cached at prepare time, frees the old slice, and attempts -// alloc(ColumnDefinition41, 2^64-1), which fails with OutOfMemory. -function hugeResultSetHeader(seq) { - return packet(seq, Buffer.from([0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])); -} +import { + listeningServer, + mysqlColumnDefinition, + mysqlHandshakeV10, + mysqlOkPacket, + mysqlRawPacket, + mysqlReadPackets, + mysqlStmtPrepareOk, +} from "./wire-frames"; const COM_STMT_PREPARE = 0x16; const COM_STMT_EXECUTE = 0x17; +const MYSQL_TYPE_VAR_STRING = 0xfd; -const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - socket.write(handshakeV10()); - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - - const cmd = payload[0]; - if (cmd === COM_STMT_PREPARE) { - socket.write(Buffer.concat([ - stmtPrepareOK(1), - columnDef(2, "p"), // param definition - columnDef(3, "c"), // column definition - ])); - } else if (cmd === COM_STMT_EXECUTE) { - socket.write(hugeResultSetHeader(1)); - } else { - socket.end(); - } - } - }); -}); - -server.listen(0, "127.0.0.1", async () => { - const port = server.address().port; - const sql = new SQL({ url: "mysql://root@127.0.0.1:" + port + "/db", max: 1 }); - - const err = await sql\`SELECT \${1}\`.catch(e => e); - // Force the connection object through full teardown so that - // JSMySQLConnection.deinit -> MySQLConnection.cleanup runs and derefs the - // cached prepared statement, triggering MySQLStatement.deinit. - await sql.close().catch(() => {}); - Bun.gc(true); - await Bun.sleep(0); - Bun.gc(true); - - console.log(JSON.stringify({ code: err?.code ?? null, name: err?.name ?? null })); - - server.close(() => process.exit(0)); -}); -`; +// Deliberately-malformed result-set header claiming 2^64-1 columns via a length-encoded +// integer with a 0xFE prefix. Bun reads this as field_count, sees it differs from the 1 +// column cached at prepare time, frees the old slice, and attempts +// alloc(ColumnDefinition41, 2^64-1), which fails with OutOfMemory. Built via mysqlRawPacket +// because the typed builders refuse to encode an unrepresentable column count. +function hugeResultSetHeader(seq: number): Buffer { + return mysqlRawPacket(seq, Buffer.from([0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])); +} test("MySQL: OOM reallocating statement.columns does not leave a dangling slice", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", fixture], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - timeout: 60_000, + // The mock server runs in the test process (pure node:net, never touches Bun's MySQL code); + // only the client runs in a subprocess so an ASAN abort there is observable as exitCode != 0. + let sawStmtExecute = false; + const { server, port } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let authed = false; + socket.write(mysqlHandshakeV10()); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), (seq, payload) => { + if (!authed) { + authed = true; + socket.write(mysqlOkPacket(seq + 1)); + return; + } + const cmd = payload[0]; + if (cmd === COM_STMT_PREPARE) { + socket.write( + Buffer.concat([ + mysqlStmtPrepareOk(1, 1, 1, 1), + mysqlColumnDefinition(2, { name: "p", type: MYSQL_TYPE_VAR_STRING }), // param definition + mysqlColumnDefinition(3, { name: "c", type: MYSQL_TYPE_VAR_STRING }), // column definition + ]), + ); + } else if (cmd === COM_STMT_EXECUTE) { + sawStmtExecute = true; + socket.write(hugeResultSetHeader(1)); + } else { + socket.end(); + } + }); + }); + socket.on("error", () => {}); }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // On the unfixed build the subprocess aborts inside MySQLStatement.deinit - // before it can print the JSON result line, so stdout is empty. With the - // fix the query is rejected cleanly and the JSON result line is printed. - // stderr is included only so its contents appear in the toEqual diff on - // failure; the pass/fail signal comes from stdout and exitCode. - expect({ stderr, stdout: stdout.trim() }).toEqual({ - stderr: expect.any(String), - stdout: expect.stringMatching(/^\{.*\}$/), - }); - const result = JSON.parse(stdout.trim()); - expect(typeof result.code === "string" || typeof result.name === "string").toBe(true); - expect(exitCode).toBe(0); + try { + const fixture = /* js */ ` + const { SQL } = require("bun"); + const sql = new SQL({ url: "mysql://root@127.0.0.1:${port}/db", max: 1 }); + + const err = await sql\`SELECT \${1}\`.catch(e => e); + // Force the connection object through full teardown so that + // JSMySQLConnection.deinit -> MySQLConnection.cleanup runs and derefs the + // cached prepared statement, triggering MySQLStatement.deinit. + await sql.close().catch(() => {}); + Bun.gc(true); + await Bun.sleep(0); + Bun.gc(true); + + console.log(JSON.stringify({ code: err?.code ?? null, name: err?.name ?? null })); + process.exit(0); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 60_000, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // On the unfixed build the subprocess aborts inside MySQLStatement.deinit + // before it can print the JSON result line, so stdout is empty. With the + // fix the query is rejected cleanly and the JSON result line is printed. + // stderr is included only so its contents appear in the toEqual diff on + // failure; the pass/fail signal comes from stdout and exitCode. + // sawStmtExecute proves the mock actually sent the huge result-set header + // (a JSON error before execute would otherwise satisfy the stdout check). + expect({ stderr, stdout: stdout.trim(), sawStmtExecute }).toEqual({ + stderr: expect.any(String), + stdout: expect.stringMatching(/^\{.*\}$/), + sawStmtExecute: true, + }); + const result = JSON.parse(stdout.trim()); + expect(typeof result.code === "string" || typeof result.name === "string").toBe(true); + expect(exitCode).toBe(0); + } finally { + await new Promise(r => server.close(() => r())); + } }); diff --git a/test/js/sql/sql-mysql-datetime-roundtrip.test.ts b/test/js/sql/sql-mysql-datetime-roundtrip.test.ts index 8a5a603a4dc8..caf94364955d 100644 --- a/test/js/sql/sql-mysql-datetime-roundtrip.test.ts +++ b/test/js/sql/sql-mysql-datetime-roundtrip.test.ts @@ -38,40 +38,6 @@ function assertRoundTrip(stdout: string, stderr: string, TZ: string) { expect(stdout).toMatch(TZ === "Etc/UTC" ? /offsetMin=0\b/ : /offsetMin=-?[1-9]/); } -// Text-protocol decode against a mock MySQL server — runs everywhere (no -// Docker / live server needed). The mock sends wall-clock DATE/DATETIME text -// (`2024-06-15 12:34:56`, zero dates, impossible calendar dates) and the -// decoded epoch-ms must match the UTC interpretation of those components in -// every process timezone. -import { COLUMNS } from "./sql-mysql-datetime-text-mock-fixture.ts"; - -describe.each(TIMEZONES)("text protocol via mock server, TZ=%s", TZ => { - test("DATE/DATETIME text decodes as UTC", async () => { - await using proc = Bun.spawn({ - cmd: [bunExe(), path.join(import.meta.dir, "sql-mysql-datetime-text-mock-fixture.ts")], - env: { ...bunEnv, TZ }, - stdout: "pipe", - stderr: "pipe", - timeout: 60_000, - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - // Surface mock-server / client errors, not just a JSON parse failure. - expect(stderr).toBe(""); - - const out = JSON.parse(stdout) as { tz: string; offsetMin: number; values: Record }; - expect(out.values).toEqual(Object.fromEntries(COLUMNS.map(col => [col.name, String(col.expected)]))); - // The child must actually have adopted the injected timezone — otherwise - // the non-UTC runs degenerate into the UTC case and prove nothing. - if (TZ === "Etc/UTC") { - expect(out.offsetMin).toBe(0); - } else { - expect(out.offsetMin).not.toBe(0); - } - expect(exitCode).toBe(0); - }); -}); - if (isDockerEnabled()) { // CI: run against the docker-compose MySQL service. describeWithContainer("mysql", { image: "mysql_plain" }, container => { diff --git a/test/js/sql/sql-mysql-datetime-text-mock-fixture.ts b/test/js/sql/sql-mysql-datetime-text-mock-fixture.ts deleted file mode 100644 index 6a6ee50afd0f..000000000000 --- a/test/js/sql/sql-mysql-datetime-text-mock-fixture.ts +++ /dev/null @@ -1,197 +0,0 @@ -// Fixture: decode MySQL text-protocol DATE/DATETIME values through a minimal -// mock MySQL server (no Docker / live server needed) and print the resulting -// epoch-ms values as JSON. -// -// The text protocol sends dates as wall-clock strings with no timezone -// (`2024-06-15 12:34:56`). The decoder must treat those components as UTC — -// the same convention the binary protocol and the encode path use — so the -// printed values must be identical regardless of this process's TZ. -// -// Spawned by sql-mysql-datetime-roundtrip.test.ts under several TZ values. - -import { SQL } from "bun"; -import { once } from "events"; -import net from "net"; - -// --- MySQL wire format helpers (mirrors sql-mysql-raw-length-prefix.test.ts) --- - -function u16le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); -} -function u24le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); -} -function u32le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); -} -function packet(seq: number, payload: Buffer): Buffer { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); -} -function lenenc(n: number): Buffer { - if (n < 0xfb) return Buffer.from([n]); - throw new Error("lenenc: only the 1-byte form is needed for this fixture"); -} -function lenencStr(s: string): Buffer { - const buf = Buffer.from(s, "utf-8"); - return Buffer.concat([lenenc(buf.length), buf]); -} - -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; -const CLIENT_DEPRECATE_EOF = 1 << 24; -const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - -const MYSQL_TYPE_DATE = 0x0a; -const MYSQL_TYPE_DATETIME = 0x0c; - -function handshakeV10(): Buffer { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - const payload = Buffer.concat([ - Buffer.from([10]), // protocol version - Buffer.from("mock-5.7.0\0"), - u32le(1), // connection id - authData1, - Buffer.from([0]), // filler - u16le(SERVER_CAPS & 0xffff), - Buffer.from([0x2d]), // utf8mb4_general_ci - u16le(0x0002), // SERVER_STATUS_AUTOCOMMIT - u16le((SERVER_CAPS >>> 16) & 0xffff), - Buffer.from([21]), // length of auth-plugin-data - Buffer.alloc(10, 0), // reserved - authData2, - Buffer.from("mysql_native_password\0"), - ]); - return packet(0, payload); -} - -function okPacket(seq: number): Buffer { - return packet(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); -} - -function columnDefinition(name: string, type: number): Buffer { - return Buffer.concat([ - lenencStr("def"), - lenencStr(""), - lenencStr("t"), - lenencStr("t"), - lenencStr(name), - lenencStr(name), - Buffer.from([0x0c]), // fixed-length-fields length = 12 - u16le(33), // utf8_general_ci - u32le(32), // column_length (display width) - Buffer.from([type]), - u16le(0), // flags - Buffer.from([0]), // decimals - Buffer.from([0, 0]), // reserved - ]); -} - -// The columns this fixture serves, with the wall-clock text the mock server -// sends and the UTC instant (or NaN) the decoder must produce for it. -export const COLUMNS: { name: string; type: number; text: string; expected: number }[] = [ - { - name: "dt", - type: MYSQL_TYPE_DATETIME, - text: "2024-06-15 12:34:56", - expected: Date.UTC(2024, 5, 15, 12, 34, 56), - }, - { - name: "dt_frac", - type: MYSQL_TYPE_DATETIME, - text: "2024-06-15 12:34:56.123456", - expected: Date.UTC(2024, 5, 15, 12, 34, 56, 123), - }, - { - name: "d", - type: MYSQL_TYPE_DATE, - text: "2024-06-15", - expected: Date.UTC(2024, 5, 15), - }, - { - name: "zero_date", - type: MYSQL_TYPE_DATETIME, - text: "0000-00-00 00:00:00", - expected: NaN, - }, - { - name: "impossible_date", - type: MYSQL_TYPE_DATETIME, - text: "2024-02-31 00:00:00", - expected: NaN, - }, -]; - -// Text-protocol result set: one row whose cells are the COLUMNS texts. -function textResultSet(startSeq: number): Buffer { - const packets: Buffer[] = []; - let seq = startSeq; - packets.push(packet(seq++, Buffer.from([COLUMNS.length]))); - for (const col of COLUMNS) { - packets.push(packet(seq++, columnDefinition(col.name, col.type))); - } - packets.push(packet(seq++, Buffer.concat(COLUMNS.map(col => lenencStr(col.text))))); - // OK packet closing the result set (CLIENT_DEPRECATE_EOF, header 0xfe). - packets.push(packet(seq++, Buffer.from([0xfe, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]))); - return Buffer.concat(packets); -} - -if (import.meta.main) { - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - - socket.write(handshakeV10()); - - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - - if (payload[0] === 0x03 /* COM_QUERY */) { - socket.write(textResultSet(seq + 1)); - } else { - // COM_QUIT / anything else — close. - socket.end(); - } - } - }); - }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - // `.simple()` forces the text protocol → ResultSet text decode → DateTime::from_text. - const rows = (await sql`SELECT * FROM t`.simple()) as Record[]; - console.log( - JSON.stringify({ - tz: process.env.TZ, - offsetMin: new Date(Date.UTC(2024, 5, 15, 12, 34, 56)).getTimezoneOffset(), - // NaN is not representable in JSON; stringify getTime() instead. - values: Object.fromEntries(COLUMNS.map(col => [col.name, String(rows[0][col.name]?.getTime())])), - }), - ); - } finally { - server.close(); - } -} diff --git a/test/js/sql/sql-mysql-datetime-tz-fixture.ts b/test/js/sql/sql-mysql-datetime-tz-fixture.ts index f057c5573a1a..e5d422eb7e07 100644 --- a/test/js/sql/sql-mysql-datetime-tz-fixture.ts +++ b/test/js/sql/sql-mysql-datetime-tz-fixture.ts @@ -60,17 +60,25 @@ checkRoundTrip("text", await sql`SELECT id, dt FROM ${sql(t)} ORDER BY id`.simpl // MySQL's permissive sql_mode stores "0000-00-00 00:00:00"; it must read back // as Invalid Date (not the Unix epoch / a wrapped date) on both protocols. +// ALLOW_INVALID_DATES additionally lets MySQL store a non-zero day past its +// month length ("2024-02-31") verbatim; that must also read back as Invalid +// Date instead of being normalized to March 2 by the decoder. const zt = "dt_zero_" + randomUUIDv7("hex").replaceAll("-", ""); -await sql`SET SESSION sql_mode=''`.simple(); +await sql`SET SESSION sql_mode='ALLOW_INVALID_DATES'`.simple(); await sql`CREATE TEMPORARY TABLE ${sql(zt)} (id INT PRIMARY KEY, dt DATETIME)`.simple(); -await sql.unsafe(`INSERT INTO ${zt} (id, dt) VALUES (1, '0000-00-00 00:00:00')`); -for (const [protocol, [row]] of [ - ["binary", await sql`SELECT dt FROM ${sql(zt)} WHERE id = 1`], - ["text", await sql`SELECT dt FROM ${sql(zt)} WHERE id = 1`.simple()], +await sql.unsafe(`INSERT INTO ${zt} (id, dt) VALUES (1, '0000-00-00 00:00:00'), (2, '2024-02-31 00:00:00')`); +for (const [protocol, rows] of [ + ["binary", await sql`SELECT id, dt FROM ${sql(zt)} ORDER BY id`], + ["text", await sql`SELECT id, dt FROM ${sql(zt)} ORDER BY id`.simple()], ] as const) { - const got: Date = row.dt; - if (!(got instanceof Date) || !Number.isNaN(got.getTime())) { - failures.push(`${protocol} zero-date: expected Invalid Date, got ${String(got)}`); + for (const [id, label] of [ + [1, "zero-date"], + [2, "impossible-date"], + ] as const) { + const got: Date = rows[id - 1].dt; + if (!(got instanceof Date) || !Number.isNaN(got.getTime())) { + failures.push(`${protocol} ${label}: expected Invalid Date, got ${String(got)}`); + } } } diff --git a/test/js/sql/sql-mysql-mediumint.test.ts b/test/js/sql/sql-mysql-mediumint.test.ts index 5ddf1b7722c5..b6218c7e4d79 100644 --- a/test/js/sql/sql-mysql-mediumint.test.ts +++ b/test/js/sql/sql-mysql-mediumint.test.ts @@ -2,229 +2,45 @@ // a fixed 4-byte field. The decoder used to consume only 3, leaving the cursor // 1 byte behind and corrupting every column that follows (or hanging on a // length-prefixed column like VARCHAR). -// -// Uses a minimal mock MySQL server so the test runs without Docker. -import { SQL } from "bun"; +import { SQL, randomUUIDv7 } from "bun"; import { expect, test } from "bun:test"; -import { once } from "events"; -import net from "net"; - -// --- MySQL wire format helpers --------------------------------------------- - -function u16le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); -} -function u24le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); -} -function u32le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); -} -function i64le(n: bigint): Buffer { - const b = Buffer.alloc(8); - b.writeBigInt64LE(n); - return b; -} -function f64le(n: number): Buffer { - const b = Buffer.alloc(8); - b.writeDoubleLE(n); - return b; -} -function packet(seq: number, payload: Buffer): Buffer { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); -} -function lenenc(n: number): Buffer { - if (n < 0xfb) return Buffer.from([n]); - if (n < 0xffff) return Buffer.concat([Buffer.from([0xfc]), u16le(n)]); - throw new Error("lenenc: not needed for this test"); -} -function lenencStr(s: string): Buffer { - const buf = Buffer.from(s, "utf-8"); - return Buffer.concat([lenenc(buf.length), buf]); -} - -// --- Capability flags ------------------------------------------------------ - -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; -const CLIENT_DEPRECATE_EOF = 1 << 24; -const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - -// MYSQL_TYPE_* values. From src/sql/mysql/mysql_types.rs. -const MYSQL_TYPE_LONG = 0x03; -const MYSQL_TYPE_DOUBLE = 0x05; -const MYSQL_TYPE_LONGLONG = 0x08; -const MYSQL_TYPE_INT24 = 0x09; -const MYSQL_TYPE_VAR_STRING = 0xfd; - -const UNSIGNED_FLAG = 1 << 5; - -// --- Packet builders ------------------------------------------------------- - -function handshakeV10(): Buffer { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - return packet( - 0, - Buffer.concat([ - Buffer.from([10]), - Buffer.from("mock-5.7.0\0"), - u32le(1), - authData1, - Buffer.from([0]), - u16le(SERVER_CAPS & 0xffff), - Buffer.from([0x2d]), - u16le(0x0002), - u16le((SERVER_CAPS >>> 16) & 0xffff), - Buffer.from([21]), - Buffer.alloc(10, 0), - authData2, - Buffer.from("mysql_native_password\0"), - ]), - ); -} - -function okPacket(seq: number, header = 0x00): Buffer { - return packet(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); -} - -function columnDef(name: string, type: number, flags = 0): Buffer { - return Buffer.concat([ - lenencStr("def"), - lenencStr(""), - lenencStr("t"), - lenencStr("t"), - lenencStr(name), - lenencStr(name), - Buffer.from([0x0c]), - u16le(33), - u32le(1024), - Buffer.from([type]), - u16le(flags), - Buffer.from([0]), - Buffer.from([0, 0]), - ]); -} - -const columns = [ - columnDef("id", MYSQL_TYPE_LONG), - columnDef("uviews", MYSQL_TYPE_INT24, UNSIGNED_FLAG), - columnDef("sviews", MYSQL_TYPE_INT24), - columnDef("balance", MYSQL_TYPE_LONGLONG), - columnDef("ratio", MYSQL_TYPE_DOUBLE), - columnDef("name", MYSQL_TYPE_VAR_STRING), -]; - -function stmtPrepareOK(startSeq: number, stmtId: number): Buffer { - const packets: Buffer[] = []; - let seq = startSeq; - packets.push( - packet( - seq++, - Buffer.concat([ - Buffer.from([0x00]), - u32le(stmtId), - u16le(columns.length), - u16le(0), // num_params - Buffer.from([0x00]), - u16le(0), - ]), - ), - ); - for (const c of columns) packets.push(packet(seq++, c)); - return Buffer.concat(packets); -} - -function binaryResultSet(startSeq: number): Buffer { - const packets: Buffer[] = []; - let seq = startSeq; - packets.push(packet(seq++, Buffer.from([columns.length]))); - for (const c of columns) packets.push(packet(seq++, c)); - // Binary row: 0x00 header, NULL bitmap ((6+7+2)/8 = 1 byte), then values. - // MEDIUMINT is transmitted as a fixed 4-byte field — the decoder must - // consume all 4 or the cursor desyncs into the following columns. - packets.push( - packet( - seq++, - Buffer.concat([ - Buffer.from([0x00]), // row header - Buffer.from([0x00]), // null bitmap: nothing null - u32le(1), // id INT - u32le(100), // uviews MEDIUMINT UNSIGNED → 64 00 00 00 - u32le(0xffffffce), // sviews MEDIUMINT (-50) → ce ff ff ff - i64le(5000n), // balance BIGINT - f64le(3.5), // ratio DOUBLE - lenencStr("alice"), // name VARCHAR - ]), - ), - ); - packets.push(packet(seq++, Buffer.from([0xfe, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]))); - return Buffer.concat(packets); -} - -function startMockServer() { - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - let stmtId = 0; - socket.write(handshakeV10()); - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - const cmd = payload[0]; - if (cmd === 0x16 /* COM_STMT_PREPARE */) { - socket.write(stmtPrepareOK(seq + 1, ++stmtId)); - } else if (cmd === 0x17 /* COM_STMT_EXECUTE */) { - socket.write(binaryResultSet(seq + 1)); - } else if (cmd === 0x03 /* COM_QUERY */) { - socket.write(okPacket(seq + 1)); - } else if (cmd === 0x19 /* COM_STMT_CLOSE */) { - // no response expected - } else { - socket.end(); - } +import { describeWithContainer, isDockerEnabled } from "harness"; + +if (isDockerEnabled()) { + describeWithContainer("mysql", { image: "mysql_plain" }, container => { + test("MEDIUMINT before other columns is read as 4 bytes (binary protocol)", async () => { + await container.ready; + await using sql = new SQL({ url: `mysql://root@${container.host}:${container.port}/bun_sql_test`, max: 1 }); + + const table = ("t_" + randomUUIDv7("hex").replaceAll("-", "")).toLowerCase(); + try { + await sql` + CREATE TEMPORARY TABLE ${sql(table)} ( + id INT, + uviews MEDIUMINT UNSIGNED, + sviews MEDIUMINT, + balance BIGINT, + ratio DOUBLE, + name VARCHAR(255) + ) + `.simple(); + await sql`INSERT INTO ${sql(table)} (id, uviews, sviews, balance, ratio, name) VALUES (1, 100, -50, 5000, 3.5, 'alice')`.simple(); + + // Prepared → binary protocol. If the decoder consumes only 3 of the 4 + // INT24 bytes the cursor desyncs into balance/ratio/name and this row + // either hangs on the VARCHAR length prefix or returns garbage. + const [row] = await sql`SELECT id, uviews, sviews, balance, ratio, name FROM ${sql(table)}`; + expect(row).toEqual({ id: 1, uviews: 100, sviews: -50, balance: 5000, ratio: 3.5, name: "alice" }); + + const [rawRow] = await sql`SELECT id, uviews, sviews, balance, ratio, name FROM ${sql(table)}`.raw(); + expect(rawRow).toHaveLength(6); + expect(rawRow[1]).toEqual(new Uint8Array([0x64, 0x00, 0x00])); // 100 + expect(rawRow[2]).toEqual(new Uint8Array([0xce, 0xff, 0xff])); // -50 as i24 LE + expect(Buffer.from(rawRow[5]).toString("utf-8")).toBe("alice"); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(table)}`.simple(); } }); }); - server.listen(0, "127.0.0.1"); - return server; } - -test("MEDIUMINT before other columns is read as 4 bytes (binary protocol)", async () => { - const server = startMockServer(); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - - const [row] = await sql`SELECT id, uviews, sviews, balance, ratio, name FROM t`; - expect(row).toEqual({ id: 1, uviews: 100, sviews: -50, balance: 5000, ratio: 3.5, name: "alice" }); - - const [rawRow] = await sql`SELECT id, uviews, sviews, balance, ratio, name FROM t`.raw(); - expect(rawRow).toHaveLength(6); - expect(rawRow[1]).toEqual(new Uint8Array([0x64, 0x00, 0x00])); // 100 - expect(rawRow[2]).toEqual(new Uint8Array([0xce, 0xff, 0xff])); // -50 as i24 LE - expect(Buffer.from(rawRow[5]).toString("utf-8")).toBe("alice"); - } finally { - await new Promise(r => server.close(() => r())); - } -}); diff --git a/test/js/sql/sql-mysql-query-string-leak.test.ts b/test/js/sql/sql-mysql-query-string-leak.test.ts index 7b7ef7f4e457..edff1e05fe36 100644 --- a/test/js/sql/sql-mysql-query-string-leak.test.ts +++ b/test/js/sql/sql-mysql-query-string-leak.test.ts @@ -4,125 +4,90 @@ // refcount 2 after construction; MySQLQuery.cleanup() only deref'd once, so // the underlying WTFStringImpl for every MySQL query string was leaked. // -// This test uses a minimal mock MySQL server (no Docker required) that OKs -// every simple query, runs a batch of large unique query strings through it, -// lets the MySQLQuery wrappers be finalized, and checks RSS didn't retain the -// query-string bytes. +// This test runs a batch of large unique query strings against a real MySQL +// server, lets the MySQLQuery wrappers be finalized, and checks RSS didn't +// retain the query-string bytes. import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, tempDir } from "harness"; +import { bunEnv, bunExe, describeWithContainer, isASAN, isDockerEnabled, tempDir } from "harness"; -test("MySQL: query string is not leaked across query lifecycle", async () => { - using dir = tempDir("mysql-query-string-leak", { - "fixture.js": /* js */ ` - const net = require("net"); - const { SQL } = require("bun"); +if (isDockerEnabled()) { + describeWithContainer("mysql", { image: "mysql_plain" }, container => { + test("MySQL: query string is not leaked across query lifecycle", async () => { + await container.ready; + const url = `mysql://root@${container.host}:${container.port}/bun_sql_test`; - function u16le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff]); } - function u24le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); } - function u32le(n) { return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); } - function packet(seq, payload) { return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); } + using dir = tempDir("mysql-query-string-leak", { + "fixture.js": /* js */ ` + const { SQL } = require("bun"); - // CLIENT_PROTOCOL_41 | CLIENT_SECURE_CONNECTION | CLIENT_PLUGIN_AUTH | - // CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | CLIENT_DEPRECATE_EOF - const SERVER_CAPS = (1 << 9) | (1 << 15) | (1 << 19) | (1 << 21) | (1 << 24); - function handshakeV10() { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - return packet(0, Buffer.concat([ - Buffer.from([10]), Buffer.from("mock-5.7.0\\0"), u32le(1), authData1, - Buffer.from([0]), u16le(SERVER_CAPS & 0xffff), Buffer.from([0x2d]), - u16le(0x0002), u16le((SERVER_CAPS >>> 16) & 0xffff), Buffer.from([21]), - Buffer.alloc(10, 0), authData2, Buffer.from("mysql_native_password\\0"), - ])); - } - // header 0x00, affected_rows 0, last_insert_id 0, status_flags 0x0002, warnings 0 - function okPacket(seq) { return packet(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); } - - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0), authed = false; - socket.write(handshakeV10()); - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - buffered = buffered.subarray(4 + len); - if (!authed) { authed = true; socket.write(okPacket(seq + 1)); continue; } - // Any subsequent packet (COM_QUERY / COM_QUIT / ...) -> OK. Sequence - // id resets per command, so the response starts at seq+1 (== 1). - socket.write(okPacket(seq + 1)); - } - }); - socket.on("error", () => {}); - }); - - server.listen(0, "127.0.0.1"); - await new Promise(r => server.on("listening", r)); - const { port } = server.address(); + const sql = new SQL({ url: process.env.MYSQL_URL, max: 1 }); - const sql = new SQL({ url: \`mysql://root@127.0.0.1:\${port}/db\`, max: 1 }); + // Warm up: first query allocates connection buffers, JIT, etc. + await sql.unsafe("select 1").simple(); - // Warm up: first query allocates connection buffers, JIT, etc. - await sql.unsafe("select 1").simple(); + // Each query string is ~512 KiB and unique (so JSC can't dedupe/intern + // them) and goes through the full create -> run -> finalize lifecycle. + // 200 iterations x 512 KiB = ~100 MiB of string payload. + const ITERATIONS = 200; + const CHUNK = 512 * 1024; - // Each query string is ~512 KiB and unique (so JSC can't dedupe/intern - // them) and goes through the full create -> run -> finalize lifecycle. - // 200 iterations x 512 KiB = ~100 MiB of string payload. - const ITERATIONS = 200; - const CHUNK = 512 * 1024; - - Bun.gc(true); - const rssBefore = process.memoryUsage.rss(); + Bun.gc(true); + const rssBefore = process.memoryUsage.rss(); + + for (let i = 0; i < ITERATIONS; i++) { + const pad = Buffer.alloc(CHUNK, 0x61 + (i % 26)).toString("latin1"); + // Embed the bulk as a comment so the server's reply is a trivial OK + // regardless of content; suffix makes every string unique. + const q = "select 1 /* " + pad + " " + i + " */"; + await sql.unsafe(q).simple(); + if ((i & 15) === 15) Bun.gc(true); + } + + await sql.close({ timeout: 0 }).catch(() => {}); + + // Give the MySQLQuery wrappers a chance to be finalized so cleanup() + // runs and drops its (single) ref on each query string. + for (let i = 0; i < 8; i++) { + await new Promise(r => setImmediate(r)); + Bun.gc(true); + } + + const rssAfter = process.memoryUsage.rss(); + const deltaMiB = (rssAfter - rssBefore) / 1024 / 1024; + console.log(JSON.stringify({ rssBefore, rssAfter, deltaMiB })); + `, + }); - for (let i = 0; i < ITERATIONS; i++) { - const pad = Buffer.alloc(CHUNK, 0x61 + (i % 26)).toString("latin1"); - // Embed the bulk as a comment so the mock server's OK reply is valid - // regardless of content; suffix makes every string unique. - const q = "select 1 /* " + pad + " " + i + " */"; - await sql.unsafe(q).simple(); - if ((i & 15) === 15) Bun.gc(true); - } + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.js"], + env: { ...bunEnv, MYSQL_URL: url }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + timeout: 120_000, + }); - await sql.close({ timeout: 0 }).catch(() => {}); - server.close(); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - // Give the MySQLQuery wrappers a chance to be finalized so cleanup() - // runs and drops its (single) ref on each query string. - for (let i = 0; i < 8; i++) { - await new Promise(r => setImmediate(r)); - Bun.gc(true); + let parsed: { deltaMiB: number }; + try { + parsed = JSON.parse(stdout.trim()); + } catch { + throw new Error(`fixture did not emit JSON\nstdout:\n${stdout}\nstderr:\n${stderr}`); } - - const rssAfter = process.memoryUsage.rss(); - const deltaMiB = (rssAfter - rssBefore) / 1024 / 1024; - console.log(JSON.stringify({ rssBefore, rssAfter, deltaMiB })); - `, + // With the leak, every one of the ~200 x 512 KiB query strings is retained + // (plus per-string overhead), so RSS grows by >= ~100 MiB. With the fix the + // strings are freed as each MySQLQuery is finalized and growth stays small. + // ASAN's quarantine retains freed allocations (default 256 MB) and a real + // server adds wire-buffer + encode churn on top of the string churn, so the + // delta runs higher under bun-asan even with the fix; widen the threshold + // there. The non-ASAN bound is the discriminating check. + expect(parsed.deltaMiB).toBeLessThan(isASAN ? 384 : 50); + expect(exitCode).toBe(0); + // 200 × 512 KiB round-trips to a real MySQL server plus ~20 Bun.gc(true) + // calls in an ASAN debug subprocess can take tens of seconds; the 5s + // default is too tight. + }, 120_000); }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "fixture.js"], - env: bunEnv, - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - timeout: 60_000, - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - - expect(stderr).toBe(""); - const { deltaMiB } = JSON.parse(stdout.trim()); - // With the leak, every one of the ~200 x 512 KiB query strings is retained - // (plus per-string overhead), so RSS grows by >= ~100 MiB. With the fix the - // strings are freed as each MySQLQuery is finalized and growth stays small. - // ASAN's quarantine retains freed allocations (default 256 MB) so the delta - // runs higher under bun-asan even with the fix; widen the threshold there. - expect(deltaMiB).toBeLessThan(isASAN ? 256 : 50); - expect(exitCode).toBe(0); - // 200 × 512 KiB round-trips plus ~20 Bun.gc(true) calls in an ASAN debug - // subprocess take ~6–17s; the 5s default is too tight. Same reason as - // postgres-tls-ctx-leak.test.ts. -}, 60_000); +} diff --git a/test/js/sql/sql-mysql-raw-length-prefix.test.ts b/test/js/sql/sql-mysql-raw-length-prefix.test.ts index 531295530f51..5926267a85cb 100644 --- a/test/js/sql/sql-mysql-raw-length-prefix.test.ts +++ b/test/js/sql/sql-mysql-raw-length-prefix.test.ts @@ -3,194 +3,16 @@ // `.raw()` on any length-encoded MySQL column (json / varchar / text / // blob / enum / geometry / ...) used to return the length-encoded-integer // prefix bytes concatenated with the payload. The reporter saw a leading -// `0xFFFD` when decoding a JSON column as UTF-8 — that's the 0xa7 length -// prefix (a lone UTF-8 continuation byte) showing up in front of the JSON. -// -// Uses a minimal mock MySQL server so the test runs without Docker or a -// live MySQL installation. +// `0xFFFD` when decoding a JSON column as UTF-8 — that's the length-prefix +// byte (a lone UTF-8 continuation byte) showing up in front of the JSON. -import { SQL } from "bun"; +import { SQL, randomUUIDv7 } from "bun"; import { expect, test } from "bun:test"; -import { once } from "events"; -import net from "net"; - -// --- MySQL wire format helpers --------------------------------------------- - -function u16le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); -} -function u24le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); -} -function u32le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); -} - -function packet(seq: number, payload: Buffer): Buffer { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); -} - -// MySQL length-encoded integer: < 0xfb → 1 byte; < 0xffff → 0xfc + 2 bytes; -// < 0xffffff → 0xfd + 3 bytes; else 0xfe + 8 bytes. -function lenenc(n: number): Buffer { - if (n < 0xfb) return Buffer.from([n]); - if (n < 0xffff) return Buffer.concat([Buffer.from([0xfc]), u16le(n)]); - if (n < 0xffffff) return Buffer.concat([Buffer.from([0xfd]), u24le(n)]); - throw new Error("lenenc: 8-byte form not needed for this test"); -} -function lenencStr(s: string | Buffer): Buffer { - const buf = typeof s === "string" ? Buffer.from(s, "utf-8") : s; - return Buffer.concat([lenenc(buf.length), buf]); -} - -// --- Capability flags ------------------------------------------------------ - -const CLIENT_PROTOCOL_41 = 1 << 9; -const CLIENT_SECURE_CONNECTION = 1 << 15; -const CLIENT_PLUGIN_AUTH = 1 << 19; -const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; -const CLIENT_DEPRECATE_EOF = 1 << 24; -const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - -// MYSQL_TYPE_* values used below. From src/sql/mysql/MySQLTypes.zig. -const MYSQL_TYPE_VAR_STRING = 0xfd; -const MYSQL_TYPE_JSON = 0xf5; +import { describeWithContainer, isDockerEnabled } from "harness"; -// --- Packet builders ------------------------------------------------------- - -function handshakeV10(): Buffer { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - const payload = Buffer.concat([ - Buffer.from([10]), // protocol version - Buffer.from("mock-5.7.0\0"), - u32le(1), // connection id - authData1, - Buffer.from([0]), // filler - u16le(SERVER_CAPS & 0xffff), - Buffer.from([0x2d]), // utf8mb4_general_ci - u16le(0x0002), // SERVER_STATUS_AUTOCOMMIT - u16le((SERVER_CAPS >>> 16) & 0xffff), - Buffer.from([21]), // length of auth-plugin-data - Buffer.alloc(10, 0), // reserved - authData2, - Buffer.from("mysql_native_password\0"), - ]); - return packet(0, payload); -} - -function okPacket(seq: number, header = 0x00): Buffer { - return packet(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); -} - -function columnDefinition(name: string, type: number): Buffer { - // ColumnDefinition41: catalog, schema, table, org_table, name, org_name (all - // lenenc strings), fixed-length-field-length (lenenc = 0x0c), character_set - // (u16), column_length (u32), column_type (u8), flags (u16), decimals (u8), - // plus 2 reserved bytes. - return Buffer.concat([ - lenencStr("def"), - lenencStr(""), - lenencStr("t"), - lenencStr("t"), - lenencStr(name), - lenencStr(name), - Buffer.from([0x0c]), // fixed-length-fields length = 12 - u16le(33), // utf8_general_ci - u32le(1024 * 1024), // column_length (display width) - Buffer.from([type]), - u16le(0), // flags - Buffer.from([0]), // decimals - Buffer.from([0, 0]), // reserved - ]); -} - -// Build a text-protocol result-set response for a single row with two columns. -// Column 1: VARCHAR name | Column 2: JSON post. -function textResultSet(startSeq: number, nameValue: string, jsonValue: string): Buffer { - // Order: column count, column defs, row, OK/EOF. - const packets: Buffer[] = []; - let seq = startSeq; - - // Column count - packets.push(packet(seq++, Buffer.from([0x02]))); - // Two column definitions - packets.push(packet(seq++, columnDefinition("name", MYSQL_TYPE_VAR_STRING))); - packets.push(packet(seq++, columnDefinition("post", MYSQL_TYPE_JSON))); - // Row: each column is a lenenc string. The bug is exactly here — the - // decoder needs to read the lenenc prefix and return only the payload. - packets.push(packet(seq++, Buffer.concat([lenencStr(nameValue), lenencStr(jsonValue)]))); - // OK packet to close the result set (with CLIENT_DEPRECATE_EOF, header 0xfe). - packets.push(packet(seq++, Buffer.from([0xfe, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]))); - - return Buffer.concat(packets); -} - -// COM_STMT_PREPARE response: OK header + 0 params + 2 result columns. -function stmtPrepareOK(startSeq: number, statementId: number): Buffer { - const packets: Buffer[] = []; - let seq = startSeq; - // StmtPrepareOK: 0x00, stmt_id u32, num_columns u16, num_params u16, - // reserved u8 = 0x00, warning_count u16. - packets.push( - packet( - seq++, - Buffer.concat([ - Buffer.from([0x00]), - u32le(statementId), - u16le(2), // num_columns - u16le(0), // num_params - Buffer.from([0x00]), // reserved - u16le(0), // warning_count - ]), - ), - ); - // With num_params = 0, no param definitions + EOF follow. Just the column - // definitions + (with CLIENT_DEPRECATE_EOF) no trailing EOF. - packets.push(packet(seq++, columnDefinition("name", MYSQL_TYPE_VAR_STRING))); - packets.push(packet(seq++, columnDefinition("post", MYSQL_TYPE_JSON))); - return Buffer.concat(packets); -} - -// Binary-protocol result-set for a single row with two non-null columns. -function binaryResultSet(startSeq: number, nameValue: string, jsonValue: string): Buffer { - const packets: Buffer[] = []; - let seq = startSeq; - - // Column count - packets.push(packet(seq++, Buffer.from([0x02]))); - packets.push(packet(seq++, columnDefinition("name", MYSQL_TYPE_VAR_STRING))); - packets.push(packet(seq++, columnDefinition("post", MYSQL_TYPE_JSON))); - // Binary row: header 0x00, NULL bitmap, then each non-null value. - // Bitmap is ceil((n + 7 + 2) / 8) bytes with the first 2 bits reserved; - // 2 non-null columns → single 0x00 byte. - packets.push( - packet( - seq++, - Buffer.concat([ - Buffer.from([0x00]), // packet header - Buffer.from([0x00]), // null bitmap: nothing null - lenencStr(nameValue), - lenencStr(jsonValue), - ]), - ), - ); - // OK packet closing (CLIENT_DEPRECATE_EOF, header 0xfe). - packets.push(packet(seq++, Buffer.from([0xfe, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]))); - return Buffer.concat(packets); -} - -// --- Test ------------------------------------------------------------------ - -// 866-byte JSON payload — encodes with the 3-byte length prefix (0xfc NN NN). -// The 8-byte VARCHAR exercises the 1-byte form. Both shapes appeared in the -// original issue report. +// >251-byte JSON payload — encodes on the wire with the 3-byte length prefix +// (0xfc NN NN). The 8-byte VARCHAR exercises the 1-byte form. Both shapes +// appeared in the original issue report. const jsonPayload = { type: "doc", content: Array.from({ length: 20 }, () => ({ type: "paragraph", text: "hello world" })), @@ -198,274 +20,182 @@ const jsonPayload = { const jsonText = JSON.stringify(jsonPayload); const shortText = "testname"; -function startMockServer() { - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - - socket.write(handshakeV10()); - - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - - const cmd = payload[0]; - if (cmd === 0x03 /* COM_QUERY */) { - socket.write(textResultSet(seq + 1, shortText, jsonText)); - } else if (cmd === 0x16 /* COM_STMT_PREPARE */) { - socket.write(stmtPrepareOK(seq + 1, 1)); - } else if (cmd === 0x17 /* COM_STMT_EXECUTE */) { - socket.write(binaryResultSet(seq + 1, shortText, jsonText)); - } else { - // COM_QUIT / anything else — close. - socket.end(); - } - } - }); - }); - server.listen(0, "127.0.0.1"); - return server; -} - -function assertRawRow(name: unknown, post: unknown) { - expect(name).toBeInstanceOf(Uint8Array); - expect(post).toBeInstanceOf(Uint8Array); - // Defining assertion: first byte is the payload's first byte - // ('t' = 0x74 for the VARCHAR, '{' = 0x7b for the JSON), NOT the MySQL - // length-encoded-integer prefix (0x08 / 0xfc respectively). - expect((name as Uint8Array)[0]).toBe(0x74); // 't' - expect((post as Uint8Array)[0]).toBe(0x7b); // '{' - expect(Buffer.from(name as Uint8Array).toString("utf-8")).toBe(shortText); - expect(Buffer.from(post as Uint8Array).toString("utf-8")).toBe(jsonText); - expect((name as Uint8Array).length).toBe(shortText.length); - expect((post as Uint8Array).length).toBe(jsonText.length); -} - -test(".raw() strips length-prefix bytes (#30039) — text protocol", async () => { - const server = startMockServer(); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - // `.simple().raw()` exercises the ResultSet.decodeText raw branch - // (ResultSet.zig:177) that used to call rawEncodeLenData. - const rows = (await sql`SELECT name, post FROM t`.simple().raw()) as unknown as [Uint8Array, Uint8Array][]; - expect(rows).toHaveLength(1); - const [name, post] = rows[0]; - assertRawRow(name, post); - } finally { - await new Promise(r => server.close(() => r())); - } -}); - -test(".raw() strips length-prefix bytes (#30039) — binary protocol", async () => { - const server = startMockServer(); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - // Without `.simple()`, the client uses a prepared statement and the - // binary-protocol row decoder — exercising the DecodeBinaryValue raw - // branches (DecodeBinaryValue.zig:153, :172) that used to call - // rawEncodeLenData for VAR_STRING and JSON. - const rows = (await sql`SELECT name, post FROM t`.raw()) as unknown as [Uint8Array, Uint8Array][]; - expect(rows).toHaveLength(1); - const [name, post] = rows[0]; - assertRawRow(name, post); - } finally { - await new Promise(r => server.close(() => r())); - } -}); - -// A COM_QUERY whose payload exceeds the 24-bit packet length limit cannot be -// framed as a single MySQL packet. It must be rejected client-side AND rolled -// back out of the connection's write buffer: leaving the partially-serialized -// packet behind desynchronizes the protocol stream, and the next query gets -// appended after the garbage and reparsed by the server as bogus packets. -test("oversized COM_QUERY is rejected and rolled back out of the write buffer", async () => { - const queries: string[] = []; - let desynced = false; - - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - socket.write(handshakeV10()); - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - if (payload[0] === 0x03 /* COM_QUERY */) { - queries.push(payload.subarray(1).toString("utf-8")); - socket.write(textResultSet(seq + 1, shortText, jsonText)); - } else if (payload[0] === 0x01 /* COM_QUIT */) { - socket.end(); - } else { - // A zero-length packet or one that does not start with a known - // command byte means the client's outgoing stream is no longer - // aligned on packet boundaries. Destroy the socket so the test - // fails fast instead of hanging. - desynced = true; - socket.destroy(); - } - } - }); - socket.on("error", () => {}); - }); - server.listen(0, "127.0.0.1"); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - - // 1 command byte + 0xffffff bytes of query text = 0x1000000 — one past - // the largest payload a single MySQL packet can frame. - const oversized = Buffer.alloc(0xffffff, "-").toString(); - const first = await sql.unsafe(oversized).then( - () => "resolved", - e => e?.code ?? String(e), - ); - - // The same connection must still be usable: the rejected packet must not - // leave any bytes behind in the write buffer. - const second = await sql.unsafe("select 1").then( - () => "resolved", - e => e?.code ?? String(e), - ); - - expect({ first, second, queries, desynced }).toEqual({ - first: "ERR_MYSQL_OVERFLOW", - second: "resolved", - queries: ["select 1"], - desynced: false, - }); - } finally { - await new Promise(r => server.close(() => r())); - } -}); - -// --- 251-byte length-encoded values vs. the NULL marker --------------------- -// -// The text-protocol NULL marker is the single literal byte 0xfb. A column -// value that is exactly 251 bytes long is length-encoded as `0xfc 0xfb 0x00` -// followed by 251 payload bytes — and the decoded *length* is also 251 -// (0xfb). The decoder must distinguish the two by encoding width: if it only -// compares the decoded value, the column is misread as NULL, only the 3 -// length bytes are consumed, and the 251 payload bytes are re-parsed as the -// lengths/contents of the following columns. Whoever controls the first -// column then controls what the application sees in the rest of the row. - // The first bytes of the 251-byte payload deliberately form a valid // length-encoded string ("admin") so a desynchronized decoder would surface // it as the *next* column's value instead of "user". const bio251 = "\x05admin" + Buffer.alloc(251 - 6, "x").toString(); const realRole = "user"; -function textResultSet251(startSeq: number): Buffer { - const packets: Buffer[] = []; - let seq = startSeq; - - // Column count - packets.push(packet(seq++, Buffer.from([0x02]))); - packets.push(packet(seq++, columnDefinition("bio", MYSQL_TYPE_VAR_STRING))); - packets.push(packet(seq++, columnDefinition("role", MYSQL_TYPE_VAR_STRING))); - // Row 1: exactly-251-byte bio (3-byte lenenc prefix 0xfc 0xfb 0x00), then - // role = "user". - packets.push(packet(seq++, Buffer.concat([lenencStr(bio251), lenencStr(realRole)]))); - // Row 2: a genuine NULL bio (the single marker byte 0xfb), then - // role = "editor" — the legitimate NULL case must keep working. - packets.push(packet(seq++, Buffer.concat([Buffer.from([0xfb]), lenencStr("editor")]))); - // OK packet to close the result set (with CLIENT_DEPRECATE_EOF, header 0xfe). - packets.push(packet(seq++, Buffer.from([0xfe, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]))); - - return Buffer.concat(packets); -} - -function startMock251Server() { - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - - socket.write(handshakeV10()); +if (isDockerEnabled()) { + describeWithContainer("mysql", { image: "mysql_plain" }, container => { + function url() { + return `mysql://root@${container.host}:${container.port}/bun_sql_test`; + } + + // --- .raw() length-prefix stripping (#30039) ----------------------------- + + function assertRawRow(name: unknown, post: unknown, blob: unknown) { + expect(name).toBeInstanceOf(Uint8Array); + expect(post).toBeInstanceOf(Uint8Array); + expect(blob).toBeInstanceOf(Uint8Array); + + // Defining assertion: first byte is the payload's first byte + // ('t' = 0x74 for the VARCHAR, '{' = 0x7b for the JSON/BLOB), NOT the + // MySQL length-encoded-integer prefix (0x08 for the 8-byte VARCHAR, + // 0xfc for the >251-byte JSON/BLOB). + expect((name as Uint8Array)[0]).toBe(0x74); // 't' + expect((post as Uint8Array)[0]).toBe(0x7b); // '{' + expect((blob as Uint8Array)[0]).toBe(0x7b); // '{' + + // VARCHAR / BLOB round-trip byte-exact. + expect(Buffer.from(name as Uint8Array).toString("utf-8")).toBe(shortText); + expect((name as Uint8Array).length).toBe(shortText.length); + expect(Buffer.from(blob as Uint8Array).toString("utf-8")).toBe(jsonText); + expect((blob as Uint8Array).length).toBe(jsonText.length); + + // MySQL normalizes stored JSON (adds spaces after ':' and ','), so + // compare parsed values. With the prefix bug present the leading 0xfc + // byte makes JSON.parse throw, so this still discriminates. + const postText = Buffer.from(post as Uint8Array).toString("utf-8"); + expect(JSON.parse(postText)).toEqual(jsonPayload); + // Normalized JSON is at least as long as the compact form, so the wire + // encoding still uses the 3-byte (0xfc) length prefix. + expect((post as Uint8Array).length).toBeGreaterThanOrEqual(jsonText.length); + } + + test(".raw() strips length-prefix bytes (#30039) — text protocol", async () => { + await container.ready; + await using sql = new SQL({ url: url(), max: 1 }); + const table = "t_rawlen_" + randomUUIDv7("hex").replaceAll("-", ""); + try { + await sql`CREATE TEMPORARY TABLE ${sql(table)} (name VARCHAR(64), post JSON, blob_data BLOB)`; + await sql`INSERT INTO ${sql(table)} (name, post, blob_data) VALUES (${shortText}, ${jsonText}, ${Buffer.from(jsonText)})`; + + // `.simple().raw()` exercises the ResultSet text-protocol raw branch + // that used to call rawEncodeLenData. + const rows = (await sql`SELECT name, post, blob_data FROM ${sql(table)}`.simple().raw()) as unknown as [ + Uint8Array, + Uint8Array, + Uint8Array, + ][]; + expect(rows).toHaveLength(1); + const [name, post, blob] = rows[0]; + assertRawRow(name, post, blob); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(table)}`; + } + }); - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); + test(".raw() strips length-prefix bytes (#30039) — binary protocol", async () => { + await container.ready; + await using sql = new SQL({ url: url(), max: 1 }); + const table = "t_rawlen_" + randomUUIDv7("hex").replaceAll("-", ""); + try { + await sql`CREATE TEMPORARY TABLE ${sql(table)} (name VARCHAR(64), post JSON, blob_data BLOB)`; + await sql`INSERT INTO ${sql(table)} (name, post, blob_data) VALUES (${shortText}, ${jsonText}, ${Buffer.from(jsonText)})`; + + // Without `.simple()`, the client uses a prepared statement and the + // binary-protocol row decoder — exercising the DecodeBinaryValue raw + // branches that used to call rawEncodeLenData for VAR_STRING / JSON / + // BLOB. + const rows = (await sql`SELECT name, post, blob_data FROM ${sql(table)}`.raw()) as unknown as [ + Uint8Array, + Uint8Array, + Uint8Array, + ][]; + expect(rows).toHaveLength(1); + const [name, post, blob] = rows[0]; + assertRawRow(name, post, blob); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(table)}`; + } + }); - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } + // --- oversized COM_QUERY rollback ---------------------------------------- + // + // A COM_QUERY whose payload exceeds the 24-bit packet length limit cannot + // be framed as a single MySQL packet. It must be rejected client-side AND + // rolled back out of the connection's write buffer: leaving the partially- + // serialized packet behind desynchronizes the protocol stream, and the next + // query gets appended after the garbage and reparsed by the server as + // bogus packets. + test("oversized COM_QUERY is rejected and rolled back out of the write buffer", async () => { + await container.ready; + await using sql = new SQL({ url: url(), max: 1 }); + + // Ensure the single pooled connection is established before the oversized + // attempt so both queries share it, and capture its server-side + // CONNECTION_ID() so we can prove the follow-up runs on the SAME session. + // A regression that flushed partial bytes (or closed on overflow) would + // let the pool transparently reconnect and `select 1 as ok` would succeed + // on a new session without the write-buffer rollback having worked. + const [{ cid: cidBefore }] = await sql`SELECT CONNECTION_ID() as cid`; + + // 1 command byte + 0xffffff bytes of query text = 0x1000000 — one past + // the largest payload a single MySQL packet can frame. + const oversized = Buffer.alloc(0xffffff, "-").toString(); + const first = await sql.unsafe(oversized).then( + () => "resolved", + e => e?.code ?? String(e), + ); + + // The same connection must still be usable: the rejected packet must not + // leave any bytes behind in the write buffer. If it did, the server would + // see garbage instead of `select 1` and this query would not resolve to + // the expected row. + const second = await sql.unsafe("select 1 as ok"); + const [{ cid: cidAfter }] = await sql`SELECT CONNECTION_ID() as cid`; + + expect({ first, second, cidAfter }).toEqual({ + first: "ERR_MYSQL_OVERFLOW", + second: [{ ok: 1 }], + // Same MySQL session ⇒ rollback worked, no reconnect masked the failure. + cidAfter: cidBefore, + }); + }); - const cmd = payload[0]; - if (cmd === 0x03 /* COM_QUERY */) { - socket.write(textResultSet251(seq + 1)); - } else { - // COM_QUIT / anything else — close. - socket.end(); - } + // --- 251-byte length-encoded values vs. the NULL marker ------------------ + // + // The text-protocol NULL marker is the single literal byte 0xfb. A column + // value that is exactly 251 bytes long is length-encoded as + // `0xfc 0xfb 0x00` followed by 251 payload bytes — and the decoded + // *length* is also 251 (0xfb). The decoder must distinguish the two by + // encoding width: if it only compares the decoded value, the column is + // misread as NULL, only the 3 length bytes are consumed, and the 251 + // payload bytes are re-parsed as the lengths/contents of the following + // columns. Whoever controls the first column then controls what the + // application sees in the rest of the row. + test("text protocol decodes a 251-byte column value as data, not as NULL", async () => { + // Sanity: the payload is exactly 251 bytes — the length whose lenenc + // encoding is `0xfc 0xfb 0x00` and whose decoded value collides with the + // text-protocol NULL marker byte (0xfb). + expect(Buffer.byteLength(bio251, "utf-8")).toBe(251); + + await container.ready; + await using sql = new SQL({ url: url(), max: 1 }); + const table = "t_null251_" + randomUUIDv7("hex").replaceAll("-", ""); + try { + await sql`CREATE TEMPORARY TABLE ${sql(table)} (id INT PRIMARY KEY, bio VARCHAR(300), role VARCHAR(32))`; + await sql`INSERT INTO ${sql(table)} (id, bio, role) VALUES (1, ${bio251}, ${realRole}), (2, NULL, ${"editor"})`; + + // `.simple()` forces the text protocol → ResultSet decode_text, where + // the NULL-marker check lives. + const rows = (await sql`SELECT bio, role FROM ${sql(table)} ORDER BY id`.simple()) as unknown as { + bio: string | null; + role: string; + }[]; + expect(rows).toHaveLength(2); + // The 251-byte value must come back intact — not as NULL with the + // following column re-read out of the 251 payload bytes (which would + // make role === "admin"). + expect(rows[0].role).toBe(realRole); + expect(rows[0].bio).toBe(bio251); + // A genuine NULL still decodes as NULL and the row stays aligned. + expect(rows[1].bio).toBeNull(); + expect(rows[1].role).toBe("editor"); + } finally { + await sql`DROP TABLE IF EXISTS ${sql(table)}`; } }); }); - server.listen(0, "127.0.0.1"); - return server; } - -test("text protocol decodes a 251-byte column value as data, not as NULL", async () => { - // Sanity: the payload is exactly 251 bytes and 251 really is the 3-byte - // lenenc form whose decoded value collides with the NULL marker byte. - expect(Buffer.byteLength(bio251, "utf-8")).toBe(251); - expect(Array.from(lenenc(251))).toEqual([0xfc, 0xfb, 0x00]); - - const server = startMock251Server(); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - // `.simple()` forces the text protocol → ResultSet decode_text, where the - // NULL-marker check lives. - const rows = (await sql`SELECT bio, role FROM users`.simple()) as unknown as { - bio: string | null; - role: string; - }[]; - expect(rows).toHaveLength(2); - // The 251-byte value must come back intact — not as NULL with the - // following column re-read out of the 251 payload bytes (which would - // make role === "admin"). - expect(rows[0].role).toBe(realRole); - expect(rows[0].bio).toBe(bio251); - // A genuine NULL marker (single 0xfb byte) still decodes as NULL and the - // row stays aligned. - expect(rows[1].bio).toBeNull(); - expect(rows[1].role).toBe("editor"); - } finally { - await new Promise(r => server.close(() => r())); - } -}); diff --git a/test/js/sql/sql-mysql-tls-plaintext-injection.test.ts b/test/js/sql/sql-mysql-tls-plaintext-injection.test.ts index c3185c15b5cb..0db2d629bfd7 100644 --- a/test/js/sql/sql-mysql-tls-plaintext-injection.test.ts +++ b/test/js/sql/sql-mysql-tls-plaintext-injection.test.ts @@ -1,8 +1,18 @@ -// Uses a minimal mock MySQL server so it can run without Docker. +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. import { SQL } from "bun"; import { expect, mock, test } from "bun:test"; -import net from "net"; +import { + listeningServer, + MYSQL_CLIENT_SSL, + MYSQL_DEFAULT_CAPABILITIES, + mysqlHandshakeV10, + mysqlOkPacket, +} from "./wire-frames"; test("MySQL TLS handshake rejects plaintext packets buffered behind the server greeting", async () => { // A man-in-the-middle can append forged packets (e.g. an OK packet that marks @@ -10,60 +20,17 @@ test("MySQL TLS handshake rejects plaintext packets buffered behind the server g // greeting. Once the handshake negotiates TLS, everything after the greeting // must arrive over the encrypted channel; bytes already buffered in plaintext // must not be fed to the auth/command handlers. - function u16le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); - } - function u24le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); - } - function u32le(n: number) { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); - } - function packet(seq: number, payload: Buffer) { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); - } - - const CLIENT_PROTOCOL_41 = 1 << 9; - const CLIENT_SSL = 1 << 11; - const CLIENT_SECURE_CONNECTION = 1 << 15; - const CLIENT_PLUGIN_AUTH = 1 << 19; - const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; - const CLIENT_DEPRECATE_EOF = 1 << 24; - const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SSL | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - const greeting = packet( - 0, - Buffer.concat([ - Buffer.from([10]), // protocol version - Buffer.from("mock-8.0.0\0"), // server version, NUL-terminated - u32le(1), // connection id - authData1, // auth-plugin-data-part-1 (8 bytes) - Buffer.from([0]), // filler - u16le(SERVER_CAPS & 0xffff), // capability flags (lower) - Buffer.from([0x2d]), // character set - u16le(0x0002), // status flags (SERVER_STATUS_AUTOCOMMIT) - u16le((SERVER_CAPS >>> 16) & 0xffff), // capability flags (upper) - Buffer.from([21]), // auth-plugin-data length - Buffer.alloc(10, 0), // reserved - authData2, // auth-plugin-data-part-2 (13 bytes) - Buffer.from("caching_sha2_password\0"), - ]), - ); + const greeting = mysqlHandshakeV10({ + serverVersion: "mock-8.0.0", + authPlugin: "caching_sha2_password", + capabilities: MYSQL_DEFAULT_CAPABILITIES | MYSQL_CLIENT_SSL, + }); // A forged OK packet. If the client keeps consuming the plaintext buffer // after deciding to upgrade to TLS, this marks the connection as // authenticated without any certificate ever being validated. - const forgedOk = packet(2, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); + const forgedOk = mysqlOkPacket(2); - const server = net.createServer(socket => { + const { port, server } = await listeningServer(socket => { // Greeting and the injected packet arrive in a single segment, before the // client has sent a byte. socket.write(Buffer.concat([greeting, forgedOk])); @@ -72,8 +39,6 @@ test("MySQL TLS handshake rejects plaintext packets buffered behind the server g socket.on("data", () => socket.end()); socket.on("error", () => {}); }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const { port } = server.address() as import("node:net").AddressInfo; const onconnect = mock(); try { diff --git a/test/js/sql/sql-mysql.test.ts b/test/js/sql/sql-mysql.test.ts index 25c13f49e539..4a3e160cb19b 100644 --- a/test/js/sql/sql-mysql.test.ts +++ b/test/js/sql/sql-mysql.test.ts @@ -1,9 +1,8 @@ import { SQL, randomUUIDv7 } from "bun"; import { beforeAll, describe, expect, mock, test } from "bun:test"; -import { once } from "events"; import { bunEnv, bunRun, describeWithContainer, isDockerEnabled, tempDirWithFiles } from "harness"; -import net from "net"; import path from "path"; +import { listeningServer } from "./wire-frames"; const dir = tempDirWithFiles("sql-test", { "select-param.sql": `select ? as x`, "select.sql": `select CAST(1 AS SIGNED) as x`, @@ -13,8 +12,7 @@ function rel(filename: string) { } // Assertions for the NEWDECIMAL decoder against a real server, used by the -// docker-backed suite below. (The non-docker mock-server suite at the end of -// this file drives a single canned column, so it asserts inline instead.) +// docker-backed suite below. // // MySQL reports computed/aggregate NEWDECIMAL columns (SUM/AVG/CAST/arithmetic/ // ROUND/literals, and SUM of an INT column) with the BINARY flag and charset @@ -1159,13 +1157,16 @@ if (isDockerEnabled()) { sql.flush(); }); + // Fault-injection test: requires a server that refuses / drops / sends malformed + // frames, which a healthy container will not do on demand. DO NOT COPY THIS + // PATTERN — anything a real server can produce belongs in describeWithContainer. + // All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline + // Buffer.alloc frame construction here. describe("timeouts", () => { test.each(["connect_timeout", "connectTimeout", "connectionTimeout", "connection_timeout"] as const)( "connection timeout key %p throws", async key => { - const server = net.createServer().listen(); - - const port = (server.address() as import("node:net").AddressInfo).port; + const { server, port } = await listeningServer(() => {}); const sql = new SQL({ adapter: "mysql", port, host: "127.0.0.1", max: 1, [key]: 0.2 }); @@ -1193,191 +1194,3 @@ if (isDockerEnabled()) { ); } } - -// The docker-backed suite above only runs where a docker daemon is available. -// The NEWDECIMAL decode path does not need a real server to exercise, though: -// the bug is a misclassification driven entirely by the column's wire metadata -// (NEWDECIMAL + BINARY flag + charset 63). A minimal mock MySQL server can reply -// with exactly that column so the decoder is exercised offline, with no docker -// and no external database. This runs everywhere. -describe("NEWDECIMAL decodes as a string (mock server, no docker)", () => { - const MYSQL_TYPE_NEWDECIMAL = 0xf6; - const BINARY_FLAG = 1 << 7; // ColumnFlags::BINARY - const BINARY_CHARSET = 63; // the "binary" pseudo-charset - // The exact wire metadata MySQL attaches to a computed/aggregate DECIMAL - // (SUM/AVG/CAST/arithmetic/ROUND/literal). Without the NEWDECIMAL special-case - // in the decoder, `BINARY_FLAG && charset == 63` routes this to a Buffer. - const DECIMAL_VALUE = "350.75"; - - function u16le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff]); - } - function u24le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff]); - } - function u32le(n: number): Buffer { - return Buffer.from([n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >>> 24) & 0xff]); - } - function packet(seq: number, payload: Buffer): Buffer { - return Buffer.concat([u24le(payload.length), Buffer.from([seq]), payload]); - } - function lenenc(n: number): Buffer { - if (n < 0xfb) return Buffer.from([n]); - if (n < 0xffff) return Buffer.concat([Buffer.from([0xfc]), u16le(n)]); - throw new Error("lenenc: not needed for this test"); - } - function lenencStr(s: string): Buffer { - const buf = Buffer.from(s, "utf-8"); - return Buffer.concat([lenenc(buf.length), buf]); - } - - const CLIENT_PROTOCOL_41 = 1 << 9; - const CLIENT_SECURE_CONNECTION = 1 << 15; - const CLIENT_PLUGIN_AUTH = 1 << 19; - const CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; - const CLIENT_DEPRECATE_EOF = 1 << 24; - const SERVER_CAPS = - CLIENT_PROTOCOL_41 | - CLIENT_SECURE_CONNECTION | - CLIENT_PLUGIN_AUTH | - CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | - CLIENT_DEPRECATE_EOF; - - function handshakeV10(): Buffer { - const authData1 = Buffer.alloc(8, 0x61); - const authData2 = Buffer.alloc(13, 0x62); - authData2[12] = 0; - return packet( - 0, - Buffer.concat([ - Buffer.from([10]), - Buffer.from("mock-5.7.0\0"), - u32le(1), - authData1, - Buffer.from([0]), - u16le(SERVER_CAPS & 0xffff), - Buffer.from([0x2d]), - u16le(0x0002), - u16le((SERVER_CAPS >>> 16) & 0xffff), - Buffer.from([21]), - Buffer.alloc(10, 0), - authData2, - Buffer.from("mysql_native_password\0"), - ]), - ); - } - function okPacket(seq: number, header = 0x00): Buffer { - return packet(seq, Buffer.from([header, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); - } - // NEWDECIMAL column carrying the BINARY flag and the binary charset — the - // metadata computed decimals arrive with. - function decimalColumn(): Buffer { - return Buffer.concat([ - lenencStr("def"), - lenencStr(""), - lenencStr("t"), - lenencStr("t"), - lenencStr("total"), - lenencStr("total"), - Buffer.from([0x0c]), - u16le(BINARY_CHARSET), - u32le(1024), - Buffer.from([MYSQL_TYPE_NEWDECIMAL]), - u16le(BINARY_FLAG), - Buffer.from([2]), // decimals - Buffer.from([0, 0]), - ]); - } - function stmtPrepareOK(startSeq: number, stmtId: number): Buffer { - let seq = startSeq; - return Buffer.concat([ - packet( - seq++, - Buffer.concat([Buffer.from([0x00]), u32le(stmtId), u16le(1), u16le(0), Buffer.from([0x00]), u16le(0)]), - ), - packet(seq++, decimalColumn()), - ]); - } - // Binary result row: 0x00 header, 1-byte NULL bitmap (nothing null), then the - // value as a length-encoded string (how NEWDECIMAL is framed on the wire). - function binaryResultSet(startSeq: number): Buffer { - let seq = startSeq; - return Buffer.concat([ - packet(seq++, Buffer.from([1])), - packet(seq++, decimalColumn()), - packet(seq++, Buffer.concat([Buffer.from([0x00]), Buffer.from([0x00]), lenencStr(DECIMAL_VALUE)])), - okPacket(seq++, 0xfe), - ]); - } - // Text result row: the value is a single length-encoded string. - function textResultSet(startSeq: number): Buffer { - let seq = startSeq; - return Buffer.concat([ - packet(seq++, Buffer.from([1])), - packet(seq++, decimalColumn()), - packet(seq++, lenencStr(DECIMAL_VALUE)), - okPacket(seq++, 0xfe), - ]); - } - - function startMockServer(): net.Server { - const server = net.createServer(socket => { - let buffered = Buffer.alloc(0); - let authed = false; - let stmtId = 0; - socket.write(handshakeV10()); - socket.on("data", chunk => { - buffered = Buffer.concat([buffered, chunk]); - while (buffered.length >= 4) { - const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); - if (buffered.length < 4 + len) break; - const seq = buffered[3]; - const payload = buffered.subarray(4, 4 + len); - buffered = buffered.subarray(4 + len); - if (!authed) { - authed = true; - socket.write(okPacket(seq + 1)); - continue; - } - const cmd = payload[0]; - if (cmd === 0x16 /* COM_STMT_PREPARE */) { - socket.write(stmtPrepareOK(seq + 1, ++stmtId)); - } else if (cmd === 0x17 /* COM_STMT_EXECUTE */) { - socket.write(binaryResultSet(seq + 1)); - } else if (cmd === 0x03 /* COM_QUERY */) { - socket.write(textResultSet(seq + 1)); - } else if (cmd === 0x19 /* COM_STMT_CLOSE */) { - // no response expected - } else { - socket.end(); - } - } - }); - }); - server.listen(0, "127.0.0.1"); - return server; - } - - test("computed DECIMAL columns return strings, not Buffers", async () => { - const server = startMockServer(); - await once(server, "listening"); - const { port } = server.address() as net.AddressInfo; - try { - await using sql = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); - - // Binary protocol (prepared statement). - const [row] = await sql`SELECT SUM(balance) AS total FROM t`; - expect(row).toEqual({ total: DECIMAL_VALUE }); - - // Text protocol (`.simple()`) must decode the same way. - const [simpleRow] = await sql`SELECT SUM(balance) AS total FROM t`.simple(); - expect(simpleRow).toEqual({ total: DECIMAL_VALUE }); - - // `.raw()` must still return the raw bytes. - const [rawRow] = await sql`SELECT SUM(balance) AS total FROM t`.raw(); - expect(rawRow[0]).toEqual(new Uint8Array(Buffer.from(DECIMAL_VALUE))); - } finally { - await new Promise(r => server.close(() => r())); - } - }); -}); diff --git a/test/js/sql/sql-onconnect-onclose-throw.test.ts b/test/js/sql/sql-onconnect-onclose-throw.test.ts index 790f5cd9d307..994eca458247 100644 --- a/test/js/sql/sql-onconnect-onclose-throw.test.ts +++ b/test/js/sql/sql-onconnect-onclose-throw.test.ts @@ -14,6 +14,13 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, describeWithContainer, isDockerEnabled, tempDir } from "harness"; +import path from "node:path"; + +// Fixtures that need closedPort() / neverAnsweringServer() run them in the +// spawned subprocess (not the test process) by importing ./wire-frames via +// this absolute path, so the bind→close→connect window is not widened by +// the subprocess spawn. +const wireFramesPath = path.join(import.meta.dir, "wire-frames.ts"); async function runFixture(code: string, env: Record = {}) { using dir = tempDir("sql-throwing-hooks", { "fixture.ts": code }); @@ -121,29 +128,24 @@ console.log("rejected:" + (err?.code ?? err?.name ?? String(err))); }); } +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // A port with nothing listening on it, so the connection is refused. Refused // connections fail fast (not retried), so the throwing onclose fires on the -// first attempt; without the fix the pending query is never rejected. -const closedPort = /* ts */ ` -const net = require("net"); -function closedPort() { - return new Promise(resolve => { - const server = net.createServer(); - server.listen(0, "127.0.0.1", () => { - const port = server.address().port; - server.close(() => resolve(port)); - }); - }); -} -`; - +// first attempt; without the fix the pending query is never rejected. The +// fixture allocates the closed port itself (same as forcedCloseFixture below) +// so the bind→close→connect window is not widened by the subprocess spawn, +// during which the concurrent forcedCloseFixture tests are issuing bind(0). function refusedConnectionFixture(adapter: "postgres" | "mysql") { const url = adapter === "postgres" ? "postgres://postgres@127.0.0.1:" : "mysql://root@127.0.0.1:"; const db = adapter === "postgres" ? "/postgres" : "/db"; - return ( - closedPort + - /* ts */ ` + return /* ts */ ` import { SQL } from "bun"; +import { closedPort } from ${JSON.stringify(wireFramesPath)}; process.on("uncaughtException", err => console.log("uncaught:", err.message)); const port = await closedPort(); const sql = new SQL({ @@ -161,31 +163,22 @@ try { console.log("query rejected:", err.code); } process.exit(0); -` - ); +`; } -test.concurrent( - "postgres: a throwing onclose callback still rejects pending queries when the connection is refused", - async () => { - const { stdout, exitCode } = await runFixture(refusedConnectionFixture("postgres")); - expect(stdout).toBe( - "onclose: ERR_POSTGRES_CONNECTION_REFUSED\nuncaught: boom from onclose\nquery rejected: ERR_POSTGRES_CONNECTION_REFUSED\n", - ); - expect(exitCode).toBe(0); - }, -); - -test.concurrent( - "mysql: a throwing onclose callback still rejects pending queries when the connection is refused", - async () => { - const { stdout, exitCode } = await runFixture(refusedConnectionFixture("mysql")); - expect(stdout).toBe( - "onclose: ERR_MYSQL_CONNECTION_REFUSED\nuncaught: boom from onclose\nquery rejected: ERR_MYSQL_CONNECTION_REFUSED\n", - ); - expect(exitCode).toBe(0); - }, -); +for (const [adapter, refusedCode] of [ + ["postgres", "ERR_POSTGRES_CONNECTION_REFUSED"], + ["mysql", "ERR_MYSQL_CONNECTION_REFUSED"], +] as const) { + test.concurrent( + `${adapter}: a throwing onclose callback still rejects pending queries when the connection is refused`, + async () => { + const { stdout, exitCode } = await runFixture(refusedConnectionFixture(adapter)); + expect(stdout).toBe(`onclose: ${refusedCode}\nuncaught: boom from onclose\nquery rejected: ${refusedCode}\n`); + expect(exitCode).toBe(0); + }, + ); +} // When createConnection fails synchronously (here: a password function that // throws), onclose used to be invoked while the adapter was still filling @@ -229,6 +222,12 @@ process.exit(0); expect(exitCode).toBe(0); }); +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // The forced-close path (#32095) and the throwing-callback path (#32037) meet // in the pool connection's close handler: the user's onclose runs first and // may throw, and the bookkeeping that follows it must still settle the @@ -236,30 +235,14 @@ process.exit(0); // never answers keeps the connection mid-handshake, and connectionTimeout: 0 // disables the connect timer, so close() is the only teardown path; if the // throw skipped the bookkeeping these fixtures would never print "closed". -const neverAnsweringServer = /* ts */ ` -const net = require("net"); -function neverAnsweringServer() { - return new Promise(resolveListening => { - const first = Promise.withResolvers(); - const server = net.createServer(socket => { - socket.unref(); - first.resolve(); - }); - server.unref(); - server.listen(0, "127.0.0.1", () => { - resolveListening({ port: server.address().port, accepted: first.promise }); - }); - }); -} -`; - +// The mock server lives in the fixture process (it must observe `accepted` +// before forcing close) and is imported from ./wire-frames by absolute path. function forcedCloseFixture(adapter: "postgres" | "mysql") { const url = adapter === "postgres" ? "postgres://postgres@127.0.0.1:" : "mysql://root@127.0.0.1:"; const db = adapter === "postgres" ? "/postgres" : "/db"; - return ( - neverAnsweringServer + - /* ts */ ` + return /* ts */ ` import { SQL } from "bun"; +import { neverAnsweringServer } from ${JSON.stringify(wireFramesPath)}; process.on("uncaughtException", err => console.log("uncaught:", err.message)); const { port, accepted } = await neverAnsweringServer(); const sql = new SQL({ @@ -277,8 +260,7 @@ await sql.close({ timeout: "0" }); console.log("closed"); console.log("query rejected:", (await queryError).code); process.exit(0); -` - ); +`; } for (const [adapter, closedCode] of [ diff --git a/test/js/sql/sql-postgres-json-array-bool-literal.fixture.ts b/test/js/sql/sql-postgres-json-array-bool-literal.fixture.ts new file mode 100644 index 000000000000..5aa9ab36681c --- /dev/null +++ b/test/js/sql/sql-postgres-json-array-bool-literal.fixture.ts @@ -0,0 +1,62 @@ +// Fault-injection fixture for sql.test.ts: a hostile Postgres server emits a +// text-format json[] DataRow whose array literal contains an unquoted element +// starting with 'f' or 't' that is not exactly "false"/"true". A real Postgres +// will not produce this. All wire-protocol bytes come from ./wire-frames. + +import { SQL } from "bun"; +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgReadyForQuery, + pgRowDescription, +} from "./wire-frames"; + +// Single column "x" of type json[] (oid 199), format 0 (text). +const rowDescription = pgRowDescription([{ name: "x", typeOid: 199, format: 0 }]); + +async function run(arrayText: string) { + const { server, port } = await listeningServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + socket.write( + Buffer.concat([ + rowDescription, + pgDataRow([Buffer.from(arrayText)]), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + socket.on("error", () => {}); + }); + const sql = new SQL({ + url: "postgres://u@127.0.0.1:" + port + "/db", + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + try { + const rows = await sql`select x`.simple(); + console.log("ROWS " + arrayText + " => " + JSON.stringify(rows[0] && rows[0].x)); + } catch (e: any) { + console.log("REJECTED " + arrayText + " => " + (e.code || e.message)); + } finally { + await sql.close().catch(() => {}); + await new Promise(r => server.close(() => r())); + } +} + +// Malformed boolean literals: must error, not spin forever. +await run("{falsy}"); +await run("{truthy}"); +// Well-formed booleans in a json[] must still parse. +await run("{false,true}"); +console.log("FIXTURE_DONE"); diff --git a/test/js/sql/sql-postgres-short-data-row.fixture.ts b/test/js/sql/sql-postgres-short-data-row.fixture.ts new file mode 100644 index 000000000000..aee4e8454c6e --- /dev/null +++ b/test/js/sql/sql-postgres-short-data-row.fixture.ts @@ -0,0 +1,71 @@ +// Fault-injection fixture for sql.test.ts: a hostile Postgres server emits a +// RowDescription declaring 62 columns followed by a DataRow declaring zero of +// them. A real Postgres will not produce this. All wire-protocol bytes come +// from ./wire-frames. + +import { SQL } from "bun"; +import { + listeningServer, + pgAuthenticationOk, + pgCommandComplete, + pgDataRow, + pgReadyForQuery, + pgRowDescription, +} from "./wire-frames"; + +// 62 text columns (oid 25, format 0) that all share the same name "c", so the +// cached row Structure has a single property and the other 61 fields are +// duplicates. +const COLUMNS = 62; +const rowDescription = pgRowDescription( + Array.from({ length: COLUMNS }, () => ({ name: "c", typeOid: 25, format: 0 as const })), +); + +async function run(label: string, rowValues: string[]) { + const { server, port } = await listeningServer(socket => { + let startup = true; + socket.on("data", data => { + if (startup) { + startup = false; + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + return; + } + if (data[0] !== 0x51 /* 'Q' */) return; + socket.write( + Buffer.concat([ + rowDescription, + pgDataRow(rowValues.map(v => Buffer.from(v))), + pgCommandComplete("SELECT 1"), + pgReadyForQuery(), + ]), + ); + }); + socket.on("error", () => {}); + }); + const sql = new SQL({ + url: "postgres://u@127.0.0.1:" + port + "/db", + max: 1, + idleTimeout: 5, + connectionTimeout: 5, + }); + try { + const rows = await sql`select c`.simple(); + console.log(label + " " + JSON.stringify(rows[0])); + } catch (e: any) { + console.log(label + "_ERROR " + (e.code || e.message)); + } finally { + await sql.close().catch(() => {}); + await new Promise(r => server.close(() => r())); + } +} + +// The DataRow declares zero of the 62 described columns: the row's single +// named property must come back as null and nothing else may be written. +await run("EMPTY_ROW", []); +// A DataRow that supplies all 62 declared columns still resolves the duplicate +// column name following the established "last one wins" rule. +await run( + "FULL_ROW", + Array.from({ length: COLUMNS }, (_, i) => "v" + i), +); +console.log("FIXTURE_DONE"); diff --git a/test/js/sql/sql.test.ts b/test/js/sql/sql.test.ts index 890229ea1d68..4428b464c513 100644 --- a/test/js/sql/sql.test.ts +++ b/test/js/sql/sql.test.ts @@ -1,7 +1,6 @@ import { $, randomUUIDv7, sql, SQL } from "bun"; import { afterAll, describe, expect, mock, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isCI, isDockerEnabled, tempDirWithFiles } from "harness"; -import * as net from "node:net"; import path from "path"; const postgres = (...args) => new SQL(...args); @@ -16,6 +15,7 @@ function rel(filename: string) { // Use docker-compose infrastructure import * as dockerCompose from "../../docker/index.ts"; import { UnixDomainSocketProxy } from "../../unix-domain-socket-proxy.ts"; +import { neverAnsweringServer } from "./wire-frames"; if (isDockerEnabled()) { describe("PostgreSQL tests", async () => { @@ -2952,12 +2952,15 @@ if (isDockerEnabled()) { // ] // }) + // Fault-injection test: requires a server that refuses / drops / sends malformed + // frames, which a healthy container will not do on demand. DO NOT COPY THIS + // PATTERN — anything a real server can produce belongs in describeWithContainer. + // All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline + // Buffer.alloc frame construction here. test.each(["connect_timeout", "connectTimeout", "connectionTimeout", "connection_timeout"] as const)( "connection timeout key %p throws", async key => { - const server = net.createServer().listen(); - - const port = (server.address() as import("node:net").AddressInfo).port; + const { port, server } = await neverAnsweringServer(); const sql = postgres({ port, host: "127.0.0.1", [key]: 0.2 }); @@ -12501,9 +12504,71 @@ CREATE TABLE ${table_name} ( expect(e.message).toContain("65535"); } }); + // A simple-query response can contain several result sets. The first one + // here has zero columns (a real zero-column Postgres table), which caches a + // zero-property row Structure when its DataRow is materialized. The next + // RowDescription widens the field list to three named columns; the cached + // structure must be invalidated so the second result set's rows are built + // with the new column layout instead of writing the new cells past the + // inline capacity of an empty object. Runs in a subprocess because a + // regression corrupts the JS heap of the process that parses the response. + test("result set following a zero-column result set uses its own column layout", async () => { + const tableName = `t_${randomUUIDv7("hex").replaceAll("-", "")}`; + const fixtureDir = tempDirWithFiles("pg-zero-column-then-wide", { + "fixture.ts": ` +import { SQL } from "bun"; + +const sql = new SQL({ url: process.env.DATABASE_URL!, max: 1, idleTimeout: 5, connectionTimeout: 5 }); +try { + await sql\`CREATE TEMPORARY TABLE ${tableName} ()\`.simple(); + await sql\`INSERT INTO ${tableName} DEFAULT VALUES\`.simple(); + // First result set: zero columns, one row. Second result set: three named + // columns, one row. + const result = await sql\`SELECT * FROM ${tableName}; SELECT '1' AS a, '2' AS b, '3' AS c\`.simple(); + console.log("SECOND_RESULT_SET " + JSON.stringify(result[1])); + console.log("SECOND_RESULT_SET_KEYS " + Object.keys(result[1][0]).sort().join(",")); +} finally { + await sql\`DROP TABLE IF EXISTS ${tableName}\`.simple().catch(() => {}); + await sql.close().catch(() => {}); +} +console.log("FIXTURE_DONE"); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + cwd: fixtureDir, + env: { ...bunEnv, DATABASE_URL: process.env.DATABASE_URL }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000, + killSignal: "SIGKILL", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const filteredStderr = stderr + .split(/\r?\n/) + .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) + .join("\n"); + + // The second result set must expose all three named columns with their + // values; reusing the zero-property structure from the first result set + // would yield a row object with no own properties. + expect(stdout).toContain('SECOND_RESULT_SET [{"a":"1","b":"2","c":"3"}]'); + expect(stdout).toContain("SECOND_RESULT_SET_KEYS a,b,c"); + expect(stdout).toContain("FIXTURE_DONE"); + expect(filteredStderr).toBe(""); + expect(exitCode).toBe(0); + }, 30_000); }); // Close "PostgreSQL tests" describe } // Close if (isDockerEnabled()) +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // A malicious or buggy Postgres server can send a text-format json[]/jsonb[] // DataRow whose array literal contains an unquoted element starting with 'f' or // 't' that is not exactly "false"/"true". The array parser must reject it; @@ -12511,79 +12576,9 @@ CREATE TABLE ${table_name} ( // blocking the JS thread. The fixture runs in a subprocess so a regression // shows up as a killed child instead of a hung test file. test("text-format json[] with a malformed boolean literal returns an error instead of looping", async () => { - const fixtureDir = tempDirWithFiles("pg-json-array-bool-literal", { - "fixture.ts": ` -import { SQL } from "bun"; -import net from "node:net"; - -function pkt(type, body) { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} -const int16 = n => { const b = Buffer.alloc(2); b.writeInt16BE(n, 0); return b; }; -const int32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n, 0); return b; }; -const cstr = s => Buffer.concat([Buffer.from(s), Buffer.from([0])]); - -// Single column "x" of type json[] (oid 199), format 0 (text). -const rowDescription = pkt("T", Buffer.concat([ - int16(1), - cstr("x"), int32(0), int16(0), int32(199), int16(-1), int32(-1), int16(0), -])); -function dataRow(text) { - const value = Buffer.from(text); - return pkt("D", Buffer.concat([int16(1), int32(value.length), value])); -} -const authenticationOk = pkt("R", int32(0)); -const readyForQuery = pkt("Z", Buffer.from("I")); -const commandComplete = pkt("C", cstr("SELECT 1")); - -async function run(arrayText) { - const server = net.createServer(socket => { - let startup = true; - socket.on("data", data => { - if (startup) { - startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); - return; - } - if (data[0] !== 0x51 /* 'Q' */) return; - socket.write(Buffer.concat([rowDescription, dataRow(arrayText), commandComplete, readyForQuery])); - }); - socket.on("error", () => {}); - }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = server.address().port; - const sql = new SQL({ - url: "postgres://u@127.0.0.1:" + port + "/db", - max: 1, - idleTimeout: 5, - connectionTimeout: 5, - }); - try { - const rows = await sql\`select x\`.simple(); - console.log("ROWS " + arrayText + " => " + JSON.stringify(rows[0] && rows[0].x)); - } catch (e) { - console.log("REJECTED " + arrayText + " => " + (e.code || e.message)); - } finally { - await sql.close().catch(() => {}); - await new Promise(r => server.close(() => r())); - } -} - -// Malformed boolean literals: must error, not spin forever. -await run("{falsy}"); -await run("{truthy}"); -// Well-formed booleans in a json[] must still parse. -await run("{false,true}"); -console.log("FIXTURE_DONE"); -`, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "fixture.ts"], - cwd: fixtureDir, + cmd: [bunExe(), path.join(import.meta.dir, "sql-postgres-json-array-bool-literal.fixture.ts")], + cwd: import.meta.dir, env: bunEnv, stdout: "pipe", stderr: "pipe", @@ -12606,123 +12601,6 @@ console.log("FIXTURE_DONE"); expect(exitCode).toBe(0); }, 30_000); -// A simple-query response can contain several result sets. The first one here -// has zero columns, which caches a zero-property row Structure when its -// DataRow is materialized. The next RowDescription widens the field list to -// three named columns; the cached structure must be invalidated so the second -// result set's rows are built with the new column layout instead of writing -// the new cells past the inline capacity of an empty object. Runs in a -// subprocess because a regression corrupts the JS heap of the process that -// parses the response. -test("result set following a zero-column result set uses its own column layout", async () => { - const fixtureDir = tempDirWithFiles("pg-zero-column-then-wide", { - "fixture.ts": ` -import { SQL } from "bun"; -import net from "node:net"; - -function pkt(type, body) { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} -const int16 = n => { const b = Buffer.alloc(2); b.writeInt16BE(n, 0); return b; }; -const int32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n, 0); return b; }; -const cstr = s => Buffer.concat([Buffer.from(s), Buffer.from([0])]); - -function rowDescription(names) { - const fields = Buffer.concat( - names.map(name => - Buffer.concat([cstr(name), int32(0), int16(0), int32(25), int16(-1), int32(-1), int16(0)]), - ), - ); - return pkt("T", Buffer.concat([int16(names.length), fields])); -} -function dataRow(values) { - const cols = Buffer.concat( - values.map(v => { - const bytes = Buffer.from(v); - return Buffer.concat([int32(bytes.length), bytes]); - }), - ); - return pkt("D", Buffer.concat([int16(values.length), cols])); -} -const authenticationOk = pkt("R", int32(0)); -const readyForQuery = pkt("Z", Buffer.from("I")); -const commandComplete = tag => pkt("C", cstr(tag)); - -const server = net.createServer(socket => { - let startup = true; - socket.on("data", data => { - if (startup) { - startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); - return; - } - if (data[0] !== 0x51 /* 'Q' */) return; - // First result set: zero columns, one row. Second result set: three named - // columns, one row. - socket.write( - Buffer.concat([ - rowDescription([]), - dataRow([]), - commandComplete("SELECT 1"), - rowDescription(["a", "b", "c"]), - dataRow(["1", "2", "3"]), - commandComplete("SELECT 1"), - readyForQuery, - ]), - ); - }); - socket.on("error", () => {}); -}); -await new Promise(r => server.listen(0, "127.0.0.1", () => r())); -const port = server.address().port; - -const sql = new SQL({ - url: "postgres://u@127.0.0.1:" + port + "/db", - max: 1, - idleTimeout: 5, - connectionTimeout: 5, -}); -try { - const result = await sql\`select; select 1 as a, 2 as b, 3 as c\`.simple(); - console.log("SECOND_RESULT_SET " + JSON.stringify(result[1])); - console.log("SECOND_RESULT_SET_KEYS " + Object.keys(result[1][0]).sort().join(",")); -} finally { - await sql.close().catch(() => {}); - await new Promise(r => server.close(() => r())); -} -console.log("FIXTURE_DONE"); -`, - }); - - await using proc = Bun.spawn({ - cmd: [bunExe(), "fixture.ts"], - cwd: fixtureDir, - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - timeout: 10_000, - killSignal: "SIGKILL", - }); - - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - const filteredStderr = stderr - .split(/\r?\n/) - .filter(l => l && !l.startsWith("WARNING: ASAN interferes")) - .join("\n"); - - // The second result set must expose all three named columns with their - // values; reusing the zero-property structure from the first result set - // would yield a row object with no own properties. - expect(stdout).toContain('SECOND_RESULT_SET [{"a":"1","b":"2","c":"3"}]'); - expect(stdout).toContain("SECOND_RESULT_SET_KEYS a,b,c"); - expect(stdout).toContain("FIXTURE_DONE"); - expect(filteredStderr).toBe(""); - expect(exitCode).toBe(0); -}, 30_000); - // Connection options are serialized into the NUL-delimited Postgres // StartupMessage as `key\0value\0`. A key or value containing a NUL byte // would be parsed by the server as additional startup parameters (an injected @@ -12834,6 +12712,12 @@ describe("shared createInstance validation (no server)", () => { }); }); +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. +// // A Postgres server controls two independent column counts: the // RowDescription's field list (which sizes the per-row cell buffer and the // cached row Structure) and each DataRow's own column count. When a DataRow @@ -12846,90 +12730,9 @@ describe("shared createInstance validation (no server)", () => { // Runs in a subprocess because a regression corrupts the JS heap of the // process that parses the response. test("data row that omits columns declared in the row description yields nulls for the missing columns", async () => { - const fixtureDir = tempDirWithFiles("pg-short-data-row", { - "fixture.ts": ` -import { SQL } from "bun"; -import net from "node:net"; - -function pkt(type, body) { - const header = Buffer.alloc(5); - header.write(type, 0); - header.writeInt32BE(body.length + 4, 1); - return Buffer.concat([header, body]); -} -const int16 = n => { const b = Buffer.alloc(2); b.writeInt16BE(n, 0); return b; }; -const int32 = n => { const b = Buffer.alloc(4); b.writeInt32BE(n, 0); return b; }; -const cstr = s => Buffer.concat([Buffer.from(s), Buffer.from([0])]); - -// 62 text columns (oid 25, format 0) that all share the same name "c", so the -// cached row Structure has a single property and the other 61 fields are -// duplicates. -const COLUMNS = 62; -const rowDescription = pkt("T", Buffer.concat([ - int16(COLUMNS), - ...Array.from({ length: COLUMNS }, () => - Buffer.concat([cstr("c"), int32(0), int16(0), int32(25), int16(-1), int32(-1), int16(0)]), - ), -])); -function dataRow(values) { - const cols = Buffer.concat( - values.map(v => { - const bytes = Buffer.from(v); - return Buffer.concat([int32(bytes.length), bytes]); - }), - ); - return pkt("D", Buffer.concat([int16(values.length), cols])); -} -const authenticationOk = pkt("R", int32(0)); -const readyForQuery = pkt("Z", Buffer.from("I")); -const commandComplete = pkt("C", cstr("SELECT 1")); - -async function run(label, rowValues) { - const server = net.createServer(socket => { - let startup = true; - socket.on("data", data => { - if (startup) { - startup = false; - socket.write(Buffer.concat([authenticationOk, readyForQuery])); - return; - } - if (data[0] !== 0x51 /* 'Q' */) return; - socket.write(Buffer.concat([rowDescription, dataRow(rowValues), commandComplete, readyForQuery])); - }); - socket.on("error", () => {}); - }); - await new Promise(r => server.listen(0, "127.0.0.1", () => r())); - const port = server.address().port; - const sql = new SQL({ - url: "postgres://u@127.0.0.1:" + port + "/db", - max: 1, - idleTimeout: 5, - connectionTimeout: 5, - }); - try { - const rows = await sql\`select c\`.simple(); - console.log(label + " " + JSON.stringify(rows[0])); - } catch (e) { - console.log(label + "_ERROR " + (e.code || e.message)); - } finally { - await sql.close().catch(() => {}); - await new Promise(r => server.close(() => r())); - } -} - -// The DataRow declares zero of the 62 described columns: the row's single -// named property must come back as null and nothing else may be written. -await run("EMPTY_ROW", []); -// A DataRow that supplies all 62 declared columns still resolves the duplicate -// column name following the established "last one wins" rule. -await run("FULL_ROW", Array.from({ length: COLUMNS }, (_, i) => "v" + i)); -console.log("FIXTURE_DONE"); -`, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "fixture.ts"], - cwd: fixtureDir, + cmd: [bunExe(), path.join(import.meta.dir, "sql-postgres-short-data-row.fixture.ts")], + cwd: import.meta.dir, env: bunEnv, stdout: "pipe", stderr: "pipe", diff --git a/test/js/sql/tls-sql.test.ts b/test/js/sql/tls-sql.test.ts index 915759bb96fe..14bd1f1680e9 100644 --- a/test/js/sql/tls-sql.test.ts +++ b/test/js/sql/tls-sql.test.ts @@ -1,8 +1,8 @@ import { SQL, randomUUIDv7 } from "bun"; import { describe, expect, test } from "bun:test"; import { describeWithContainer, isDockerEnabled } from "harness"; -import net from "node:net"; import path from "node:path"; +import { listeningServer, pgAuthenticationCleartextPassword, pgSSLRequest, pgSSLResponse } from "./wire-frames"; if (!isDockerEnabled()) { test.skip("skipping TLS SQL tests - Docker is not available", () => {}); @@ -282,7 +282,11 @@ if (!isDockerEnabled()) { ); } -// Uses a minimal mock PostgreSQL server, so it runs without Docker. +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. test("postgres client refuses protocol messages received in place of the SSLRequest answer", async () => { // Until the server answers the 8-byte SSLRequest with 'S' or 'N', the socket // is still plaintext. A peer on the network path can answer with an @@ -290,9 +294,6 @@ test("postgres client refuses protocol messages received in place of the SSLRequ // it, it writes the password onto the unencrypted socket. Only 'S'/'N' may // be accepted while the SSLRequest answer is pending. const password = "hunter2-must-not-appear-on-the-wire"; - const sslRequest = [0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f]; - // AuthenticationCleartextPassword: 'R', int32 length 8, int32 auth type 3. - const cleartextPasswordRequest = Buffer.from([0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x03]); let preTlsClientBytes = Buffer.alloc(0); let answeredSslRequest = false; @@ -300,15 +301,15 @@ test("postgres client refuses protocol messages received in place of the SSLRequ const clientWroteToPlaintextSocket = Promise.withResolvers(); const sockets = new Set(); - const server = net.createServer(socket => { + const { server, port } = await listeningServer(socket => { sockets.add(socket); socket.on("error", () => {}); socket.on("data", data => { if (!answeredSslRequest) { preTlsClientBytes = Buffer.concat([preTlsClientBytes, data]); - if (preTlsClientBytes.length < 8) return; + if (preTlsClientBytes.length < pgSSLRequest().length) return; answeredSslRequest = true; - socket.write(cleartextPasswordRequest); + socket.write(pgAuthenticationCleartextPassword()); return; } plaintextAfterAuthRequest.push(Buffer.from(data)); @@ -316,8 +317,6 @@ test("postgres client refuses protocol messages received in place of the SSLRequ socket.end(); }); }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const { port } = server.address() as import("node:net").AddressInfo; try { await using sql = new SQL({ @@ -336,7 +335,7 @@ test("postgres client refuses protocol messages received in place of the SSLRequ // The client was waiting on the SSLRequest answer, so the only bytes it may // have written so far are the 8-byte SSLRequest itself. - expect(Array.from(preTlsClientBytes)).toEqual(sslRequest); + expect(preTlsClientBytes).toEqual(pgSSLRequest()); // Nothing -- least of all the password -- may be written to the // still-unencrypted socket in response to the injected auth request. expect(Buffer.concat(plaintextAfterAuthRequest).toString("latin1")).not.toContain(password); @@ -349,7 +348,11 @@ test("postgres client refuses protocol messages received in place of the SSLRequ } }); -// Uses a minimal mock PostgreSQL server, so it runs without Docker. +// Fault-injection test: requires a server that refuses / drops / sends malformed +// frames, which a healthy container will not do on demand. DO NOT COPY THIS +// PATTERN — anything a real server can produce belongs in describeWithContainer. +// All wire-protocol bytes come from test/js/sql/wire-frames.ts; do not inline +// Buffer.alloc frame construction here. test("postgres client aborts the connection when the server declines TLS that was explicitly requested", async () => { // `tls: true` (or any tls object) is an explicit request for an encrypted // connection. When the server answers the 8-byte SSLRequest with 'N' @@ -357,9 +360,6 @@ test("postgres client aborts the connection when the server declines TLS that wa // silently continuing the protocol in plaintext, which would put the // startup message and the password on the unencrypted socket. const password = "hunter2-must-not-appear-on-the-wire"; - const sslRequest = [0x00, 0x00, 0x00, 0x08, 0x04, 0xd2, 0x16, 0x2f]; - // AuthenticationCleartextPassword: 'R', int32 length 8, int32 auth type 3. - const cleartextPasswordRequest = Buffer.from([0x52, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x03]); for (const tls of [true, { rejectUnauthorized: false }] as const) { let preTlsClientBytes = Buffer.alloc(0); @@ -368,16 +368,16 @@ test("postgres client aborts the connection when the server declines TLS that wa const clientContinuedInPlaintext = Promise.withResolvers(); const sockets = new Set(); - const server = net.createServer(socket => { + const { server, port } = await listeningServer(socket => { sockets.add(socket); socket.on("error", () => {}); socket.on("data", data => { if (!declinedTls) { preTlsClientBytes = Buffer.concat([preTlsClientBytes, data]); - if (preTlsClientBytes.length < 8) return; + if (preTlsClientBytes.length < pgSSLRequest().length) return; declinedTls = true; // The legitimate "SSL not available" answer to an SSLRequest. - socket.write(Buffer.from("N")); + socket.write(pgSSLResponse("N")); return; } // Anything received from here on is the client continuing the protocol @@ -385,11 +385,9 @@ test("postgres client aborts the connection when the server declines TLS that wa plaintextAfterDecline.push(Buffer.from(data)); clientContinuedInPlaintext.resolve(); // A downgraded client would answer this with the cleartext password. - socket.write(cleartextPasswordRequest); + socket.write(pgAuthenticationCleartextPassword()); }); }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const { port } = server.address() as import("node:net").AddressInfo; try { await using sql = new SQL({ @@ -407,7 +405,7 @@ test("postgres client aborts the connection when the server declines TLS that wa ]); // The only plaintext bytes the client may ever send are the SSLRequest itself. - expect(Array.from(preTlsClientBytes)).toEqual(sslRequest); + expect(preTlsClientBytes).toEqual(pgSSLRequest()); // After the server declines TLS, nothing further -- least of all the // password -- may be written to the unencrypted socket. expect(Buffer.concat(plaintextAfterDecline).toString("latin1")).not.toContain(password); diff --git a/test/js/sql/wire-frames.test.ts b/test/js/sql/wire-frames.test.ts new file mode 100644 index 000000000000..204c798b3b75 --- /dev/null +++ b/test/js/sql/wire-frames.test.ts @@ -0,0 +1,90 @@ +// Spec-compliance self-test for the wire-frames builders. The fault-injection +// tests in this directory hand-roll Postgres/MySQL protocol frames; if a +// builder's byte layout ever drifts from what Bun's own parser accepts, this +// file goes red before any of the dependent tests do. + +import { SQL } from "bun"; +import { expect, test } from "bun:test"; +import { + listeningServer, + mysqlHandshakeV10, + mysqlLenencInt, + mysqlOkPacket, + mysqlReadPackets, + pgAuthenticationOk, + pgErrorResponse, + pgMinimalReadyServer, + pgReadyForQuery, +} from "./wire-frames"; + +test("mysqlLenencInt encodes per page_protocol_basic_dt_integers.html", () => { + expect(mysqlLenencInt(0)).toEqual(Buffer.from([0x00])); + expect(mysqlLenencInt(250)).toEqual(Buffer.from([0xfa])); + expect(mysqlLenencInt(251)).toEqual(Buffer.from([0xfc, 0xfb, 0x00])); + expect(mysqlLenencInt(0xffff)).toEqual(Buffer.from([0xfc, 0xff, 0xff])); + expect(mysqlLenencInt(0x1_0000)).toEqual(Buffer.from([0xfd, 0x00, 0x00, 0x01])); + expect(mysqlLenencInt(0xff_ffffn)).toEqual(Buffer.from([0xfd, 0xff, 0xff, 0xff])); + expect(mysqlLenencInt(0x1_00_0000n)).toEqual(Buffer.from([0xfe, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00])); +}); + +test("pgErrorResponse encodes per §55.7", () => { + expect(pgErrorResponse({ S: "FATAL", C: "57P03", M: "x" })).toEqual( + Buffer.from("E\x00\x00\x00\x16SFATAL\x00C57P03\x00Mx\x00\x00", "binary"), + ); +}); + +test("postgres: pgAuthenticationOk + pgReadyForQuery are accepted by Bun's parser", async () => { + // Minimal Postgres mock: on the startup packet, reply AuthenticationOk + + // ReadyForQuery. connect() resolving proves both frames decode. + const { port, server } = await listeningServer(socket => { + socket.once("data", () => { + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + }); + }); + const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1 }); + try { + await expect(db.connect()).resolves.toBeDefined(); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); + +test("postgres: pgMinimalReadyServer satisfies connect()", async () => { + const { port, server } = await pgMinimalReadyServer(); + const db = new SQL({ url: `postgres://postgres@127.0.0.1:${port}/postgres`, max: 1 }); + try { + await expect(db.connect()).resolves.toBeDefined(); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); + +test("mysql: mysqlHandshakeV10 + mysqlOkPacket are accepted by Bun's parser", async () => { + // Minimal MySQL mock: send HandshakeV10 on accept, reply OK to the + // HandshakeResponse41. connect() resolving proves both frames decode. + const { port, server } = await listeningServer(socket => { + let buffered = Buffer.alloc(0); + let authed = false; + socket.write(mysqlHandshakeV10()); + socket.on("data", chunk => { + buffered = mysqlReadPackets(Buffer.concat([buffered, chunk]), seq => { + if (!authed) { + authed = true; + socket.write(mysqlOkPacket(seq + 1)); + } + }); + }); + socket.on("error", () => {}); + }); + // Empty password so the mysql_native_password scramble is empty and the mock + // can OK it without validating. + const db = new SQL({ url: `mysql://root@127.0.0.1:${port}/db`, max: 1 }); + try { + await expect(db.connect()).resolves.toBeDefined(); + } finally { + await db.close({ timeout: 0 }); + server.close(); + } +}); diff --git a/test/js/sql/wire-frames.ts b/test/js/sql/wire-frames.ts new file mode 100644 index 000000000000..3b1100f06aba --- /dev/null +++ b/test/js/sql/wire-frames.ts @@ -0,0 +1,339 @@ +// Shared wire-protocol frame builders for the SQL fault-injection tests. +// Every Postgres / MySQL protocol message the mock servers emit is built +// here so there is exactly one byte-layout to keep in sync with the spec. +// Fault-injection tests import from this module instead of inlining +// Buffer.alloc / writeInt32BE sequences. + +import net from "node:net"; + +// --------------------------------------------------------------------------- +// Server helpers shared by every fault-injection test. +// --------------------------------------------------------------------------- + +/** Start a TCP server on 127.0.0.1 with an ephemeral port. */ +export async function listeningServer( + onSocket: (socket: net.Socket) => void, +): Promise<{ port: number; server: net.Server }> { + const server = net.createServer(onSocket); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + return { port: (server.address() as net.AddressInfo).port, server }; +} + +/** Reserve and immediately release a port so connecting to it is refused. */ +export async function closedPort(): Promise { + const server = net.createServer(); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as net.AddressInfo).port; + await new Promise(resolve => server.close(() => resolve())); + return port; +} + +/** + * A server that accepts the TCP connection and then never writes a byte, so the + * client stays mid-handshake until it gives up or is forced closed. `accepted` + * resolves once the first connection has been accepted. + */ +export async function neverAnsweringServer(): Promise<{ port: number; server: net.Server; accepted: Promise }> { + const first = Promise.withResolvers(); + const { port, server } = await listeningServer(socket => { + socket.unref(); + first.resolve(); + }); + server.unref(); + return { port, server, accepted: first.promise }; +} + +// --------------------------------------------------------------------------- +// PostgreSQL frontend/backend protocol — https://www.postgresql.org/docs/current/protocol-message-formats.html +// --------------------------------------------------------------------------- + +// PostgreSQL FE/BE protocol §55.4: Int16 / Int32 are network-order (big-endian) signed integers; String is NUL-terminated. +export function pgInt32(n: number): Buffer { + const b = Buffer.alloc(4); + b.writeInt32BE(n, 0); + return b; +} +export function pgCString(s: string): Buffer { + return Buffer.concat([Buffer.from(s, "utf-8"), Buffer.from([0])]); +} + +// PostgreSQL FE/BE protocol §55.2.1 SSLRequest: Int32(8) Int32(80877103) +export function pgSSLRequest(): Buffer { + const buf = Buffer.alloc(8); + buf.writeInt32BE(8, 0); + buf.writeInt32BE(80877103, 4); // 0x04d2162f + return buf; +} + +// PostgreSQL FE/BE protocol §55.2.1 SSLRequest response: Byte1('S' = willing, 'N' = unwilling) +export function pgSSLResponse(answer: "S" | "N"): Buffer { + return Buffer.from(answer, "latin1"); +} + +// PostgreSQL FE/BE protocol §55.7 AuthenticationOk: Byte1('R') Int32(8) Int32(0) +export function pgAuthenticationOk(): Buffer { + const buf = Buffer.alloc(9); + buf.write("R", 0); + buf.writeInt32BE(8, 1); + buf.writeInt32BE(0, 5); + return buf; +} + +// PostgreSQL FE/BE protocol §55.7 AuthenticationCleartextPassword: Byte1('R') Int32(8) Int32(3) +export function pgAuthenticationCleartextPassword(): Buffer { + const buf = Buffer.alloc(9); + buf.write("R", 0); + buf.writeInt32BE(8, 1); + buf.writeInt32BE(3, 5); + return buf; +} + +// PostgreSQL FE/BE protocol §55.7 ReadyForQuery: Byte1('Z') Int32(5) Byte1(status) +export function pgReadyForQuery(status: "I" | "T" | "E" = "I"): Buffer { + const buf = Buffer.alloc(6); + buf.write("Z", 0); + buf.writeInt32BE(5, 1); + buf.write(status, 5); + return buf; +} + +// PostgreSQL FE/BE protocol §55.7 ErrorResponse: Byte1('E') Int32(len) (Byte1 field-code, String value)* Byte1(0) +export function pgErrorResponse(fields: { S: string; C: string; M: string; [k: string]: string }): Buffer { + const entries = Object.entries(fields); + let len = 4; // Int32 length itself + for (const [, v] of entries) len += 1 + Buffer.byteLength(v) + 1; // code + value + NUL + len += 1; // terminating NUL + const buf = Buffer.alloc(1 + len); + let o = 0; + buf.write("E", o++); + buf.writeInt32BE(len, o); + o += 4; + for (const [k, v] of entries) { + buf.write(k, o++); + o += buf.write(v, o); + buf[o++] = 0; + } + buf[o] = 0; + return buf; +} + +// PostgreSQL FE/BE protocol §55.7 generic backend message: Byte1(type) Int32(len = 4 + body.length) body +// Low-level escape hatch for fault-injection tests that need a deliberately malformed body. +export function pgRaw(type: string, body: Buffer): Buffer { + const buf = Buffer.alloc(5 + body.length); + buf.write(type, 0); + buf.writeInt32BE(body.length + 4, 1); + body.copy(buf, 5); + return buf; +} + +// PostgreSQL FE/BE protocol §55.7 CommandComplete: Byte1('C') Int32(len) String(tag) +export function pgCommandComplete(tag: string): Buffer { + return pgRaw("C", Buffer.concat([Buffer.from(tag), Buffer.from([0])])); +} + +export type PgRowDescriptionColumn = { + name: string; + tableOid?: number; + columnAttr?: number; + typeOid: number; + typeSize?: number; + typeModifier?: number; + /** 0 = text, 1 = binary */ + format?: 0 | 1; +}; + +// PostgreSQL FE/BE protocol §55.7 RowDescription: Byte1('T') Int32(len) Int16(nfields) +// per field: String(name) Int32(tableOid) Int16(colAttr) Int32(typeOid) Int16(typeSize) Int32(typeMod) Int16(format) +export function pgRowDescription(cols: PgRowDescriptionColumn[]): Buffer { + const parts: Buffer[] = [Buffer.alloc(2)]; + parts[0].writeInt16BE(cols.length, 0); + for (const c of cols) { + const name = Buffer.concat([Buffer.from(c.name), Buffer.from([0])]); + const meta = Buffer.alloc(18); + meta.writeInt32BE(c.tableOid ?? 0, 0); + meta.writeInt16BE(c.columnAttr ?? 0, 4); + meta.writeInt32BE(c.typeOid, 6); + meta.writeInt16BE(c.typeSize ?? -1, 10); + meta.writeInt32BE(c.typeModifier ?? -1, 12); + meta.writeInt16BE(c.format ?? 0, 16); + parts.push(name, meta); + } + return pgRaw("T", Buffer.concat(parts)); +} + +// PostgreSQL FE/BE protocol §55.7 DataRow: Byte1('D') Int32(len) Int16(ncols) per col: Int32(byteLen | -1) Byte[len] +export function pgDataRow(cols: (Buffer | null)[]): Buffer { + const parts: Buffer[] = [Buffer.alloc(2)]; + parts[0].writeInt16BE(cols.length, 0); + for (const c of cols) { + const hdr = Buffer.alloc(4); + if (c === null) { + hdr.writeInt32BE(-1, 0); + parts.push(hdr); + } else { + hdr.writeInt32BE(c.length, 0); + parts.push(hdr, c); + } + } + return pgRaw("D", Buffer.concat(parts)); +} + +/** Minimal Postgres mock: on the startup packet, reply AuthenticationOk + ReadyForQuery. */ +export async function pgMinimalReadyServer(): Promise<{ port: number; server: net.Server }> { + return listeningServer(socket => { + socket.once("data", () => { + socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()])); + }); + }); +} + +// --------------------------------------------------------------------------- +// MySQL client/server protocol — https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_packets.html +// --------------------------------------------------------------------------- + +// Capability flags — page_protocol_basic_capability_flags.html (subset used by the mocks). +export const MYSQL_CLIENT_PROTOCOL_41 = 1 << 9; +export const MYSQL_CLIENT_SSL = 1 << 11; +export const MYSQL_CLIENT_SECURE_CONNECTION = 1 << 15; +export const MYSQL_CLIENT_PLUGIN_AUTH = 1 << 19; +export const MYSQL_CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA = 1 << 21; +export const MYSQL_CLIENT_DEPRECATE_EOF = 1 << 24; +export const MYSQL_DEFAULT_CAPABILITIES = + MYSQL_CLIENT_PROTOCOL_41 | + MYSQL_CLIENT_SECURE_CONNECTION | + MYSQL_CLIENT_PLUGIN_AUTH | + MYSQL_CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA | + MYSQL_CLIENT_DEPRECATE_EOF; + +// MySQL packet framing — page_protocol_basic_packets.html: Int<3>(payload_length) Int<1>(sequence_id) payload +export function mysqlRawPacket(seq: number, payload: Buffer): Buffer { + const header = Buffer.alloc(4); + header[0] = payload.length & 0xff; + header[1] = (payload.length >> 8) & 0xff; + header[2] = (payload.length >> 16) & 0xff; + header[3] = seq & 0xff; + return Buffer.concat([header, payload]); +} + +// MySQL Protocol::HandshakeV10 — page_protocol_connection_phase_packets_protocol_handshake_v10.html +// Int<1>(10) NulString(server_version) Int<4>(thread_id) String<8>(auth1) Int<1>(0) Int<2>(cap_lo) +// Int<1>(charset) Int<2>(status) Int<2>(cap_hi) Int<1>(auth_len) String<10>(reserved) String<13>(auth2) NulString(plugin) +export function mysqlHandshakeV10( + opts: { authPlugin?: string; capabilities?: number; serverVersion?: string } = {}, +): Buffer { + const caps = opts.capabilities ?? MYSQL_DEFAULT_CAPABILITIES; + const authData1 = Buffer.alloc(8, 0x61); + const authData2 = Buffer.alloc(13, 0x62); + authData2[12] = 0; + const payload = Buffer.concat([ + Buffer.from([10]), + Buffer.from(`${opts.serverVersion ?? "mock-5.7.0"}\0`), + Buffer.from([1, 0, 0, 0]), // thread_id + authData1, + Buffer.from([0]), // filler + Buffer.from([caps & 0xff, (caps >> 8) & 0xff]), // capability_flags_1 + Buffer.from([0x2d]), // character_set (utf8mb4_general_ci) + Buffer.from([0x02, 0x00]), // status_flags (SERVER_STATUS_AUTOCOMMIT) + Buffer.from([(caps >> 16) & 0xff, (caps >>> 24) & 0xff]), // capability_flags_2 + Buffer.from([21]), // auth_plugin_data_len + Buffer.alloc(10, 0), // reserved + authData2, + Buffer.from(`${opts.authPlugin ?? "mysql_native_password"}\0`), + ]); + return mysqlRawPacket(0, payload); +} + +// MySQL Protocol::OK_Packet — page_protocol_basic_ok_packet.html: Int<1>(0x00) lenenc(affected_rows) lenenc(last_insert_id) Int<2>(status) Int<2>(warnings) +export function mysqlOkPacket(seq: number): Buffer { + return mysqlRawPacket(seq, Buffer.from([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00])); +} + +// MySQL Protocol::AuthSwitchRequest — page_protocol_connection_phase_packets_protocol_auth_switch_request.html: +// Int<1>(0xfe) NulString(plugin_name) String(plugin_provided_data) +export function mysqlAuthSwitchRequest(seq: number, pluginName: string, pluginData: Buffer): Buffer { + return mysqlRawPacket(seq, Buffer.concat([Buffer.from([0xfe]), Buffer.from(pluginName + "\0"), pluginData])); +} + +// MySQL length-encoded integer — page_protocol_basic_dt_integers.html#sect_protocol_basic_dt_int_le: +// <0xfb 1B; 0xfc + Int<2>; 0xfd + Int<3>; 0xfe + Int<8>. +export function mysqlLenencInt(n: number | bigint): Buffer { + const v = typeof n === "bigint" ? n : BigInt(n); + if (v < 0xfbn) return Buffer.from([Number(v)]); + if (v < 0x1_0000n) return Buffer.from([0xfc, Number(v) & 0xff, Number(v >> 8n) & 0xff]); + if (v < 0x1_00_0000n) return Buffer.from([0xfd, Number(v) & 0xff, Number(v >> 8n) & 0xff, Number(v >> 16n) & 0xff]); + const out = Buffer.alloc(9); + out[0] = 0xfe; + out.writeBigUInt64LE(v, 1); + return out; +} + +// MySQL string — page_protocol_basic_dt_strings.html: lenenc-int byte length followed by that many bytes. +export function mysqlLenencStr(s: string | Buffer): Buffer { + const buf = typeof s === "string" ? Buffer.from(s, "utf-8") : s; + return Buffer.concat([mysqlLenencInt(buf.length), buf]); +} + +// MySQL Protocol::ColumnDefinition41 — page_protocol_com_query_response_text_resultset_column_definition.html: +// lenenc("def") lenenc(schema) lenenc(table) lenenc(org_table) lenenc(name) lenenc(org_name) +// lenenc(0x0c) Int<2>(charset) Int<4>(column_length) Int<1>(type) Int<2>(flags) Int<1>(decimals) Int<2>(0x0000) +export function mysqlColumnDefinition( + seq: number, + opts: { + name: string; + type: number; + charset?: number; + flags?: number; + decimals?: number; + columnLength?: number; + schema?: string; + table?: string; + orgTable?: string; + orgName?: string; + }, +): Buffer { + const fixed = Buffer.alloc(12); + fixed.writeUInt16LE(opts.charset ?? 33, 0); + fixed.writeUInt32LE(opts.columnLength ?? 0, 2); + fixed[6] = opts.type; + fixed.writeUInt16LE(opts.flags ?? 0, 7); + fixed[9] = opts.decimals ?? 0; + // bytes 10-11 reserved zero + return mysqlRawPacket( + seq, + Buffer.concat([ + mysqlLenencStr("def"), + mysqlLenencStr(opts.schema ?? ""), + mysqlLenencStr(opts.table ?? ""), + mysqlLenencStr(opts.orgTable ?? ""), + mysqlLenencStr(opts.name), + mysqlLenencStr(opts.orgName ?? ""), + Buffer.from([0x0c]), + fixed, + ]), + ); +} + +// MySQL COM_STMT_PREPARE_OK — page_protocol_com_stmt_prepare.html#sect_protocol_com_stmt_prepare_response_ok: +// Int<1>(0x00) Int<4>(statement_id) Int<2>(num_columns) Int<2>(num_params) Int<1>(0x00) Int<2>(warning_count) +export function mysqlStmtPrepareOk(seq: number, stmtId: number, numColumns: number, numParams: number): Buffer { + const payload = Buffer.alloc(12); + payload[0] = 0x00; + payload.writeUInt32LE(stmtId, 1); + payload.writeUInt16LE(numColumns, 5); + payload.writeUInt16LE(numParams, 7); + payload[9] = 0x00; + payload.writeUInt16LE(0, 10); + return mysqlRawPacket(seq, payload); +} + +/** Drain complete MySQL packets from `buffered`, calling onPacket(seq, payload) for each; returns the leftover bytes. */ +export function mysqlReadPackets(buffered: Buffer, onPacket: (seq: number, payload: Buffer) => void): Buffer { + while (buffered.length >= 4) { + const len = buffered[0] | (buffered[1] << 8) | (buffered[2] << 16); + if (buffered.length < 4 + len) break; + onPacket(buffered[3], buffered.subarray(4, 4 + len)); + buffered = buffered.subarray(4 + len); + } + return buffered; +}