Files
bun-src/test/js/sql/postgres-datarow-overrun.test.ts
2026-08-27 21:09:14 +00:00

265 lines
9.4 KiB
TypeScript

// 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.
//
// DataRow, RowDescription and ParameterDescription each carry an Int32 message
// length that frames an Int16 count and a variable-length body driven by that
// count. A count (or per-column Int32 cell length) that overruns the enclosing
// message is a protocol error (libpq: "insufficient data left in message"); a
// decoder that trusts the inner counts over the outer length reads the
// following CommandComplete / ReadyForQuery bytes as payload, still comes up
// short, and returns ShortRead. The dispatch loop treats ShortRead as "wait
// for more socket data", so the query never settles and every later query
// queues behind it forever. All three must reject with
// ERR_POSTGRES_INVALID_MESSAGE instead.
import { SQL } from "bun";
import { afterAll, describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
import {
listeningServer,
pgAuthenticationOk,
pgBindComplete,
pgCommandComplete,
pgDataRow,
pgInt32,
pgParameterDescription,
pgParseComplete,
pgRaw,
pgReadFrontendMessages,
pgReadyForQuery,
pgRowDescription,
} from "./wire-frames";
// --- DataRow / RowDescription (simple-query path) --------------------------
const oneField = pgRowDescription([{ name: "n", typeOid: 25 /* text */ }]).subarray(7);
const simpleCases: { name: string; reply: Buffer[]; code: string }[] = [
{
// DataRow: Int16 field_count=1, Int32 cell_len=100, then 2 bytes of payload.
name: "DataRow cell length exceeds the enclosing message",
reply: [
pgRowDescription([{ name: "n", typeOid: 25 }]),
pgRaw("D", Buffer.concat([Buffer.from([0, 1]), pgInt32(100), Buffer.from("ab")])),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
],
code: "ERR_POSTGRES_INVALID_MESSAGE",
},
{
// DataRow: Int16 field_count=3 but only one cell's worth of bytes follows.
name: "DataRow field count exceeds the enclosing message",
reply: [
pgRowDescription([{ name: "n", typeOid: 25 }]),
pgRaw("D", Buffer.concat([Buffer.from([0, 3]), pgInt32(2), Buffer.from("ab")])),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
],
code: "ERR_POSTGRES_INVALID_MESSAGE",
},
{
// DataRow: a negative cell length other than -1 (NULL) would read ~4 GiB.
name: "DataRow negative cell length other than -1",
reply: [
pgRowDescription([{ name: "n", typeOid: 25 }]),
pgRaw("D", Buffer.concat([Buffer.from([0, 1]), pgInt32(-2), Buffer.from("ab")])),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
],
code: "ERR_POSTGRES_INVALID_MESSAGE",
},
{
// DataRow: declared message length 5, room for the Int32 length + one
// stray byte, not even the Int16 field count.
name: "DataRow message too short for the field count",
reply: [
pgRowDescription([{ name: "n", typeOid: 25 }]),
pgRaw("D", Buffer.from([0]), 5),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
],
code: "ERR_POSTGRES_INVALID_MESSAGE",
},
{
// RowDescription: Int16 field_count=2, body carries exactly one field.
// Only ReadyForQuery follows so the second field's reads exhaust the
// buffer instead of tripping the format-code check on garbage bytes.
name: "RowDescription field count exceeds the enclosing message",
reply: [pgRaw("T", Buffer.concat([Buffer.from([0, 2]), oneField])), pgReadyForQuery()],
code: "ERR_POSTGRES_INVALID_MESSAGE",
},
];
// One mock server for the file; each test sets `simpleReply` before connecting
// and the accept handler latches it per connection.
let simpleReply!: Buffer[];
const simple = await listeningServer(socket => {
const frames = simpleReply;
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(frames));
});
socket.on("error", () => {});
});
afterAll(() => new Promise<void>(r => simple.server.close(() => r())));
test.each(simpleCases)("postgres: $name fails the query", async ({ reply, code }) => {
simpleReply = reply;
const db = new SQL({ url: `postgres://[email protected]:${simple.port}/db`, max: 1, connectionTimeout: 1 });
let err: any;
try {
await db`select n`.simple();
err = new Error("expected the query to reject");
} catch (e) {
err = e;
} finally {
await db.close({ timeout: 0 }).catch(() => {});
}
expect({ code: err.code, name: err.name }).toEqual({ code, name: "PostgresError" });
});
// Boundary: a cell that exactly fills the declared message length still decodes.
test("postgres: DataRow whose cell exactly fills the message still decodes", async () => {
simpleReply = [
pgRowDescription([{ name: "n", typeOid: 25 }]),
pgDataRow([Buffer.from("ab")]),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
];
const db = new SQL({ url: `postgres://[email protected]:${simple.port}/db`, max: 1, connectionTimeout: 1 });
try {
const rows: any = await db`select n`.simple();
expect(rows[0]).toEqual({ n: "ab" });
} finally {
await db.close({ timeout: 0 }).catch(() => {});
}
});
test("postgres: column names that are not valid UTF-8 decode to U+FFFD and the last duplicate column wins", async () => {
const fieldMeta = oneField.subarray(2);
simpleReply = [
pgRaw(
"T",
Buffer.concat([Buffer.from([0, 2]), Buffer.from([0x80, 0]), fieldMeta, Buffer.from([0x81, 0]), fieldMeta]),
),
pgDataRow([Buffer.from("first"), Buffer.from("second")]),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
];
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const db = new Bun.SQL({ url: process.env.PGURL, max: 1 });
const rows = await db\`select n\`.simple();
console.log(rows.length, JSON.stringify(Object.keys(rows[0])), JSON.stringify(rows[0]));
await db.close({ timeout: 0 });`,
],
env: { ...bunEnv, PGURL: `postgres://[email protected]:${simple.port}/db` },
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect({ stdout, stderr }).toEqual({ stdout: `1 [""] {"":"second"}\n`, stderr: expect.any(String) });
expect(exitCode).toBe(0);
});
// --- ParameterDescription (extended-protocol path) -------------------------
describe("postgres: ParameterDescription body overrun", () => {
// Mock backend that answers Parse with the four-message prepare response,
// replacing ParameterDescription with `pd`, and answers Bind with one row.
let pd!: Buffer;
let extended: { port: number; server: import("node:net").Server };
const makeExtended = async () =>
listeningServer(socket => {
const frame = pd;
let pending = Buffer.alloc(0);
let sawStartup = false;
socket.on("data", chunk => {
pending = Buffer.concat([pending, chunk]);
if (!sawStartup) {
if (pending.length < 4) return;
const len = pending.readInt32BE(0);
if (pending.length < len) return;
pending = pending.subarray(len);
sawStartup = true;
socket.write(Buffer.concat([pgAuthenticationOk(), pgReadyForQuery()]));
}
pending = pgReadFrontendMessages(pending, type => {
if (type === 0x50 /* Parse */) {
socket.write(
Buffer.concat([
pgParseComplete(),
frame,
pgRowDescription([{ name: "v", typeOid: 25 }]),
pgReadyForQuery(),
]),
);
} else if (type === 0x42 /* Bind */) {
socket.write(
Buffer.concat([
pgBindComplete(),
pgDataRow([Buffer.from("x")]),
pgCommandComplete("SELECT 1"),
pgReadyForQuery(),
]),
);
}
});
});
socket.on("error", () => {});
});
async function prepared(): Promise<any> {
extended = await makeExtended();
const db = new SQL({
adapter: "postgres",
hostname: "127.0.0.1",
port: extended.port,
username: "u",
database: "db",
tls: false,
max: 1,
prepare: true,
connectionTimeout: 1,
});
try {
return await db`select ${"x"} as v`;
} finally {
await db.close({ timeout: 0 }).catch(() => {});
await new Promise<void>(r => extended.server.close(() => r()));
}
}
test("parameter count exceeding the message fails the query", async () => {
// Int16 count=1000 but no Int32[n] body follows.
pd = pgRaw("t", Buffer.from([0x03, 0xe8]));
let err: any;
try {
await prepared();
err = new Error("expected the query to reject");
} catch (e) {
err = e;
}
expect({ code: err.code, name: err.name }).toEqual({
code: "ERR_POSTGRES_INVALID_MESSAGE",
name: "PostgresError",
});
});
test("well-formed ParameterDescription still decodes", async () => {
pd = pgParameterDescription([25]);
const rows: any = await prepared();
expect(rows[0]).toEqual({ v: "x" });
});
});