sql(postgres): reject a non-BufferSource value bound to a bytea parameter

Merged
RO
2mo ago
+78-4

Problem

  • A value that is not a Buffer, TypedArray or ArrayBuffer, bound to a parameter the server types as bytea, is stored as a zero-length bytea with a success result. ${[1, 2, 3]} (a number[] of bytes), a JSON-revived { type: "Buffer", data: [...] }, a Date and { a: 1 } all store \x. The payload is lost and nothing reports it.
  • The cause is the Tag::bytea arm of write_bind (src/sql_jsc/postgres/PostgresRequest.rs:191). It calls value.as_array_buffer(global) and on None writes b"" as the parameter value.

Fix

  • On None, throw ERR_INVALID_ARG_TYPE: Query parameter $N of type bytea must be a Buffer, TypedArray, ArrayBuffer or string. Received an instance of Array. The query rejects with that TypeError, the same way the other encoder arms reject a value they cannot encode (tag_jsc::from_js, the json arm).
  • Correct because as_array_buffer returns None only for a value that is not an ArrayBuffer or a view on one, so there are no bytes to send. A string never reaches this arm (it is sent in text format and the server parses it), and null/undefined are written as SQL NULL before the match. An empty Uint8Array still stores an empty bytea.
  • Verified: test/js/sql/postgres-bytea-bind.test.ts (new, real server; 5 of 6 tests fail on stock bun). Also ran every test/js/sql/postgres-*.test.ts, sql-prepare-false.test.ts and sql-postgres-datetime-roundtrip.test.ts.

Background

  • Bun sends a parameter whose JS value is an object, array or Date with OID 0 in Parse and lets the server infer the type. The server answers Describe with a ParameterDescription. For an insert into t (body) values ($1) with a bytea column, or $1::bytea, it reports OID 17.
  • write_bind then writes the Bind message. For OIDs with a binary encoder (Tag::is_binary_format_supported) it sends format code 1 and encodes the JS value itself. bytea is one of them, and its binary form is the raw bytes.
  • JSValue::as_array_buffer (JSC__JSValue__asArrayBuffer in bindings.cpp) returns the backing store of an ArrayBuffer, SharedArrayBuffer, TypedArray or DataView, and nothing for any other value.
Notes
  • postgres.js serializes a bytea parameter with Buffer.from(x), so it accepts a number[] (and turns ['a','b'] into \x0000). This PR does not coerce arrays. The error tells the caller to pass a Buffer/Uint8Array. Accepting number[] can be added on top if wanted.
  • After any encoder arm throws, the partial Bind message stays in the write buffer and the next query on that connection fails with ERR_POSTGRES_CONNECTION_CLOSED. This is pre-existing for every bind-time error (for example { a: 1n } bound to jsonb on 1.4.3) and is what #34732 fixes. The new test uses one connection per rejecting case so it does not depend on that.
  • With prepare: false the Bind is written before ParameterDescription arrives, so the value goes through the OID 0 text path instead (String([1,2,3]) today, #39452 changes that path). This PR only touches the binary bytea arm.
  • DataView is rejected earlier, in Signature::generate (Unknown object is not a valid PostgreSQL type), so the message does not list it.

[human-review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 5 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/postgres-bytea-bind.test.ts
bun test v1.4.3 (a3e0ab60c)

test/js/sql/postgres-bytea-bind.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
28 |     await using sql = connect();
29 |     const err: any = await sql`select ${value}::bytea as x`.then(
30 |       () => null,
31 |       e => e,
32 |     );
33 |     expect(err).toBeInstanceOf(TypeError);
                     ^
error: expect(received).toBeInstanceOf(expected)

Expected constructor: [class TypeError extends Error]
Received value: null

      at <anonymous> (/workspace/bun/test/js/sql/postgres-bytea-bind.test.ts:33:17)
(fail) postgres > number[] bound to a bytea parameter rejects [271.18ms]
28 |     await using sql = connect();
29 |     const err: any = await sql`select ${value}::bytea as x`.then(
30 |       () => null,
31 |       e => e,
32 |     );
33 |     expect(err).toBeInstanceOf(TypeError);
                     ^
error: expect(received).toBeInstanceOf(expected)

Expected constructor: [class TypeError extends Error]
Received value: nu
... (truncated)

release without fix: 5 FAILED
bun test v1.4.2 (744846f84)

test/js/sql/postgres-bytea-bind.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
28 |     await using sql = connect();
29 |     const err: any = await sql`select ${value}::bytea as x`.then(
30 |       () => null,
31 |       e => e,
32 |     );
33 |     expect(err).toBeInstanceOf(TypeError);
                     ^
error: expect(received).toBeInstanceOf(expected)

Expected constructor: [class TypeError extends Error]
Received value: null

      at <anonymous> (/workspace/bun/test/js/sql/postgres-bytea-bind.test.ts:33:17)
(fail) postgres > number[] bound to a bytea parameter rejects [11.28ms]
28 |     await using sql = connect();
29 |     const err: any = await sql`select ${value}::bytea as x`.then(
30 |       () => null,
31 |       e => e,
32 |     );
33 |     expect(err).toBeInstanceOf(TypeError);
                     ^
error: expect(received).toBeInstanceOf(expected)

Expected constructor: [class TypeError extends Error]
Received value: null

      at <anonymous> (/workspace/bun/test/js/sql/postgres-bytea-bind.test.ts:33:17)
(fail) postgres > JSON-revived Buffer bound to a bytea parameter rejects [3.29ms]
28 
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/sql/postgres-bytea-bind.test.ts
bun test v1.4.3 (a3e0ab60c)

test/js/sql/postgres-bytea-bind.test.ts:
Container ready via docker-compose: postgres_plain at 127.0.0.1:5432
(pass) postgres > number[] bound to a bytea parameter rejects [227.64ms]
(pass) postgres > JSON-revived Buffer bound to a bytea parameter rejects [19.63ms]
(pass) postgres > Date bound to a bytea parameter rejects [13.73ms]
(pass) postgres > plain object bound to a bytea parameter rejects [13.56ms]
(pass) postgres > the error names the position of the offending parameter [28.45ms]
(pass) postgres > BufferSource, string and null values bound to a bytea parameter round-trip [77.72ms]

 6 pass
 0 fail
 22 expect() calls
Ran 6 tests across 1 file. [3.02s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     cab663e870
  features     baseline

23 deps, 131 codegen, 1172 objects in 666ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1248] mkdir stamps
[2/1248] mkdir codegen
[3/1248] mkdir obj
[4/1248] mkdir pch
[5/1248] install /workspace/bun
bun install v1.4.2 (744846f84)

Checked 22 installs across 61 packages (no changes) [41.00ms]
[6/1248] gen ErrorCode+*.h
[7/1248] install /workspace/bun/packages/bun-error
bun install v1.4.2 (744846f84)

Checked 1 install across 2 packages (no changes) [1.00ms]
[8/1248] gen bindgenv2
[9/1248] fetch tinycc
[tinycc] up to date
[10/1248] install /workspace/bun/src/node-fallbacks
bun install v1.4.2 (744846f84)

Checked 111 installs across 104 packages (no changes) [5.00ms]
[11/1248] fetch picohttpparser
[picohttpparser] up to date
[12/1248] fetch zlib
[zlib] up to date
[13/1248] gen node-fallbacks/react-refresh.js
Bundled 1 module in 13ms

  react-refresh.js  4.81 KB  (entry point)

[14/1248] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[15
... (truncated)
diff hotspot
src/sql_jsc/postgres/PostgresRequest.rs | 15 ++++++--
 test/js/sql/postgres-bytea-bind.test.ts | 67 +++++++++++++++++++++++++++++++++
 2 files changed, 78 insertions(+), 4 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                     reads  edits  tests
src/sql_jsc/postgres/PostgresRequest.rs      2      2      8
test/js/sql/postgres-bytea-bind.test.ts      0      2      8