sql(postgres): reject a non-BufferSource value bound to a bytea parameter
Merged
RO
2mo ago+78+78+01234567890123456789-4-4-0123456789
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]}(anumber[]of bytes), a JSON-revived{ type: "Buffer", data: [...] }, aDateand{ a: 1 }all store\x. The payload is lost and nothing reports it. - The cause is the
Tag::byteaarm ofwrite_bind(src/sql_jsc/postgres/PostgresRequest.rs:191). It callsvalue.as_array_buffer(global)and onNonewritesb""as the parameter value.
Fix
- On
None, throwERR_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 thatTypeError, the same way the other encoder arms reject a value they cannot encode (tag_jsc::from_js, the json arm). - Correct because
as_array_bufferreturnsNoneonly for a value that is not anArrayBufferor 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), andnull/undefinedare written as SQL NULL before the match. An emptyUint8Arraystill 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 everytest/js/sql/postgres-*.test.ts,sql-prepare-false.test.tsandsql-postgres-datetime-roundtrip.test.ts.
Background
- Bun sends a parameter whose JS value is an object, array or
Datewith OID 0 in Parse and lets the server infer the type. The server answers Describe with a ParameterDescription. For aninsert into t (body) values ($1)with abyteacolumn, or$1::bytea, it reports OID 17. write_bindthen 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__asArrayBufferin bindings.cpp) returns the backing store of anArrayBuffer,SharedArrayBuffer, TypedArray orDataView, and nothing for any other value.
Notes
- postgres.js serializes a bytea parameter with
Buffer.from(x), so it accepts anumber[](and turns['a','b']into\x0000). This PR does not coerce arrays. The error tells the caller to pass aBuffer/Uint8Array. Acceptingnumber[]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: falsethe 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. DataViewis rejected earlier, inSignature::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