Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 38 additions & 73 deletions test/js/sql/postgres-binary-array-bounds.test.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,52 @@
// 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;
elemtype: number;
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<unknown> {
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;
Expand All @@ -88,19 +56,16 @@ async function runMockQuery(columnBytes: Buffer, typeOid: number): Promise<unkno
// tripping the debug-only @alignCast in init() first.
socket.write(
Buffer.concat([
rowDescription([{ name: "arr", oid: typeOid, format: 1 /* binary */ }]),
dataRowRaw([columnBytes]),
commandComplete("SELECT 1"),
readyForQuery,
pgRowDescription([{ name: "arr", typeOid, format: 1 /* binary */ }]),
pgDataRow([columnBytes]),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
]),
);
});
socket.on("error", () => {});
});

await new Promise<void>(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,
Expand Down Expand Up @@ -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),
]),
},
{
Expand All @@ -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",
Expand Down Expand Up @@ -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]));
Expand Down
136 changes: 32 additions & 104 deletions test/js/sql/postgres-binary-numeric.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown> {
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<void>(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<void>(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 `'<literal>'::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);
});
});
Loading
Loading