initial commit
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
// 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" });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user