Skip to content

sql(mysql): scramble caching_sha2_password against the 20-byte nonce - #33504

Closed
robobun wants to merge 1 commit into
mainfrom
farm/13c083a1/mysql-caching-sha2-nonce
Closed

robobun wants to merge 1 commit into
mainfrom
farm/13c083a1/mysql-caching-sha2-nonce

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

caching_sha2_password is MySQL 8's default auth plugin, and bun computes its scramble over the 21-byte auth-plugin-data instead of the 20-byte nonce, so the token it sends can never verify against a real server.

Repro

Any MySQL 8 account whose credentials the server has already cached (i.e. every connection after the first), over plain TCP:

const sql = new Bun.SQL({ adapter: "mysql", hostname, port, username, password, database });
await sql`SELECT 1`;
// ERR_MYSQL_PUBLIC_KEY_RETRIEVAL_NOT_ALLOWED: Connection closed

mysql2, go-sql-driver and Connector/J all connect here, via fast auth.

Cause

handle_handshake stores auth_plugin_data_part_1 (8 bytes) + auth_plugin_data_part_2 (13 bytes) as the nonce. Part 2's 13th byte is the NUL that terminates auth-plugin-data, not nonce data: every other client cuts it (go-sql-driver authData[:20], mysql2 data.slice(0, 20), and the server's own Generate_scramble in sha2_password_common.cc hashes the 20-byte m_rnd).

mysql_native_password::scramble slices nonce[0..20], which is why native auth works. caching_sha2_password::scramble hashed the whole slice, so the token is XOR(SHA256(pw), SHA256(SHA256(SHA256(pw)) || nonce || 0x00)). Two consequences:

  1. The token never verifies, so even a warm server-side cache answers perform_full_authentication (0x04). With the default allowPublicKeyRetrieval: false (copied from mysql2 / Connector/J) the connection then fails outright, and the fast-auth-success path is dead code in production. With TLS or the opt-in it "works", at the cost of a full RSA/TLS password round-trip on every connection.
  2. The same nonce masks the password on the RSA path, where the XOR is cyclic (plain[i] ^= nonce[i % nonce.len]). A 21-byte nonce shifts every byte from the 20th on, so passwords of 20 bytes and over are corrupted and full authentication fails as well. That is caching_sha2_password auth to MySQL 8.0 not working with passwords longer than 19 chars #26195.

Fix

auth_data holds the 20-byte scramble: truncated where the handshake stores it, and where an AuthSwitchRequest replaces it (the switch packet terminates its plugin_data the same way). caching_sha2_password::scramble now rejects a short nonce and slices to 20, exactly like its mysql_native_password sibling.

Builds on #28161 by @kernoeb, which fixed the handshake nonce for the RSA path; this additionally covers the AuthSwitchRequest nonce and the fast-auth token, and proves all of it without a new container.

Verification

bun bd test test/js/sql/sql-mysql.auth.test.ts test/js/sql/sql-mysql-auth-short-nonce.test.ts, against scripted servers that derive the expected bytes from the password and the nonce they handed out:

  • the fast-auth token is the spec token, not the 21-byte one
  • fast auth succeeds over plain TCP against a warm cache (before: ERR_MYSQL_PUBLIC_KEY_RETRIEVAL_NOT_ALLOWED)
  • the RSA-encrypted password, decrypted with the mock server's private key, is the NUL-terminated password XORed against the 20-byte nonce, for both the handshake nonce and an AuthSwitchRequest nonce
  • a short AuthSwitchRequest nonce is rejected with ERR_MYSQL_MISSING_AUTH_DATA on the caching_sha2 path too, not just the native one

One container test covers #26195 end to end: a caching_sha2_password account with a 27-character password, full auth against a real MySQL 8.

Fail-before, with src/ stashed
(fail) caching_sha2_password scramble hashes the double-SHA256 before the 20-byte nonce
(fail) caching_sha2_password fast auth succeeds over plain TCP against a warm cache
(fail) caching_sha2_password full auth XORs the password against the 20-byte nonce (handshake)
(fail) caching_sha2_password full auth XORs the password against the 20-byte nonce (auth-switch)

  {
-   "plain": "31356e3c3d3e3f303132333435363728292a2b2c393a3b3c3d3e3f48",
+   "plain": "31356e3c3d3e3f303132333435363728292a2b2c78393a3b3c3d3e47",
  }

The 78 the unfixed build leaves in the RSA plaintext is the password's 21st byte, XORed against the NUL instead of against nonce[0].

Closes #26195


[review] gate passed · iteration 6 · 5 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/mechgate.xml" test/js/sql/sql-mysql-auth-short-nonce.test.ts test/js/sql/sql-mysql.auth.test.ts
bun test v1.4.0 (6a21fe827)

test/js/sql/sql-mysql-auth-short-nonce.test.ts:
(pass) MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read [523.09ms]
69 |       const err = await sql`select 1`.then(
70 |         () => ({ code: "UNEXPECTED_SUCCESS" }),
71 |         e => ({ code: e?.code ?? String(e) }),
72 |       );
73 | 
74 |       expect({ err, sawAuthSwitchResponse }).toEqual({
                                                  ^
error: expect(received).toEqual(expected)

  {
    "err": {
-     "code": "ERR_MYSQL_MISSING_AUTH_DATA",
+     "code": "ERR_MYSQL_SERVER_ERROR",
    },
-   "sawAuthSwitchResponse": false,
+   "sawAuthSwitchResponse": true,
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/sql/sql-mysql-auth-short-nonce.test.ts:74:46)
(fail) MySQL: AuthSwitchRequest with a short caching_sha2_password nonce is rejected, not OOB-read [56.06ms]
(pass) MySQL: an AuthSwitchRequest 
... (truncated)

release without fix: 5 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/js/sql/sql-mysql-auth-short-nonce.test.ts:
(pass) MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read [10.22ms]
69 |       const err = await sql`select 1`.then(
70 |         () => ({ code: "UNEXPECTED_SUCCESS" }),
71 |         e => ({ code: e?.code ?? String(e) }),
72 |       );
73 | 
74 |       expect({ err, sawAuthSwitchResponse }).toEqual({
                                                  ^
error: expect(received).toEqual(expected)

  {
    "err": {
-     "code": "ERR_MYSQL_MISSING_AUTH_DATA",
+     "code": "ERR_MYSQL_SERVER_ERROR",
    },
-   "sawAuthSwitchResponse": false,
+   "sawAuthSwitchResponse": true,
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/sql/sql-mysql-auth-short-nonce.test.ts:74:46)
(fail) MySQL: AuthSwitchRequest with a short caching_sha2_password nonce is rejected, not OOB-read [2.51ms]
(pass) MySQL: an AuthSwitchRequest frame declaring a zero-length payload is rejected [2.21ms]

test/js/sql/sql-mysql.auth.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daem
... (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/mechgate.xml" test/js/sql/sql-mysql-auth-short-nonce.test.ts test/js/sql/sql-mysql.auth.test.ts
bun test v1.4.0 (6a21fe827)

test/js/sql/sql-mysql-auth-short-nonce.test.ts:
(pass) MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read [545.90ms]
(pass) MySQL: AuthSwitchRequest with a short caching_sha2_password nonce is rejected, not OOB-read [70.78ms]
(pass) MySQL: an AuthSwitchRequest frame declaring a zero-length payload is rejected [57.47ms]

test/js/sql/sql-mysql.auth.test.ts:
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
(pass) caching_sha2_password fast-auth success: the trailing OK belongs to auth, not the first query (split framing) [106.07ms]
(pass) caching_sha2_password fast-auth success: the trailing OK belongs to auth, not the first query (coalesced framing) [41.65ms]
(pass) caching_sha2_password scramble hashes the double-SHA256 before the 20-byte nonce 
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 973ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_sql v0.0.0 (/workspace/bun/src/sql)
�[1m�[92m   Compiling�[0m bun_sql_jsc v0.0.0 (/workspace/bun/src/sql_jsc)
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m   Compiling�[0m bun_bin v0.0.0 (/workspace/bun/src/bun_bin)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 4m 38s
[2/6] cxx obj/codegen/ZigGeneratedClasses.cpp.o
[3/6] link bun-profile
[5/6] strip bun
[5/6] bun-profile --revision
1.4.0-canary.1+6a21fe827
[build] done
bun test v1.4.0-canary.1 (6a21fe827)

test/js/sql/sql-mysql-auth-short-nonce.test.ts:
(pass) MySQL: AuthSwitchRequest with a short mysql_native_password nonce is rejected, not OOB-read [14.90ms]
(pass) MySQL: AuthSwitchRequest with a short caching_sha2_password nonce is rejected, not OOB-read [2.48ms]
(pass) MySQL: 
... (truncated)
diff hotspot
src/sql/mysql/protocol/Auth.rs                 |  13 +-
 src/sql_jsc/mysql/MySQLConnection.rs           |   4 +
 test/js/sql/sql-mysql-auth-short-nonce.test.ts | 101 +++++------
 test/js/sql/sql-mysql.auth.test.ts             | 225 ++++++++++++++++++++++---
 test/js/sql/wire-frames.ts                     |  25 ++-
 5 files changed, 294 insertions(+), 74 deletions(-)

gate history · 3 passed · 0 rejected · iteration 6

evidence per changed file
file                                            reads  edits  tests
src/sql/mysql/protocol/Auth.rs                      4      7      0
src/sql_jsc/mysql/MySQLConnection.rs                4      3      0
test/js/sql/sql-mysql-auth-short-nonce.test.ts      1      3      0
test/js/sql/sql-mysql.auth.test.ts                  1      3      0
test/js/sql/wire-frames.ts                          1      2      0

root cause · written by the author bot

The caching_sha2_password scramble in Bun's MySQL client XORed the fast-auth token over the full 21-byte auth-plugin-data the server sends, including the trailing NUL terminator, rather than over the 20-byte scramble the server actually hashes, so the token never matched and fast auth always failed against MySQL 8. The fix introduces a shared 20-byte scramble length, truncates the nonce to that length when it is read from both the initial handshake and an AuthSwitchRequest, and has the caching_sha2_password scramble function slice to and validate against that length as the native-password p…

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

MySQL authentication now uses a shared 20-byte scramble length, trims handshake and auth-switch nonce data, rejects short nonces, and adds coverage for native, caching SHA-2, fast-auth, full-auth, RSA, and long-password scenarios.

MySQL authentication nonce handling

Layer / File(s) Summary
Centralize scramble length and validation
src/sql/mysql/protocol/Auth.rs
Defines SCRAMBLE_LENGTH and applies it to nonce validation, slicing, and caching SHA-2 scrambling.
Trim handshake and auth-switch data
src/sql_jsc/mysql/MySQLConnection.rs
Limits handshake and AUTH_SWITCH plugin data to the shared scramble length before authentication responses are generated.
Add authentication packet fixtures and short-nonce coverage
test/js/sql/wire-frames.ts, test/js/sql/sql-mysql-auth-short-nonce.test.ts
Adds nonce and packet helpers, authentication markers, and parameterized short-nonce tests for both plugins.
Verify fast and full authentication flows
test/js/sql/sql-mysql.auth.test.ts
Tests warm-cache fast authentication, nonce-derived tokens, long-password full authentication, and RSA payloads across handshake and auth-switch flows.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes fix #26195 by using the 20-byte nonce for caching_sha2_password and adding regression coverage for long passwords.
Out of Scope Changes check ✅ Passed All changes are directly related to the MySQL auth fix and its tests; no unrelated code paths stand out.
Description check ✅ Passed The description explains the problem, cause, fix, affected authentication flows, issue reference, and verification results.
Title check ✅ Passed The title clearly and concisely describes the main change to use the 20-byte nonce for caching_sha2_password scrambling.

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the claude label Jul 6, 2026
@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 AM PT - Aug 12th, 2026

❌ @robobun, your commit 6a21fe8 has some failures in Build #93072 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33504

That installs a local version of the PR into your bun-33504 executable, so you can run:

bun-33504 --bun

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: 6a21fe82 (clean rebase, content identical to what was reviewed), diff green; build red is darwin queue capacity only.

Build 93072: 179 jobs passed, 0 failed. The two darwin 26 aarch64 - test-bun jobs expired waiting for a macOS 26 agent, which is what marks the build failed. No error annotations; this PR's test files (sql-mysql.auth.test.ts, sql-mysql-auth-short-nonce.test.ts, wire-frames.ts) have not appeared in a failure or flaky annotation on any of the builds for this PR. Earlier builds' reds were the same darwin lane (artifact download timeout) plus unrelated tests already handed to main-break triage.

Reviews: CodeRabbit clean (pre-merge checks passing), the automated code review found no issues on each revision, comment-cop threads resolved. 0 open review threads.

Verified locally on the rebased build:

  • bun bd test test/js/sql/sql-mysql.auth.test.ts test/js/sql/sql-mysql-auth-short-nonce.test.ts -> 9 pass
  • same with src/ reverted to origin/main -> 5 fail, one per source hunk

Ready for a maintainer to merge (or to decide between this and #28161).

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(sql/mysql): fix caching_sha2_password auth for passwords > 19 chars #28161 - Also fixes caching_sha2_password auth by truncating the nonce to 20 bytes, addressing the same root cause (trailing NUL in auth_plugin_data_part_2) and the same issue caching_sha2_password auth to MySQL 8.0 not working with passwords longer than 19 chars #26195

🤖 Generated with Claude Code

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Same root cause as #28161, yes, and the handshake hunk is effectively the one @kernoeb wrote in March. The overlap is called out in the description; for whoever triages the flag, the delta is:

I offered to close this if kernoeb would rather carry it (#28161 (comment)). Either way one of the two should land: main still cannot fast-auth against a stock MySQL 8.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this touches MySQL authentication crypto (scramble computation and the RSA-encrypted password path), so it's worth a human confirming the protocol interpretation.

Extended reasoning...

Overview

The PR fixes caching_sha2_password (MySQL 8's default auth plugin) to scramble against the 20-byte nonce instead of the 21 bytes that include the wire protocol's trailing NUL. Native changes are small and surgical:

  • src/sql/mysql/protocol/Auth.rs: adds SCRAMBLE_LENGTH = 20, and caching_sha2_password::scramble now rejects short nonces and slices to 20 bytes — mirroring its mysql_native_password sibling, which already did this.
  • src/sql_jsc/mysql/MySQLConnection.rs: truncates auth_data to 20 bytes both where the handshake stores it and where an AuthSwitchRequest replaces it.

The rest is test infrastructure: new scripted-server tests that derive the expected scramble/RSA plaintext from the password + nonce, a real-container test with a 27-char password (#26195), and a test.each extension of the existing short-nonce rejection test to cover both plugins. wire-frames.ts gains mysqlErrorPacket, MYSQL_MOCK_NONCE, and two protocol constants.

Security risks

This is authentication/crypto code. The change is a correctness fix that aligns with go-sql-driver (authData[:20]), mysql2 (data.slice(0, 20)), and MySQL server's own Generate_scramble, and it makes auth strictly more correct (the previous token could never verify). It doesn't weaken any check — it adds a short-nonce guard and narrows the hashed input to spec. Vec::truncate(20) is a no-op when the vec is already ≤20, and the AuthSwitchRequest path uses .min() so short data isn't over-sliced. Still, changes to how a password is scrambled and how it's XOR-masked before RSA encryption are exactly the class of change where a human should confirm the protocol reading.

Level of scrutiny

Medium-high. The diff is small and heavily cited, the tests are thorough (byte-exact scripted servers plus a real MySQL 8 container), and the bug-hunting pass found nothing. But per the approval guidelines I don't auto-approve changes on auth/crypto paths.

Other factors

No CODEOWNERS cover these files. No prior human review comments on the PR. One existing test was tightened (it previously accepted either 20- or 21-byte nonce behavior and now pins 20), which is intentional and stated in the description.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Fair ask, so here is the evidence for the protocol reading, none of which is bun taking its own word for it:

The nonce is 20 bytes. The server generates it with Generate_scramble (sha2_password_common.cc), which hashes digest_stage2 || m_rnd where m_rnd is SCRAMBLE_LENGTH = 20 bytes, and writes SCRAMBLE_LENGTH + 1 into the handshake's auth_plugin_data_len because it appends a NUL. Clients cut it back off: go-sql-driver authData[:20], mysql2 data.slice(0, 20), Connector/J's Security.scramble411. Bun's own mysql_native_password::scramble already did (nonce[0..8] + nonce[8..20]), which is precisely why native auth worked and caching_sha2 did not.

The tests derive the expectation, not the result. The scripted servers compute the token from the password and the nonce they handed out, and the RSA case decrypts the client's payload with the mock's private key and compares against password + NUL XORed cyclically against the 20-byte nonce. If the protocol reading here were wrong, the tests would be wrong in the same direction and they would still fail, because the unfixed build produces a third value. The fail-before output in the description shows exactly that byte: 78 is the password's 21st character, left unmasked because the NUL was standing in for nonce[0].

A real server agrees. The container case authenticates a caching_sha2_password account with a 27-character password against MySQL 8 over the full-auth path, which is the same #26195 reproduction @kernoeb reported from a live server.

The part worth a human eye, if any, is the call to truncate rather than to strip_suffix(&[0]): truncation matches what the other clients do unconditionally, and it also covers a server that sends a nonce without the terminator.

kernoeb added a commit to kernoeb/bun that referenced this pull request Jul 6, 2026
…itchRequest too

Extends the handshake-path fix to the AuthSwitchRequest storage site and adds
a defensive length guard to caching_sha2_password::scramble, closing the gap
robobun's oven-sh#33504 identified. Truncates by position (not by byte value) to
avoid falsely rejecting a legitimate nonce whose last byte is 0x00.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any issues, but this touches MySQL auth/crypto (password scrambling and the RSA path) and overlaps with #28161, so a human should sign off on the truncate-vs-strip_suffix choice and which PR to land.

Extended reasoning...

Overview

This PR fixes caching_sha2_password (MySQL 8's default auth plugin) so Bun scrambles against the 20-byte nonce instead of the 21-byte auth-plugin-data that includes the trailing NUL terminator. It touches three source sites: adds a SCRAMBLE_LENGTH constant and short-nonce guard + slice to caching_sha2_password::scramble in src/sql/mysql/protocol/Auth.rs, truncates auth_data to 20 bytes after the HandshakeV10 in MySQLConnection::handle_handshake, and does the same for the AuthSwitchRequest path. The rest is test infrastructure — scripted mock servers that verify the exact scramble bytes and RSA-decrypt the client's encrypted password, plus one container test with a 27-char password (#26195 repro).

Security risks

This is squarely security-sensitive: it changes how the client derives the fast-auth token and how it XOR-masks the password before RSA encryption. The change is a tightening (matching go-sql-driver, mysql2, Connector/J, and the server's own Generate_scramble), and the added short-nonce check in caching_sha2_password::scramble closes the same OOB-read hazard that mysql_native_password already guarded. I don't see new exposure — the truncation is applied after concatenation and uses .min() on the auth-switch path so it can't underflow — but any change to a password-scrambling primitive warrants human eyes.

Level of scrutiny

High. Authentication/crypto path, wire-protocol byte semantics, and the fix affects every MySQL 8 connection. The reasoning is well-cited (server source, three reference clients) and the tests derive expectations independently rather than from Bun's own output, which is exactly right. But the author explicitly flagged one design call for review (truncate to 20 vs strip_suffix(&[0])), and there's an open overlapping PR (#28161) from another contributor covering the handshake hunk — a maintainer needs to decide which lands and whether attribution/co-authorship is handled.

Other factors

Test coverage is unusually strong: fail-before evidence is documented, each of the three source hunks is load-bearing for at least one test, and the RSA test decrypts with a mock private key to assert the exact plaintext. The container test wasn't run locally (no docker in the author's environment). No prior human reviews yet; CI was still building at last update.

@robobun
robobun force-pushed the farm/13c083a1/mysql-caching-sha2-nonce branch from a85d166 to 237a708 Compare July 11, 2026 08:29

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't find any bugs, but this touches the MySQL authentication path (password scrambling / RSA obfuscation) and overlaps with #28161, so a maintainer should make the merge call.

What was reviewed:

  • caching_sha2_password::scramble — the new short-nonce guard runs after AuthMethod::scramble's empty-password early return, so passwordless accounts are unaffected; Sha256Password routes through the same helper and picks up the fix.
  • handle_handshake truncate + AuthSwitchRequest .min(SCRAMBLE_LENGTH) — a short handshake/switch nonce still reaches scramble() and is rejected there rather than sliced OOB.
  • Truncate-vs-strip_suffix — checked go-sql-driver/mysql2 and both slice to 20 unconditionally, so truncation is the reference-client behavior.
  • Scripted-server tests derive expected bytes independently and the RSA case decrypts with the mock's private key, so they pin the wire bytes rather than the implementation.
Extended reasoning...

Overview

The PR fixes caching_sha2_password authentication for Bun's MySQL driver by ensuring the scramble is computed against the 20-byte nonce rather than the 21-byte NUL-terminated auth-plugin-data. Source changes are three small hunks: a SCRAMBLE_LENGTH constant plus a length-check-and-slice in caching_sha2_password::scramble (src/sql/mysql/protocol/Auth.rs), and truncation of auth_data to 20 bytes at both sites that store it — the handshake and the AuthSwitchRequest handler (src/sql_jsc/mysql/MySQLConnection.rs). The rest is test coverage: scripted TCP servers that assert the exact scramble bytes and RSA-decrypt the full-auth payload, a container test with a 27-char password reproducing #26195, and a test.each extension of the short-nonce OOB test to cover caching_sha2_password.

Security risks

This is authentication code — it computes the token that proves knowledge of the database password and XOR-masks the plaintext password before RSA-encrypting it. The change narrows the input to the hash (drops a trailing NUL) to match every reference client and the MySQL server's own Generate_scramble. It also adds a defensive length check that turns a potential slice-past-end into an ERR_MYSQL_MISSING_AUTH_DATA. I don't see a way this weakens auth: it makes the fast-auth token verifiable and the RSA obfuscation compatible with the server's de-obfuscation. The truncate-vs-strip-suffix question the author flagged is the right call — reference clients slice to 20 unconditionally, and unconditional truncation also handles a hypothetical server that omits the terminator.

Level of scrutiny

High. This is production-critical protocol code on an authentication path, the fix changes behavior for every MySQL 8 connection, and there is an overlapping open PR (#28161) whose author was offered co-attribution. The author's own status comment explicitly asks for a maintainer to "merge (or to decide between this and #28161)". Per policy I don't approve auth/crypto changes without a human reviewer.

Other factors

The test coverage is unusually strong: expectations are derived from the spec (not from the implementation under test), the RSA path is verified by private-key decryption of the client's actual payload, both nonce entry points (handshake and auth-switch) are exercised, and there's a real-server container regression for #26195. I traced the new short-nonce guard against the empty-password early return in AuthMethod::scramble and confirmed Sha256Password shares the fixed helper. No CODEOWNERS entry covers src/sql/mysql/.

@robobun
robobun force-pushed the farm/13c083a1/mysql-caching-sha2-nonce branch from 237a708 to a4caea4 Compare July 31, 2026 04:28
Comment thread src/sql/mysql/protocol/Auth.rs Outdated
Comment thread src/sql/mysql/protocol/Auth.rs Outdated
Comment thread src/sql_jsc/mysql/MySQLConnection.rs Outdated
@robobun
robobun force-pushed the farm/13c083a1/mysql-caching-sha2-nonce branch from a4caea4 to 26b78ff Compare July 31, 2026 04:32
Comment thread src/sql/mysql/protocol/Auth.rs Outdated
@robobun
robobun force-pushed the farm/13c083a1/mysql-caching-sha2-nonce branch from 26b78ff to d615e63 Compare July 31, 2026 04:35

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found. Deferring because this touches the MySQL authentication path (nonce handling for caching_sha2_password fast-auth and the RSA full-auth XOR), and there's an open maintainer call on landing this vs. #28161.

What was reviewed:

  • caching_sha2_password::scramble now length-checks and slices the nonce to 20 bytes, mirroring mysql_native_password::scramble and go-sql-driver/mysql2/Connector/J.
  • Both nonce-storage sites (handle_handshake and the AUTH_SWITCH arm) truncate to SCRAMBLE_LENGTH; Vec::truncate and .min() are no-ops on short input, so a short server nonce still falls through to the MissingAuthData guard rather than panicking.
  • Sha256Password routes through the same scramble helper, so it picks up the fix without a separate change.
  • Scripted-server tests derive expected token/RSA bytes independently and the container test covers #26195 end-to-end; the tightened test replaces the previous either-nonce .toContain with an exact match.
Extended reasoning...

Overview

The PR fixes MySQL caching_sha2_password authentication by using the 20-byte scramble nonce instead of the 21-byte auth-plugin-data (which includes a trailing NUL). Source changes are ~25 lines across two files: a new SCRAMBLE_LENGTH constant in Auth.rs, a length-check + slice in caching_sha2_password::scramble, and two truncation sites in MySQLConnection.rs (handshake and AuthSwitchRequest). The rest is test coverage: parameterized short-nonce rejection, exact fast-auth token bytes, RSA plaintext verification via privateDecrypt, and a container test with a 27-char password for #26195.

Security risks

This is authentication code. The change strictly tightens behavior — it adds a bounds check against short server-supplied nonces and aligns the nonce length with the MySQL spec and every reference client. Nothing weakens a check or falls back to a laxer default. The truncation is unconditional (matching go-sql-driver's authData[:20]), which the author explicitly flagged as the one design call worth a human eye vs. strip_suffix(&[0]).

Level of scrutiny

Auth-path changes to a database driver warrant a maintainer look even when the fix is spec-conformance. The protocol reading is well-cited (server's Generate_scramble, mysql2, go-sql-driver, Connector/J) and CI is green, but the repo guidance treats auth/crypto as human-review territory.

Other factors

There is an open overlap with #28161 that the author says needs a maintainer to arbitrate ("Ready for a maintainer to merge (or to decide between this and #28161)"). All comment-cop inline threads are resolved. The release without fix: all passed line in the evidence block is expected — those are the container-gated tests that skip without docker; the ASAN debug run shows 5 fail-before / all-pass-after.

@robobun
robobun force-pushed the farm/13c083a1/mysql-caching-sha2-nonce branch from d615e63 to 6c66462 Compare July 31, 2026 05:12

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found, but this is MySQL auth (password scrambling / RSA-encrypted password path) and there's a competing #28161 to reconcile — worth a maintainer pass.

Checked that caching_sha2_password::scramble now length-checks and slices to 20 bytes exactly like its mysql_native_password sibling; Sha256Password dispatches to the same function so it's covered too.
Verified the AUTH_SWITCH truncation uses .min() so a short plugin_data still reaches scramble()'s own guard and surfaces as ERR_MYSQL_MISSING_AUTH_DATA rather than panicking on the slice.
Confirmed the truncated self.auth_data is what feeds EncryptedPassword.nonce on the RSA path, which is the #26195 corruption site.
Container-test tightening (dropped FLUSH PRIVILEGES + allowPublicKeyRetrieval on the warm connection) looks safe under concurrent: true since each test uses a distinct MySQL user.

Extended reasoning...

Overview

The PR fixes caching_sha2_password (MySQL 8's default) so the fast-auth token and the RSA-encrypted full-auth password are computed against the 20-byte scramble instead of the 21-byte NUL-terminated auth-plugin-data. Native changes are ~17 lines across two files: a new SCRAMBLE_LENGTH constant, a length-check + slice in caching_sha2_password::scramble, a .truncate(20) where the handshake stores the nonce, and a .min(20) slice where an AuthSwitchRequest replaces it. The rest is test infrastructure — scripted mock servers that derive the expected token/plaintext independently and a container regression for #26195.

Security risks

This is authentication code. The change narrows the input to a hash and to the cyclic XOR that masks the password before RSA encryption; it does not remove any check or widen any trust boundary. The truncation matches go-sql-driver authData[:20], mysql2 data.slice(0, 20), and the server's own Generate_scramble. I did not find a way for the truncation to weaken auth (a shorter nonce still fails scramble()'s new guard; a longer one is trimmed to what the server actually verifies against). Still, per policy I don't auto-approve auth/crypto paths.

Level of scrutiny

Moderate-high. The native diff is small and mechanical, but it sits on the password-transmission path for the default MySQL 8 plugin, and the container-test tightening (removing allowPublicKeyRetrieval: true from the warm-cache connection and the FLUSH PRIVILEGES from a concurrent sibling) changes what a real-server flake looks like.

Other factors

  • AuthMethod::Sha256Password routes through caching_sha2_password::scramble, so the new 20-byte slice applies there as well — consistent with the protocol.
  • No CODEOWNERS on src/sql/mysql.
  • Comment-cop bot flags on long comments were addressed in d615e63 and the threads are resolved.
  • #28161 covers the same root cause; the author explicitly leaves the choice between the two to a maintainer, which is a human decision either way.

kernoeb added a commit to kernoeb/bun that referenced this pull request Jul 31, 2026
…itchRequest too

Extends the handshake-path fix to the AuthSwitchRequest storage site and adds
a defensive length guard to caching_sha2_password::scramble, closing the gap
robobun's oven-sh#33504 identified. Truncates by position (not by byte value) to
avoid falsely rejecting a legitimate nonce whose last byte is 0x00.
The handshake's auth-plugin-data is the 20-byte scramble followed by a NUL
terminator. caching_sha2_password hashed all 21 bytes, so the fast-auth token
never verified and the RSA path masked the password with a 21-byte nonce,
corrupting passwords of 20 bytes and over (#26195).

Truncate the nonce where the handshake stores it, where an AuthSwitchRequest
replaces it, and inside caching_sha2_password::scramble itself, matching
go-sql-driver's authData[:20] and mysql2's data.slice(0, 20).

Co-authored-by: kernoeb <kernoeb@protonmail.com>
@robobun
robobun force-pushed the farm/13c083a1/mysql-caching-sha2-nonce branch from 6c66462 to 6a21fe8 Compare August 12, 2026 10:34

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The source change is small and the protocol reading checks out against mysql2/go-sql-driver/Connector/J and the server's own Generate_scramble, but since it touches password-handling paths and there's an open maintainer call on landing this vs. #28161, a human look is still worthwhile.

What was reviewed:

  • caching_sha2_password::scramble now length-checks and slices to 20, mirroring mysql_native_password::scramble; Sha256Password routes through the same function so it's covered too.
  • The two truncation sites (handle_handshake and the AUTH_SWITCH arm) — .min(SCRAMBLE_LENGTH) on the auth-switch slice can't OOB, and a short nonce still reaches the MissingAuthData guard in scramble().
  • The FLUSH PRIVILEGES removal and dropping allowPublicKeyRetrieval: true from the warm-cache container test — both tighten existing coverage rather than weakening it, and the concurrent-test cache-eviction reasoning holds.
  • The scripted-server tests derive expected bytes from spec and decrypt the RSA payload with the mock's private key, so they don't just mirror the implementation.
Extended reasoning...

Overview

Two source files change: src/sql/mysql/protocol/Auth.rs gains a SCRAMBLE_LENGTH constant, a short-nonce guard, and a [..20] slice in caching_sha2_password::scramble (matching what mysql_native_password::scramble already does); src/sql_jsc/mysql/MySQLConnection.rs truncates auth_data to 20 bytes at both places it's populated (handshake and AuthSwitchRequest). The remaining ~270 lines are test additions: scripted-server tests that assert exact token/RSA bytes against spec-derived values, a container test for #26195 with a 27-char password, and shared wire-frames.ts helpers.

Security risks

This is authentication/password-handling code. The change is strictly a hardening plus a protocol-correctness fix: it adds a length check that mirrors the sibling plugin's, and it truncates a server-provided buffer before use rather than trusting its length. No security check is removed or loosened. The RSA path (EncryptedPassword) already guards nonce.is_empty(); with auth_data now 20 bytes, its cyclic XOR matches every other client. I don't see a way this weakens anything, but password-handling paths conventionally get a human look.

Level of scrutiny

Medium-high. The source diff is tiny (~17 lines) and mechanically simple, but it sits in a wire-protocol auth path and there's a competing PR (#28161) from an external contributor that a maintainer explicitly needs to arbitrate — the author says as much ("Ready for a maintainer to merge (or to decide between this and #28161)"). That's a human decision, not a code-correctness one.

Other factors

The protocol reading is well-cited (MySQL's sha2_password_common.cc, go-sql-driver authData[:20], mysql2 data.slice(0, 20)) and the fail-before/pass-after evidence is in the description. The existing container tests are modified (FLUSH PRIVILEGES dropped, allowPublicKeyRetrieval defaulted on the warm-cache connection) — both changes tighten what the tests prove and are explained inline, but they do change pre-existing test behavior. All comment-cop threads are resolved. No CODEOWNERS entries cover src/sql/mysql.

Jarred-Sumner added a commit that referenced this pull request Oct 17, 2026
…quests (#45304)

### What does this PR do?

It folds 197 open pull requests on `Bun.sql` (PostgreSQL, MySQL, MariaDB, SQLite), `bun:sqlite` and `node:sqlite` into one change: 149 are replaced by it, 18 are closed without porting, 30 stay open. Most PRs were taken as a diagnosis; the files many of them patched were rewritten once. 257 files, +41,670 / -21,600: `src` +12,536 / -16,054, tests +26,903 / -5,100. CI ran the suite on Linux x64 / aarch64, musl, ASAN, macOS and Windows at this head (78e0830cdd): every job that ran passed (178; one macOS aarch64 job could not download the build, two macOS x64 jobs got no machine). Numbers marked (release) compare CI's Linux x64 release binaries of main (f841e3670) and of earlier commits of this PR, in user-space instructions unless a unit is given; the others are from a local debug + ASAN build.

### How did you verify your code works?

102 SQL and SQLite test files on a Linux x64 debug + ASAN build against PostgreSQL, MySQL and MariaDB containers: 5,918 tests pass; 8 time out on a machine at load 210 to 250, each of which passes alone or is known to depend on load. Nearly every new test was run on main first and fails there; the exceptions are guards that pin behaviour we keep. PostgreSQL parameters against main, cell by cell (value, type, mode): of 942, 726 are equal, 137 work that main rejects, 17 reject with another code, 44 are new errors, 18 stored another value (six cases: two are main's again since, four are listed). The option parser against main: 120,000 generated cases, every difference named; TLS is weaker than on main only on a unix socket (3,191), where `url` names another driver than a variable's name (14), or where the adapter's own URL rules and main read another adapter's (21); no file main opens read-only is writable. Option keys that an options object inherits are read where main reads them. On the wire against main, the startup packet and the number of round trips are the same (no parameter, one, jsonb + text + timestamptz, begin / commit; prepared and `prepare: false`); only a redundant Flush is gone. Client CPU (release, 43ad527e44): point select -48 %, 100-row select -30 %, insert -21 %. The pool, `cancel()`, MySQL pipelining and the `bun:sqlite` hooks were also run under randomized stress or differential scripts.

### Behaviour changes to look at first

`main -> now`. The rule: a visible change stays only where main is broken (crash, hang, data lost or stored wrong, security hole, against its own docs); elsewhere this PR behaves like main, and the pull request that wanted the change stays open. Every bullet was run on main, on this branch and, where there is one, on the reference clients: postgres.js, pg, mysql2, mariadb, Node 26.3's `node:sqlite`, psql 15 (not better-sqlite3). `ERR_*_X` stands for the `POSTGRES`, `MYSQL`, `SQLITE` codes.

**Waiting for the maintainer's word**

- **Integers from 2^51 to 2^53 - 1** were `float8` (MySQL: DOUBLE) -> `int8` (BIGINT). PostgreSQL stored 2^52 + 1 as 4503599627370500 in `numeric` and `4.503599627370497e+15` in `text`; now exact. The price: `select ${2 ** 52} as x` returns a string and `/ 3` divides as integers, as from 2^31 to 2^51 already. On MySQL only VARCHAR was wrong; there `* 4096` is a new errno 1690, and mysql2 and mariadb behave like main (#43293).
- **An array with an enumerable `raw` is refused.** `Object.assign([text], { raw: [text] })`, also what Babel's and swc's loose mode emit for a tagged template, ran `text` as SQL, as in postgres.js -> `TypeError`; tslib and the strict helpers (non-enumerable `raw`) still run. It closes `ids.raw = []` on a data array pasting `ids[0]` into the SQL, which only the application itself can set. The own-property half of the check closes more: on main a polluted `Array.prototype.raw = []` turns `sql([userData])` into SQL text. The price is every tagged call compiled in loose mode (#38219).
- **An untyped `sql.array()` has no cast.** `sql.array(["a", "b"])` without a type was cast to `::JSON[]`, so both examples of main's docs fail -> no cast, the server decides (#41301, #41242). Where it can't tell, main and postgres.js work and this PR doesn't: `select ${sql.array([1, 2])}` is the string `{"1","2"}`, `unnest()` 42725, `array_length()` 42804. Pass the type.
- **`prepare: false`:** an object with its own `toString()` bound to `json` / `jsonb` was that string (decimal.js: the number `1.5`) -> its JSON, as in a prepared query on main (#39452).
- **bun:sqlite `db.transaction(fn)`, the minimal fix.** When `fn` returns a native promise that is already rejected (an `async` function that throws before its first `await`), main committed -> rolls back and returns the promise. All else is as on main: an `async` callback commits at its first `await`, a `throw` behind it keeps the rows, and the docs now warn. The alternative is to refuse async callbacks outright (#45132), which also refuses what works today: `async` without an `await`, an `await` that comes last, a returned fulfilled promise or thenable.
- **bun:sqlite, not changed:** without `safeIntegers` a BigInt parameter above 64 bits keeps its low 64 bits (`2n ** 64n + 5n` binds 5), as on main; a `RangeError` for it (a third of #32841) was in and is reverted. Proposed on this PR: reject below -2^63 or above 2^64 - 1, which still lets `Bun.hash()` output bind but makes a released call throw.

**Silent: the same code gives another result**

- bun:sqlite `changes` also counted rows written by triggers, cascades, FTS5 / rtree shadow tables -> only the statement's own, like node:sqlite and SQLite's `changes()`. An insert through an INSTEAD OF trigger on a view counted what the trigger wrote -> 0. The SQLite adapter's `count` follows (#43306).
- `affectedRows` was `null` on PostgreSQL and SQLite -> rows written, 0 for a `SELECT`, so `r.affectedRows ?? r.count` gives 0 after a `SELECT` (#40434, #40432). MySQL `count` of a write: 0 -> rows affected (#41886). SQLite adapter: `lastInsertRowid` of `INSERT ... RETURNING` `null` -> the rowid; `count` of `WITH ... INSERT` 0 -> 1; `command` is the first keyword: an upsert `UPDATE` -> `INSERT`, but `with ... insert` `INSERT` -> `WITH`.
- `uuid[]`, `int4range[]`, `tsvector[]`, `reg*[]`, 35 built-in array types in all, read as the literal `"{...}"` -> arrays, like postgres.js (pg: `uuid[]` only) (#41042, #41039). Columns named `01` and `1` collapsed into one key -> each keeps its name; `row[1]` no longer finds `01` (#45096).
- Into `text`: a `Date` was `Tue Jan 02 2024 03:04:05 GMT+0000 (...)` -> ISO 8601; a plain object was `[object Object]`, as in postgres.js -> JSON, as in pg (#41976, #29010).
- `null` in `sql.array(values, "TEXT")`, `"INT"` and the rest was the string `"null"` or an error -> SQL NULL (#35111); in a `"JSON"` / `"JSONB"` array it stays the JSON value `null`, as on main. A `Buffer` element there was its hex string -> `JSON.stringify(buffer)`.
- PostgreSQL, a late mode on a statement that is already prepared: `.execute().raw()` gives text for `numeric`, `time` and binary for `int2[]`, `float8[]`, the reverse of main; `.raw()` and a late `.values()` of `numeric`, `time` rejects with `ERR_POSTGRES_UNKNOWN_FORMAT_CODE`. All else of `.raw()` is as on main (658 of 666 cells).
- `prepare: false` sends what a prepared query sends (one more round trip next to an array, an `Int32Array`, an object with its own `toString()`): `[1]` into `jsonb` was the scalar `1`, an `Int32Array` into `bytea` the bytes of the text `1,2`; an `Array` into `bytea` is now `ERR_INVALID_ARG_TYPE` (#39452).
- bun:sqlite `get()` read every row, as documented, so an error in row 2 was thrown -> one step. `SELECT 1 a, 2 b, 3 a`: `{b, a}` -> `{a: 3, b: 2}` (key order). A column named like an index (`"2023"`) now comes first in `Object.keys(row)`, as JS orders keys (#37238). `values()` / `raw()` without columns: `null` -> `[]`. node:sqlite `run()` on a `SELECT` stepped through every row -> one step. All like Node.
- SQLite's `MEMSTATUS` is off: `PRAGMA hard_heap_limit` / `soft_heap_limit` are accepted and no longer enforced, as in node:sqlite (#44197, #44187).
- PostgreSQL: a Flush after every simple Query disarmed the server's idle timers; after an extended query they already worked -> `idle_in_transaction_session_timeout` / `idle_session_timeout` end Bun's sessions on both paths (25P03, 57P05), `listen()` included (docs: `connection: { idle_session_timeout: 0 }`) (#44784).
- After `SET datestyle = 'SQL, DMY'` April 3 read as March 4, as in postgres.js -> any non-ISO style reads Invalid Date, also MDY, which main read right.
- TCP keepalive (60 s idle, 10 probes 1 s apart) on every SQL socket, no option, as in postgres.js (pg, mariadb: none): a query silent for 60 s can reject after a 10 s outage, where main resumed (not run). #44983 asked for `listen()` only.
- `then()`, `catch()`, `finally()`, `run()` started the query a microtask later -> in the call, like `execute()` (#44105). `begin()` / `reserve()` overtook older plain queries -> one queue, oldest first, like postgres.js (#45275). `sql.file(path)` ran at once -> a lazy `Query`, as typed and as in postgres.js.
- A result returned from a `begin()` / `savepoint()` callback came back as a plain `Array` without `count`, `command`, `columns` -> the result itself, like postgres.js; `assert.deepStrictEqual` with an array literal then fails (#45387).
- A statement on an ended `tx`: `ERR_*_CONNECTION_CLOSED` -> `ERR_*_INVALID_TRANSACTION_STATE`, because retry helpers rerun on "connection closed" (#43263).
- With a URL, `host` / `user` / `pass` beat `hostname` / `username` / `password`; without one they lost -> the long names always win. `?path=A` beat `{ path: B }` (also `unix://A` and `{ socket: B }`) -> the option wins, like every other option.
- MySQL / MariaDB ignored `?connect_timeout=`, `?idle_timeout=`, `?max_lifetime=`, `?max=` in the URL -> they set the options (see New).
- MySQL / MariaDB: `close()` and the timers send COM_QUIT, like mysql2 and mariadb. "Connection closed" / "failed to bind and execute query" for most client-side codes -> one sentence per code (#38468). An ERR packet the server sends unasked reaches `onclose` as the server's error, not as `ERR_MYSQL_UNEXPECTED_PACKET` (#40913).
- Driver errors: `stack` is the header line only (it had two `internal:sql/*` frames); a non-`Error` thrown while a parameter is read is no longer wrapped (#44118, #44204). PostgreSQL statement names in server logs: `P` + 40 bytes of SQL + `$` + id -> `P<id>` (#44042).

**Loud: an error where main ran**

- `PGUSERNAME` and `USERNAME` are read as the PostgreSQL user, as main's docs promise; main ignored both. `PGUSERNAME` beats `USER`. `USERNAME` counts where `USER` isn't set, as on Windows, where main logged in as `postgres` (not run there) (#26711, #26684).
- `mariadb://` without a database asked for one named `mariadb`, which no server has, and couldn't connect (1049) -> selects none, like the mariadb connector; the first unqualified table is a 1046. MySQL keeps `mysql`, as main's docs promise; the types said the user name (#44367, #44373).
- MySQL / MariaDB: `sql("db.table")` was one name `` `db.table` `` -> `` `db`.`table` ``, as documented, as on PostgreSQL and SQLite and in mysql2 (mariadb keeps one name). Helper keys (`sql({ "c.d": 7 })`) and aliases split too, so a name with a real dot can't be passed. A name taken from a user can now reach another schema (#24050).
- A query created on `reserved` / `tx` / `sp` and first awaited after `release()`, COMMIT, ROLLBACK or RELEASE ran on the pooled connection, as in postgres.js: a `select` worked, an insert that landed in another caller's transaction resolved and kept 0 rows -> rejects unsent. Typical: ``return reserved`...` `` without `await` out of a `using` block (#43261, #43263).
- `sp === tx` in `tx.savepoint(sp => ...)` -> `sp` is its own handle, like postgres.js, and ends with its savepoint; on main and in postgres.js it kept running in the transaction (#43263).
- PostgreSQL: an array or object bound to `bool` / `int4` / `float8` / `timestamp` was coerced in the client (`[]`: `true`, 0, 0, 2000-01-01), so `delete ... where ${[]}` removed every row -> sent as text, which the server rejects (22P02, 22007) unless it is valid: `[5]` into `int4` is still 5, and `['2024-01-02']` into `timestamptz`, 2000-01-01 on main, is that date (#41976, #41970).
- An Invalid Date was stored as 1970-01-01 on PostgreSQL (#41976) and MySQL -> rejected. MySQL: a function, or an object whose `toJSON()` returns undefined, was bound as `""` (MariaDB: errno 1210) -> `TypeError`, like mysql2.
- bun:sqlite: `stmt.run(new Date())`, a Map, a Promise as the only argument bound NULL, as in node:sqlite; in any other position main throws already -> `TypeError` everywhere. An `ArrayBuffer` alone bound NULL -> BLOB.
- `channelBinding: "require"` / `?channel_binding=require` on MySQL, MariaDB, SQLite was an unknown key and ignored -> `TypeError`.
- A `path` that doesn't exist was dropped and TCP dialed -> `ERR_*_CONNECTION_REFUSED` (postgres.js, pg: ENOENT) (#41570).
- `tls.checkServerIdentity` was never called -> called, and it raises an `sslmode` below `verify-ca` to `verify-full`, so a self-signed certificate without `ca` fails, as in pg and postgres.js (#42054, #41856).
- SQLite adapter: what `onconnect` / `onclose` throw was swallowed -> uncaught exception, as on the other adapters. `?mode=RO`, `?mode=readonly`, `?mode=`, `{ readOnly: true }` opened read-write -> throw (#45153, #45079).
- The guard against `BEGIN` on a pooled client also catches `START\tTRANSACTION` and `/* c */ BEGIN`, and no longer trips on `begin_work()` or copies the query (#41758, #41740).
- A nested `sql.unsafe(text, values)` behind other parameters read the wrong parameter on PostgreSQL, as in postgres.js -> `SyntaxError` (#38220).
- A simple `FETCH` from a binary PostgreSQL cursor decoded `numeric`, `time` and read `int2` / `oid` as 0, `int8` / `uuid` as garbage -> these reject with `ERR_POSTGRES_UNKNOWN_FORMAT_CODE`.
- `COPY ... TO STDOUT` without a stream resolved `[]`, `COPY ... FROM STDIN` hung and wedged the connection -> both reject with `ERR_POSTGRES_COPY_NOT_SUPPORTED` (#45332).

**Main was broken; code that leaned on it will notice**

- A pooled connection that came back inside an open or failed transaction (a reservation after `BEGIN`, `sql.unsafe("select 1; begin")`) went to the next caller, who got 25P02 or lost its writes -> the drivers keep the server's transaction status and the pool closes it. For that status to be current, a failed PostgreSQL query rejects at its ReadyForQuery, like libpq, not at the ErrorResponse. Exempt: plain queries at `max: 1`, where the docs allow `BEGIN` by hand, and a prepared XA branch.
- `idleTimeout` / `maxLifetime` closed connections under a running query, a reservation or a `begin()` transaction -> they never interrupt those, as in postgres.js; past `maxLifetime` a connection takes nothing new, finishes, closes (#30648, #30646, #25405).
- What the caller's own `catch(f)`, `then(_, f)` or `finally(f)` handler throws was swallowed -> an unhandled rejection. MySQL / MariaDB: `begin()` never settled when a statement on `tx` was only started and failed -> settles. (94 cells against main: these 32 differ.)
- Callers waiting for a connection were rejected with the close error of somebody else's connection -> the pool dials for them (#44938). A client-side auth or TLS error before the first connection was cached until restart -> every query dials.
- A second `close()` resolved while a session was still open -> all calls wait for one close, the shortest timeout wins (#43959). `close({ timeout: 0 })` waited for running queries -> closes at once, as documented (#33740, #32038).
- `tx.close()` without `await`: `begin()` resolved with nothing stored, or committed -> one ROLLBACK, no COMMIT, `begin()` rejects (#44799).
- Two `reserved.begin()` at once shared one server transaction -> the second waits. SQLite: a second `sql.begin()` failed with "cannot start a transaction within a transaction" -> it waits. Side effect: the same call awaited inside its own callback never settles, like `max: 1` on a server; use `tx.savepoint()`.
- `where id in ${sql([])}` was a syntax error on the servers -> `in (NULL)`, no row, like postgres.js. SQLite keeps `in ()` / `not in ()`.
- Statement caches were unbounded -> a connection keeps the statements it used last, 1,024 on PostgreSQL and 256 on MySQL / MariaDB (there all connections share the server's `max_prepared_stmt_count`, 16,382 by default), and sends Close / COM_STMT_CLOSE for the rest (#28980, #33190). MySQL: a failed prepare was replayed, even after `CREATE TABLE` -> every use prepares again (#33189, #42720).
- A SCRAM password that SASLprep changes costs a second login (see New), so a wrong one counts as two failed logins per query (main: `max`) and a right one as at most one per `SQL` instance: the rest of the pool waits for the first login, which fills the pool one login later for such a password only. A `password` function that returns a promise is not held: `max` each. Other passwords, md5 and cleartext roles: one login, as before (#41605).
- `$PASSWORD` was sent as the database password -> not read. `postgres:///x` was a socket if `/x` existed -> always database `x` (#41570).
- `sql("a\0b")` returned a rejected promise no `try` could catch -> throws at the call (#45160). A `resolve` / `reject` overwritten on a query with a function that throws hung `close()` -> uncaught exception; under an `uncaughtException` listener it still hangs.
- bun:test: `expect(p).resolves` / `.rejects` / `expect(() => p).toThrow()` never called `then()`, so a lazy `Query` hung `bun test` forever -> they call it once while `p` is pending, like Jest (#19130).
- MySQL / MariaDB: `lastInsertRowid` above 2^53 - 1 was rounded -> exact, a string, or a BigInt with `bigint: true` (#44646). Commands are pipelined (see New). A command of 16 MB or more failed in the client (`ERR_MYSQL_OVERFLOW`, session kept) -> it is sent; over `max_allowed_packet` the query gets the server's 1153 and the connection fails, because the server hangs up. A 0xFB reply (a request for a local file) hung the query -> fails the connection, no file is read.
- MySQL / MariaDB: prepared `TIME(n)` lost its fraction -> `"12:34:56.789"`, padded to the column's precision like `.simple()` (mysql2 and mariadb don't pad) (#31738). `.raw()` on a prepared query gave a string for TIME, a `Date` for DATE, DATETIME, TIMESTAMP -> a `Buffer`, as documented. `caching_sha2_password` over a unix socket couldn't log in -> the password goes in clear, as the server expects there and as mysql2 and mariadb do (#37819).
- bun:sqlite: `{ readonly: true, create: true }` opened read-write -> `TypeError` (#41815). `db.run("a; b; c")` / `exec()` swallowed a runtime error of any statement but the last -> throws there, the rest doesn't run (#37418, #37415, #41010). Running a `Statement` again inside its own `iterate()` loop restarted the loop forever -> the newest run wins, the older iterator throws "This iterator was invalidated" (#32917). Both like node:sqlite.
- node:sqlite: `stmt.get()` on `INSERT ... RETURNING` returned a row although the commit failed (deferred foreign key) -> throws. Node 26.3 has the bug. Undo: 6 lines in `NodeSqlite.cpp`.

**Costs**

- The first `new Bun.SQL()` of a process: 23.3 M -> 25.1 M (+7.9 %); the first bun:sqlite database +4.6 % (release).
- bun:sqlite `prepare` + `finalize`: 21,739 -> 23,284 (+7.1 %, release), because statements report their native memory to the GC: 20,000 dropped 200-parameter statements 465 -> 27 MB.
- More distinct texts than the statement cache holds are prepared again and again. PostgreSQL, 24,000 queries over N texts at `max: 4` (release, limit still 256): N = 1,000: 2.0 -> 4.7 s, client CPU 470 -> 880 ms; N = 300: 1.4 -> 1.6 s. With 1,024 (debug builds) 1,000 texts cost what 300 cost; the step lies between 1,000 and 1,100. A full cache costs the server about 41 MB per connection (41 KB per statement with a join and ten columns); main has no bound.
- A bulk insert with an `undefined` cell on PostgreSQL: +2.1 % (see Memory and performance).

### New

**`query.cancel()` works.** It was typed and documented but did nothing. A query that isn't written yet leaves the queue, is never sent and rejects with `ERR_*_QUERY_CANCELLED`. On PostgreSQL a running query with nothing else written behind it is stopped by a CancelRequest on a second connection, outside `max` (same peer, same TLS settings and certificate check, at most 5 s) and rejects with the server's 57014. The session writes nothing until the server has closed that connection, so a late cancel can't hit the next caller's query. There `reserved.close()` / `tx.close()` also stop the running query (#33370, #44625).

**COPY streams (PostgreSQL).** `query.readable()` is a `ReadableStream<Uint8Array>` for `COPY ... TO STDOUT`, `query.writable()` a `WritableStream` for `COPY ... FROM STDIN`. Bytes only (text, CSV, binary), no row encoder. Backpressure both ways. `close()` / `pipeTo()` resolve when the server has stored the rows and reject with its error; `abort()` sends CopyFail, and so does the end of the transaction, savepoint or reservation that a stream is open on: `begin()` then rejects with that error and rolls back. The query still resolves with `count`. They are web streams, not Node streams, and a `COPY` without parameters always goes out as a simple query (#23350, #15088).

**`result.columns`, `result.statement`.** PostgreSQL: `[{ name, type, table, number }]` (OIDs, like postgres.js); MySQL / MariaDB: `[{ name, type, table, length, flags }]`. `statement` is `{ string, types, columns }`. Both are built on the first read, prepared or not. The runs of a prepared statement share one object, which isn't frozen. On SQLite both stay `null`: capturing them cost a 1-row query +17 % to +34 % (main's release build) (#30037, #26809, #18866).

**Column name transforms (all adapters).** `new SQL({ transform: SQL.camel })` reads column `user_id` as `userId` and writes `user_id` for `sql({ userId })`, `sql("userId")`. Presets `SQL.camel`, `pascal`, `kebab`; one direction with `SQL.toCamel`, `SQL.fromCamel` and so on; or `{ column: fn | { from, to }, undefined: value }`. Names, directions and casing are postgres.js's. With `undefined: value` (`null` counts) a helper leaves no column out, like postgres.js. No `value` / `row` transform, so keys inside json aren't renamed (#36451, #15087).

**`sql.json(value)` (all adapters)** stores any value as JSON: a number, a boolean, `null`, an object or array on SQLite, and a JSON *string* where Bun sends a string as it is (MySQL, MariaDB, SQLite, PostgreSQL with `prepare: false`). On PostgreSQL it returns a small wrapper, elsewhere `JSON.stringify(value)`.

**SCRAM-SHA-256-PLUS, `channelBinding: "disable" | "prefer" | "require"`**, also `?channel_binding=` and `PGCHANNELBINDING`. `tls-server-end-point`, hash as in RFC 5929, RSASSA-PSS included, checked against libpq 15. `require` fails with `ERR_POSTGRES_CHANNEL_BINDING_REQUIRED` before anything derived from the password is sent. Off (`disable`) unless asked for, on the maintainer's decision: the login on the wire is main's. With `prefer`, psql's default, a login fails behind a proxy that terminates TLS but forwards the server's `-PLUS` offer (28000) and with an Ed25519 certificate (#41703).

**SASLprep, by retry.** A non-ASCII SCRAM password typed in another Unicode form than it was set in (NFD, soft hyphen) got 28P01 while psql logged in -> the first login uses the password as typed, like main; on 28P01 from a SCRAM role, if SASLprep (two `replace` and `normalize("NFKC")`, no Unicode tables) changes the string, Bun logs in once more with that form. The pool remembers which form worked; the LISTEN connection does the same (#41605).

**`tls.checkServerIdentity(hostname, cert)` (PostgreSQL, MySQL, MariaDB).** Called once per connection, after the chain verified and before anything is sent inside TLS; replaces the built-in name check. A returned object or a throw fails the connection with that value; a Promise is `ERR_INVALID_RETURN_VALUE`. Not called next to `rejectUnauthorized: false` (SQL half of #42054, #41856).

**Re-prepare after DDL (PostgreSQL).** After `ALTER TABLE`, `DEALLOCATE ALL` or `DISCARD ALL` every run of a cached statement failed forever (0A000, 26000) -> it is prepared again under a new name and the old one closed. The query is re-sent only when nothing is written behind it (a re-send would reorder writes) and outside a transaction block; otherwise it rejects with the server's error and the next run works. 20 concurrent runs after `ADD COLUMN`: main rejects 20 and every later run; now 19 at `max: 1`, 15 at `max: 5`, then none (#35120, #29484).

**`bun --hot` keeps database connections (PostgreSQL, MySQL).** Every save opened up to `max` new connections and never closed the old ones (2 4 6 8 10 12 sessions at `max: 2`) -> a `new SQL()` with equal settings takes over the previous load's idle connections when it would dial. Settings are compared by value; a function counts as changed. A pool of an earlier load closes only when nobody asked it for a connection during a whole load: a replaced pool lives one reload longer, a pool kept on `globalThis` and used is never touched. Session state (`SET`, temporary tables) survives, `onconnect` runs again for a reused connection. A module-level `sql.listen()` shares one LISTEN connection across loads (#25623, #23215).

**MySQL / MariaDB pipelining.** `.simple()` queries and queries whose statement is already prepared go out without waiting for earlier replies; an error fails only its own query. Nothing is written behind a text with a `;` until it has ended, and a packet out of sequence fails the connection. Main's docs promise this; it sent one command at a time. Undo: `BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING=1`. 4 executes through a proxy that holds each reply 30 ms took 124 ms on main, now 31 ms on both servers, 1 write instead of 4 (release) (#44022).

**MySQL: `sha256_password`** was documented, but every login failed with errno 1045.

**Arrays as PostgreSQL array parameters.** `where id = any(${[1, 2]})` (08P01) and `sql({ tags: ["a"] })` into a `text[]` failed -> a JS array or an `Int32Array` bound to a built-in array type goes as an array literal, with NULLs and nesting, also with `prepare: false`. The other typed arrays stay `bytea`; `sql.array(new Float32Array([1.5]))`, which main rejects, puts them into an array column. Into `text`, an untyped position or a user-defined type an array stays `String(value)` (`a,b`), as on main and in postgres.js (pg: `{"a","b"}`), so an array of an enum needs `sql.array(values, "mood")`. A plain array into `json[]` / `jsonb[]` follows `sql.array(values, "JSONB")`: nested arrays are dimensions, `null` is the JSON value, `undefined` is SQL NULL, `sql.json([1, 2])` is one element (that half of #33579, #29551).

**MySQL / MariaDB `foundRows: true`**, also `?foundRows=true`: `CLIENT_FOUND_ROWS`, so `affectedRows` counts rows matched, not rows changed, the default of mysql2 and mariadb. Off by default, as on main (#30848, #30843).

**libpq URL keys and variables.** `?connect_timeout=`, `?sslrootcert=` ... went to the server, which refused the connection, and `?password=` went out in the startup packet. Now read: `host`, `port`, `user`, `password`, `dbname`, `connect_timeout`, `idle_timeout`, `max_lifetime`, `max`, `prepare`, `channel_binding`, `sslrootcert`, `sslcert`, `sslkey`, `sslpassword`. Keys that only tune libpq (`keepalives*`, `target_session_attrs` ...) are dropped. Keys that restrict the connection (`require_auth`, `sslcrl` ...) still go to the server, which refuses them. New variables: `PGUSERNAME`, `USERNAME`, `PGAPPNAME`, `PGOPTIONS`, `PGCONNECT_TIMEOUT`, `PGCHANNELBINDING`.

**bun:sqlite: `db.function()`, `db.aggregate()`**, better-sqlite3's signatures (options `varargs`, `deterministic`, `directOnly`, `safeIntegers`); `inverse` makes a window function. What a callback throws comes out of `get()` / `all()` / `run()` unchanged. A BigInt result outside 64 bits follows the function's `safeIntegers`, like a parameter (#8558, #25703, #1474, #5051).

**bun:sqlite: `db.onInsert`, `db.onUpdate`, `db.onDelete`** `= function (database, tableName, rowid, schemaName)`; `null` removes it. What a hook throws is an uncaught exception at once, and the statement goes on. While a function, aggregate or hook runs, every other call on that `Database`, `close()` included, throws "This database connection is busy executing a query". No commit hook (#12631, #4175). **Small:** `db[Symbol.for("sqlite-type")]` (#22109), `stmt.reader`, `stmt.readonly` (#16469), `stmt.declaredTypes` before the first run.

### Fixed

What is listed above isn't repeated. A line without `->` names a symptom that is gone.

#### Pool and lifecycle

- A pool that lost connections dialed nothing while one was ready (it stayed at 4 of 5 for ever and ran 3 parallel queries on 1 backend) -> it grows back, one dial per waiting caller that no dial in flight covers.
- `execute()` / `begin()` inside `onclose` got the dead connection ("connection must be a PostgresSQLConnection") -> it has left the pool and the waiting callers are redialed for before `onclose` runs (#43958, #44938, #45039).
- A connection the server killed in the read that finished the handshake got `onclose`, then `onconnect`, and stayed in the pool: every later query failed, `close()` hung -> `onconnect` never comes after `onclose`, both drivers (#40913, #45039, #42804).
- `close({ timeout: 2 ** 31 })` overflowed `setTimeout` and killed the queries after 1 ms -> clamped (#32100, #32096). `close()`, then `close({ timeout: 0 })`, left a held reservation running (#43959).
- A replaced `Promise.prototype.finally` could wedge the pool -> a private promise; the connection is released before the caller's reactions run (#44315).
- `q.cancel(); await q` never settled -> rejects (#44799).
- The connect-retry budget read `Date.now()`: endless under `setSystemTime()`, short on a clock step -> monotonic clock (#37960).
- A `password` function that throws `undefined` left the query pending forever. `` sql`a; b`.values().simple() `` ignored `simple()`. `query.active` was always false.
- PostgreSQL: a FATAL error for the running query left the connection in the pool until the FIN, and a session ended while idle reached `onclose` as `ERR_POSTGRES_EXPECTED_REQUEST` -> fails at once with the server's error, e.g. 57P01 (#40913).
- `onconnect` / `onclose` never fired for the `sql.listen()` connection (#41051, #41050).
- `bun --sql-preconnect` with a refused connection ran the script, then exited 1, against the docs -> exit 0.

#### Handles, transactions, savepoints

- `sql.begin(tx => Promise.all([...]))` with one failing task: the other tasks' later statements ran after ROLLBACK in autocommit, so rows of a rejected `begin()` or `savepoint()` stayed -> they reject, on all four databases (#43263).
- `tx.unsafe()`, `tx.file()`, `reserved.unsafe()`, `reserved.file()`, `notify()`, `commitDistributed()` ignored the handle's state and ran after its end (#43249, #43261). `` tx`...`.values() `` on an ended handle threw `.values is not a function` -> a `Query` that rejects when run (#43257).
- `tx.close({ timeout })` whose queries finished in time committed -> rolls back (#44799, #32148); `reserved.close({ timeout })` then lost the pool slot. `close({ timeout: -1 })` left the handle unusable (#32100). A handled failing query was reported again as unhandled.
- `close()` or a lost connection started or rejected identifiers, fragments and unstarted queries, and a handle retained them all -> a query is tracked from start to settle (#44932).
- `tx.close()` and the end of the callback could both end the transaction -> one end. Now that `cancel()` works, `close()` spares Bun's own BEGIN / COMMIT / SAVEPOINT (#44799, #44625).
- Connection lost under a `begin()` whose callback doesn't return: later `begin()`, `reserve()`, `close()` hung -> the slot goes back at the loss (#43205). `reserved.unsafe()` then ran on the pool's next connection (#45039).
- `reserved.release()` during a running `reserved.begin()`: the nested COMMIT committed the next caller's work -> the connection goes back after the transaction (#43255).
- `tx.savepoint(sp => [q1, q2])` sent RELEASE before the queries (#43263). `tx.savepoint("audit", fn)` sent `SAVEPOINT s0` -> `s0_audit` (#45111). A failed ROLLBACK lost the callback's error -> it is the `cause` (#41817).
- The `begin()` callback saw no `AsyncLocalStorage` store on a cold pool, another request's on a busy one -> its caller's (#43652).
- A `reserve()` handle dropped without `release()` kept its connection forever -> released when collected (#35117).
- `sql instanceof Bun.SQL` threw -> true for every handle; `class X extends Bun.SQL {}` threw at the definition -> `new X()` throws "Bun.SQL cannot be subclassed" (#42637). `new Bun.sql(url, { tls: true })` dropped the options and connected in plaintext (#45118).
- SQLite: a failed COMMIT left the transaction open and every later `begin()` failed -> rolled back. After `INSERT OR ROLLBACK`, `RAISE(ROLLBACK)`, `SQLITE_FULL` the rest of the callback ran in autocommit -> nothing more runs (#45065). `close()` didn't reject a running `begin()` (#43116).

#### Options, URLs, environment

- `{ filename: "./dev.db" }` without `adapter` dialed PostgreSQL on host `.`; `SQLITE_URL=x.db` opened `postgres://x.db` (#44483). A server's URL in `filename`, or `hostname`, `port` ... next to it, still make it a connection string. `{ adapter: "sqlite", url }` opened `:memory:` (that half of #38427).
- `{ adapter: "sqlite" }` next to a server `DATABASE_URL` used it as the file name -> `:memory:`. `{ adapter: "mysql" }` without a host next to `DATABASE_URL=postgres://...` dialed that host and timed out -> `MYSQL_URL`, else defaults, and then with the TLS level the passed-over URL asks for, as on main (`tls: false` or `ssl: "..."` replaces it); such a URL without a host fills in as on main (#45080).
- `hostname`, `host`, `path`, `socket` or five explicit PostgreSQL fields next to `DATABASE_URL=:memory:` or another SQLite URL opened SQLite -> the server. `{ url: "postgres://..." }` next to `MYSQL_URL` ran the MySQL driver on it and timed out -> the scheme of `url` wins over the name of a variable (those parts of #41586).
- SQLite: `?mode=rw` created the file; `?mode=rw&mode=ro`, a `mode=ro` next to a `#` (`?mode=ro#x`, `?a=b#mode=ro`), `?mode=ro` + `{ readonly: undefined }`, `{ readwrite: false }` opened read-write -> read-only (#38417, #45079, #45153). `bigint: true` did nothing -> `safeIntegers` (#41816).
- `socket:` and `?socket=`, both documented, did nothing -> aliases of `path`. `mysql://user:pass@/db?socket=...` threw `Invalid URL` -> URLs without a host parse (#37807, #23954).
- A host that starts with `/` (`hostname`, `PGHOST`, `?host=`, `%2F...`) went to DNS -> socket directory. `sslmode=require` on a PostgreSQL unix socket failed -> ignored there, like libpq.
- PostgreSQL: a `path` that is a directory was dialed as it is -> `<directory>/.s.PGSQL.<port>`. The pathname of `unix:///run/a.sock` was also the database `run/a.sock` -> the default database. `SQLITE://` / `FILE:` in `filename` or in a variable were `Invalid URL` or PostgreSQL -> SQLite, as in the string form. A NUL in `path` was dropped and TCP dialed -> rejected.
- `console.log(sql.options)` printed the password, `tls.key`, `tls.passphrase` -> `[REDACTED]` (#41725). `sql.options.sslMode = "require"` meant `disable` -> the next query rejects (#43818).

#### Query building

- ``where ${[sql`a`, sql`or b`]}`` bound one parameter and matched every row on PostgreSQL -> the fragments are joined. ``sql({ ts: sql`now()` })``, `sql({ n: sql.array(a, "INT4") })` failed -> work.
- The MySQL update helper knew one spelling of `ON DUPLICATE KEY UPDATE` -> any (#32040, #32035). Keywords inside strings and comments confused helpers. Each helper copied and lower-cased the whole text so far (#43789).
- `Buffer` / `Uint8Array` elements in `sql.array` stored garbage -> `\x` hex; a typed array stored `{NaN,NaN}` -> its numbers (#36241).
- `sql({ "-1": 1 })` threw. An identifier used in another client's query was quoted for the wrong dialect (#24050). `sql.unsafe(text, values)` read the caller's array when the query ran.
- `globalThis.Array = ...` before the first use of `Bun.SQL` threw "The superclass is not a constructor" (#32558).

#### PostgreSQL protocol, types, errors

- Every pattern of NULLs in a statement's parameters prepared another statement, and none was ever closed, so backends grew to several GB -> the cache key is the text plus the declared types, NULL fits any (#28980).
- A statement name could end inside a multi-byte character, and two texts with the same first 40 bytes could share one (42P05) (#44042). A text that ends in NUL put the stream out of step; extended batches sent a Flush before Sync (#44784).
- An `int4[]` / `float4[]` with a NULL or more than one dimension was rejected on a binary path (`NULLS_IN_ARRAY_NOT_SUPPORTED_YET`, `MULTIDIMENSIONAL_ARRAY_NOT_SUPPORTED_YET`) -> a plain `Array`, `null` for NULL, `float4` elements widened. The elements decide, not the header's has-nulls flag, which the server leaves set after the last NULL is gone (those parts of #45211, #45062).
- A `numeric` of 131,069+ digits read `""` or `"-"` when sent in binary -> to decode, `numeric` and `time` are asked for in text and their binary decoders are deleted (#45014); `int2[]` and `float8[]` are asked for in binary. The values are the same.
- Text arrays had a parser with its own number, JSON, date and bytea decoders -> a tokenizer that hands each element to the scalar decoder. Fixed by it: explicit bounds, `'[0:2]={1,2,3}'`, `indkey::int2[]` (#43335, #43332); `1e-07`, `1e+20` in float arrays, `12345678901`, `1e5`, `[null]` in `json[]`, `-0` (#43336, #45305); `json[]` nested over 100 deep.
- BC dates were Invalid Date, dates past year 9999 were read in local time on the text paths -> one parser, same instant under every `TZ`.
- 65,536 parameters, and on MySQL the second run of a failed prepare or a wrong parameter count, rejected with a plain object (no `stack`, not `instanceof Error`) (#44118, #43992, #43993). A failing query stayed pending forever when `Error.prepareStackTrace` threw -> `stack` is made on the first read (#44204).
- `PostgresError.column` / `.line` held a position in Bun's own source when the server sent none -> absent (#45151, #45064). `detail`, `hint`, `constraint` ... were UTF-16 bytes read as text when non-ASCII. `severity` was translated on a server with another `lc_messages`.
- The Bind format code came from the list of types Bun can decode, so `numeric`, `float4`, `time` were announced binary and sent as text (08P01) -> binary only where value and type match (`bool`, `int4`, `float8`, `bytea`, `timestamp(tz)`); the rest is text (#41976).
- `Date` into `date` / `time` failed (#41976, #29010). `prepare: false` sent a `Date` as `toString()` and an object as `[object Object]` (#39452, #29010, #30221, #39450). decimal.js / Temporal values into `numeric`, `time` failed; `URL`, `DataView` threw; a bigint outside int64 failed -> `numeric`.
- A CopyBothResponse hung the query -> "Streaming replication is not supported".
- Ten codes that nothing can produce any more are gone: six `ERR_POSTGRES_*` (`*_NOT_SUPPORTED_YET`, `UNSUPPORTED_NUMERIC_FORMAT`, `INVALID_QUERY_BINDING`, `OVERFLOW`, `INVALID_BYTE_SEQUENCE_FOR_ENCODING`), four `ERR_MYSQL_*` (`INVALID_QUERY_BINDING`, `*LOCAL_INFILE*`, `PASSWORD_REQUIRED`).

#### PostgreSQL authentication and TLS

- `?channel_binding=` was forwarded as a startup parameter (42704). A server that offers no known mechanism got a SCRAM-SHA-256 answer anyway -> `ERR_POSTGRES_SASL_NO_KNOWN_MECHANISM`. A mandatory extension (`m=`) is rejected before the proof (#41703).
- A SCRAM iteration count out of bounds failed with `ERR_POSTGRES_INVALID_MESSAGE` "Failed to read data" -> `ERR_POSTGRES_SASL_ITERATION_COUNT_OUT_OF_RANGE` with the count and the bounds (the error of #38133). A GSSAPI / SSPI request showed an unfilled format string -> "GSSAPI authentication is not supported".
- Over TLS the StartupMessage (user, database) was queued when the handshake started -> written once chain and identity are accepted.
- A TLS 1.2 server that renegotiates reports a second handshake: both drivers sent their login again, and the next query got the reply to it -> ignored after the first.

#### MySQL / MariaDB

- Two queries on one connection could resolve with each other's rows when a parameter's `toJSON` started a query (#32005). `BUN_FEATURE_FLAG_DISABLE_SQL_AUTO_PIPELINING=1` hung every prepared query (#44022).
- One INSERT with K nullable columns was up to 2^K server statements, and statements piled up until the server ran out of memory (a production report is on #33190) (#33190, #28980).
- `caching_sha2_password`: the scramble hashed 21 bytes (nonce + NUL), so fast authentication always failed: plain TCP needed `allowPublicKeyRetrieval: true` at every login, not just the first; a password of 20+ bytes failed with errno 1045 -> 20-byte nonce (#33504, #28161, #26195).
- A length that points past its packet's end hung the query or decoded the next packet as data (a phantom row) -> `ERR_MYSQL_MALFORMED_PACKET` (#35118).
- A `Date` before 1970 was rejected -> years 0 to 9999 (#31209). Prepared `TIMEDIFF()`, `SEC_TO_TIME(3661.25)` lost their fraction (#31738). MariaDB: every object or array parameter failed with errno 1210 (#37125). `DataView` was rejected.
- An idle connection that got a query didn't ref the event loop: exit 0 with the query in flight, or a spinning core under top-level await (#38163, #27102). A short `maxLifetime` with a slow handshake rejected the first query.
- Every `close()` counted as an aborted client on the server (`Aborted_clients` +3 after 3 closes -> +0).

#### SQLite adapter

- A valid `SELECT` resolved `[]` (`'%"%'` in the text, `/*c*/select`, `select*from`, `values (...)`), and `INSERT ... SELECT`, `WITH ... INSERT` reported `count` 0 -> the adapter asks SQLite whether the statement returns columns instead of tokenizing (#33583, #30811).
- A text whose first statement returns rows (`sql.unsafe("SELECT 1; INSERT ...")`, a migration file that starts with a `PRAGMA`) ran only that statement -> every statement runs; the result is the first statement's rows. Only the first statement can have parameters: a later one that has any is refused, where main bound NULL or skipped it.
- Named parameters on a nested `sql.unsafe()` were dropped and NULL was bound: `delete ... where name is not ${sql.unsafe("$o", { $o: "bob" })}` emptied the table -> `SyntaxError`. `sql.unsafe("... = ?", 0)` (also `""`, `false`, `0n`) bound NULL -> binds the value. PostgreSQL: a fragment with a second argument that is no array, in front of a parameter, sent `$NaN` -> works (those parts of #44745, #44867).
- More than about 640,000 rows rejected with `RangeError: Maximum call stack size exceeded` (#38438). `sql.flush()` threw -> no-op (#41816).
- Through the adapter too: multi-statement errors (#37418), `byteOffset` (#45229); a NUL in a file name throws from `new SQL()` (#37006).

#### bun:sqlite

- `{}`, `{ create: false }`, `{ readonly: false }`, `{ readwrite: false }` failed with `SQLITE_MISUSE`, `{ create: false, strict: true }` created the file -> the mode comes from `readonly`, `create`, `readwrite` only (#44908, #32422, #9410, #15876).
- `SQLiteError.byteOffset` counted from the failing statement -> from the start of the text (#45229).
- A kept statement returned rows with the old keys, or values under wrong keys, after `ALTER TABLE` (#32841, #1332); `stmt.columnNames` / `declaredTypes` still update at the next run, as in Node. `SELECT 1`: `row["1"]` was `undefined` (#37238, #18326).
- `{ name: 1 }` for `$name` bound NULL (default), `{ $name: 1 }` threw (strict) -> both work (#13409, #18456). Strict `$1` read key `0` (#37240).
- UTF-16 strings: a lone surrogate read back as three U+FFFD, a leading U+FEFF was dropped, a leading U+FFFE byte-swapped the rest (#32841). A whole number from 2^51 on was stored as REAL (#43281). `db.prepare(sql, "x")` ignored the binding (#25472, #45367); a lone value is still dropped unless the statement has one parameter. A bind that threw in `prepare()` kept the file open after `close()` until a GC.
- `break` out of `iterate()` left the cursor open (`SQLITE_LOCKED`, "database is locked") -> reset (#32917).
- A NUL byte in a file name, extension path or schema name cut the string short -> throws (#37006, part of #38514). `Database.setCustomSQLite()` returned `true` where it does nothing (#36371).
- A bind failure was a plain `Error` -> `SQLiteError`. SQL text over 1e9 bytes failed as a whole -> the limit is per statement. System-library builds asked `dlopen` for `sqlite3` -> `libsqlite3.so.0` (#26629).

#### node:sqlite

- `db.close()` / `using db` kept the file and `-wal` / `-shm` open until every `StatementSync` was collected (`EBUSY` on Windows) -> `close()` finalizes the connection's statements and releases the file at once, like Node (#40005, #41890, #40001).
- `stmt.get()` built a new Structure per call (#44103, #44084). A `Proxy` (Vue `reactive()`, MobX), a module namespace or a `String` object as named parameters bound NULL to everything. `applyChangeset()` errors had an `errstr` Node's don't have (#34453).

#### Crashes and memory safety

- PostgreSQL: a query started from a parameter's `toString()` / `toJSON()` on the same connection panicked (`@intCast`) with `prepare: false`, or gave 08P01 plus two queries that never settle -> parameters are converted first, writing Bind runs no JS, and the nested query runs behind the outer one (#43918).
- `sql.options.idleTimeout = -1` (or `"abc"`, `{}`) panicked at the next connection, both drivers (#43818). `run()` on the private PostgreSQL query handle with a missing argument panicked (SQL half of #44897).
- MySQL: a later parameter's `toJSON` that shrank an earlier resizable `ArrayBuffer` segfaulted -> bytes are read after all user code ran (MySQL half of #38289, #38277).
- bun:sqlite: `db.fileControl(op, null)` segfaulted, `(op)` threw -> storage Bun owns (#44089, covers #36301). A `Database` could be closed under its own running `serialize()` (#41611). `RAISE(ABORT, '')` hit a debug assertion (part of #43797).
- bun:sqlite, node:sqlite: a 2^30-character string aborted the process -> `RangeError` (parts of #42868).

#### Memory and performance

- GC cells per awaited `select 1` (release): PostgreSQL 84 -> 28, MySQL 76 -> 27, SQLite 52 -> 33 (whole PR; #44105, #44315). Per cached PostgreSQL query: 47,770 -> 23,291 instructions (-51 %).
- A bulk insert of 1,000 x 10, end to end (release, 43ad527e44, the union of keys still in): PostgreSQL -38.0 %, MySQL -52.6 %; with one `undefined` cell +2.1 % and -15.7 %.
- The PostgreSQL read buffer was never compacted while reads ended inside a message (rows larger than a socket read): a 1,000 MiB COPY stream grew RSS by 1,235 MB without the fix. Now (release): 255 MiB/s, peak RSS 59 MiB.
- bun:sqlite (release): `all()` per row, `get` by key and an insert of 3 ints cost 4,613, 8,375 and 4,489 on main, now 3,225, 6,502 and 3,381; 29 of 31 cases are cheaper. Rows of 63+ columns made one Structure per row -> one per statement, 1,665 -> 310 per cell (#31669); an `exec()` of 80,000 statements 5.34 -> 0.11 s of CPU (#45207).
- bun:sqlite: the `Database` is a GC cell that owns its connection (#41611), so dropped `deserialize()` databases and failed opens (#34898) no longer pile up: 40,000 of the first +3,016 -> +3.9 MB. #28911's `db.query()` loop 953 -> 92 MB; text over 64 KB isn't cached (#28912, #28911). Workers shared SQLite's malloc mutex -> configured once per process, `MEMSTATUS` off and `MULTITHREAD` (#44197).
- node:sqlite (release): a 5-column `get()` 13,458 -> 4,077 (#44103); all 10 cases are cheaper.

#### Tests and CI

- **The PostgreSQL test containers crash-restarted under load** (57P03 "the database system is in recovery mode"). We infer: docker kills the `sh -c pg_isready` health check at its 5 s timeout, the postmaster (PID 1) adopts the orphan and takes its exit 2 for a crashed backend. `test/docker/docker-compose.yml` now has an exec-form check and `init: true`.
- `sql-tls-ip-literal-host.test.ts` failed 16 of 108 runs on main (mock on 127.0.0.1, client dialed `::1`) -> 0.
- `postgres_auth` had the md5 and SCRAM verifiers swapped and no test used it -> fixed; the login tests are in `test/js/sql/postgres-auth.test.ts`.
- MariaDB runs the whole `sql-mysql.test.ts` matrix. `prestart-map.mjs` is complete (#35912, #44043), and `test/internal/prestart-map.test.ts` fails when a test's container is missing. The `isDockerEnabled()` wrappers are gone, so `BUN_TEST_SERVICE_*` overrides run the suites (#37123, SQL part of #39850).
- Ported test-only PRs: #36948 #37114 #38221 #40193 #40194 #40537 #40633 #41104 #41176 #42587 #44035 #44850.

#### Docs and types

- `docs/runtime/sql.mdx` was rewritten.
- Types: ``await sql`...` `` carries the result metadata (`SQL.ResultMetadata`) (#40434, #30037); `release()` is `Promise<void>` (#43255); `SavepointSQL` has `savepoint()`; bun:sqlite options accept `undefined` under `exactOptionalPropertyTypes` (#45079).
- `onconnect` / `onclose` were documented as receiving a client -> an error or `null` (#38625, #38634). `timestamp` decodes as UTC (#39881, #39879). Multi-statement files are atomic on PostgreSQL only (#41744). Also #41813 (SQLite codes, placeholders, MySQL DDL), #44998 (what a savepoint holds), #44373 (defaults), #42206 (SQL half), #41094.
- bun:sqlite: examples call `db.run()`, not the deprecated `db.exec()` (#24482); typings and docs for `safeIntegers()` (#26627), `isFinalized`, `toJSON()`, `clearQueryCache()`; `get()` / `all()` without arguments reuse the last bound values (#31747, #26622).

### Found and not fixed

"Also on main" is marked (m).

- **Worth a close read:** `signature_digest_nid` in `src/boringssl_sys/boringssl.rs` is new unsafe FFI over a certificate the peer chooses, and `on_handshake` now runs user code.
- **node:sqlite, macOS:** #45171 (read-only open of a WAL database whose sidecars are gone) isn't fixed and gets easier to hit, because every `close()` is now a real close.
- **Options, decided by the maintainer: as on main.** `NODE_TLS_REJECT_UNAUTHORIZED=0` also switches off `sslmode=verify-ca` / `verify-full` (#41589). `new SQL({ hostname })` takes the other fields of an environment URL, its user and password too (#41586). A `TLS_*` URL is taken as written: `?sslmode=disable` or `tls: false` make it plaintext. `PGDATABASE` / `MYSQL_DATABASE` / `MARIADB_DATABASE` beat the URL's database (#45276, #45287). The hardening was in this PR, is out again, and stays open in those pull requests.
- **Pool (m):** no lazy or `min`-sized pool (#30631, #30632, #30636), no `AbortSignal` (#23175). Queries assigned to a connection but not sent yet die with it (#44924). At `max: 1` with a `BEGIN` by hand, `idleTimeout` / `maxLifetime` can close the connection between two statements (documented). Other callers' queries already sent behind a query that leaves a transaction open are rolled back with it.
- **`--hot`:** a kept pool that nobody asks for a connection during a whole load has its idle connections closed and dials again on demand. An uncleared `setInterval` of an earlier load that queries makes its old pool fill again (m). `listen()` callbacks of earlier loads keep running (m).
- **cancel():** a running PostgreSQL query with another one written behind it rejects at once but runs to its end on the server, as on main. MySQL / MariaDB (m): a query on the wire runs to its end. No `KILL QUERY` is sent: it names a session, and a late kill hit the session's next query in 34 % to 88 % of our tries, depending on server and set-up (#44625).
- **MariaDB XA**, `max > 1` (m): the connection that prepared a branch answers other callers with 1399, and `commitDistributed()` elsewhere gets 1397. New: `reserved.beginDistributed()` directly followed by `reserved.release()` closes the connection.
- **PostgreSQL (m):** `real`, `int4[]` and `float4[]` follow the wire format: `0.1` / `Array` in text, `0.10000000149011612` / a typed array in binary (#41888, #45211). A string bound to `json` / `jsonb` is stored as a JSON string when prepared, like postgres.js, and sent as it is with `prepare: false`, like pg; so `${JSON.stringify(obj)}` stores a string (#28819, #40944). Arrays of user-defined types read as the literal `"{a,b}"` (#41040), composites as `"(1,q)"` (#20379). A fractional number into `numeric` keeps 15 digits, a bare number into `json` / `money` fails (42804). `prepare: false`: a `Date` into `time`, `json`, `jsonb` is rejected.
- **PostgreSQL auth:** `prefer` doesn't stop a server from asking for a cleartext or md5 password; `require` does. (m): the SCRAM iteration count must be within 4096..10,000,000, deliberate hardening; libpq, postgres.js and pg accept any count >= 1. Parity is the range check in the SASLContinue arm of `PostgresSQLConnection.rs` (#38133).
- **MySQL:** `sha256_password` over a unix socket still needs `allowPublicKeyRetrieval: true`. (m): FLOAT reads `0.1` from `.simple()`, `0.10000000149011612` prepared, like mysql2 (#41888); BIGINT above 2^31 is a string; after `USE` a cached text still runs in the OLD database, silently; a multi-statement text that `KILL QUERY` hits between two statements never settles (`maxLifetime` bounds it).
- **SQLite:** on every adapter `transform.undefined` skips `sql.unsafe()` values. `db.prepare("a; b").run()` runs only `a`, like node:sqlite (documented).
- **Not reproduced on Linux, no claim:** #24130 #24845 #24434 #23305 #30494 #16844 #17173 #23654 #32099.

### Decisions reversed while writing this

- `--hot` was first going to close the old load's connections; the maintainer overruled it. "Close when the settings changed" then closed pools the program still used; now use decides.
- The MySQL statement cache was going to be keyed on the text alone, like mysql2 and mariadb. MySQL >= 8.0.22 settles a parameter's type at its first non-NULL value, so `WHERE name = ${"bob"}` after `${5}` returned wrong rows: one statement per text and set of non-NULL types.
- Ed25519 under `prefer` was going to fall back to unbound SCRAM, as in #41703; psql 15 fails, and a fallback lets whoever picks the certificate switch the binding off.
- `SQLITE_DEFAULT_WAL_SYNCHRONOUS=1` (#20662) was in and is reverted: an upgrade must not lower the durability of a COMMIT.
- On the maintainer's decision: a bulk insert takes the first row's keys as its columns, as on main and in postgres.js, not the union of all rows' keys (half of #35111); `int4[]` / `float4[]` and `real` follow the wire format, not one form on every path (#45211, #41888); `console.log(rows)`, `Object.keys(rows)` and a spread list `count`, `command`, `lastInsertRowid`, `affectedRows`, as on main (#38443; `columns` and `statement`, new, are not listed); MySQL's default database stays `mysql`.
- Five more are main's again, because main works there and nobody asked: a failing query that nobody handles no longer ends the process; the first query fills the pool to `max` (the lazy pool, #30636); a caller whose dial is refused waits for a connection that is reserved or in a transaction; PostgreSQL `.raw()` returns main's bytes, not text; in `sql.array(values, "JSON" | "JSONB")` `null` is the JSON value and a nested array a dimension.
- Taken out before the merge, because main works there. Values: a string into `json` / `jsonb` sent as it is (#40944), a JS array into `text` as an array literal, `Infinity` untyped, typed arrays by the server's type, `foundRows: true` by default, `not in (NULL)` for an empty list on SQLite, the result mode fixed at `execute()`. Errors: for an `async` `db.transaction()` callback (#45132), `parts.map(sql.unsafe)`, `sql.unsafe(text, 0)` (#44745, #44867), `new Database(path, true)`, `{ readonly: true, create: true }` on the SQLite adapter, `prepare: false` on MariaDB. Options: `?` inside a plain SQLite file name (#38427), `new SQL("")` reading the environment.

### From outside contributors

PRs by people, team members included. Every item is reimplemented from the idea unless it says otherwise.

- #40913 @dylan-conway: his mechanism as it is (`onconnect` at the end of the read, only if still connected), ported by hand to both drivers. Also the error of his #38514.
- #25623 @eriklangille: the diagnosis in #23215 and the goal, reuse across `--hot` reloads. Other mechanism: connections move between pools in `pool.ts`, no GC hook.
- #23350 @gohryt: the request for COPY and the scenarios to test. Other API.
- #36451 @pjb3: the `transform` option on all adapters, with postgres.js's directions, applied to the row shape.
- #8558 @nikeee, #25703 @jdjkelly: user-defined functions; the name `function`, option names, arity rule, test ideas.
- #12631 @jecquas: `sqlite3_update_hook`, in the shape its review suggested.
- #31669 @alii: one cached Structure for rows wider than 62 columns. #31209, #31738 @alii: `Date` parameters before 1970, fractional seconds of binary TIME.
- #43789 @ralyodio: the diagnosis that `detectCommand` copies and lower-cases the whole text per helper. #45151 @RubenPari: the cause (every JSC `Error` has its own `line` / `column`) and the `delete`.
- #26711 @RajeshKumar11: `PGUSERNAME`. #45287 @wahajahmed010 (the URL's database above `PGDATABASE`) stays open: decided as on main.
- #22109 @himself65: `Symbol.for("sqlite-type")`. #26627 @0xh3x: `safeIntegers()` typings. #31747 @Artoria2e5: the JSDoc correction. #24482 @jobansd: `exec` -> `run`, the same 15 edits. #41094 @deepshekhardas: trailing spaces.
- Not taken, with thanks (reasons below): #26503 @S4N-T0S, #31751 @EffortlessSteven, #27442 @sosukesuzuki (cases of both are tests now), #22934 @nektro, #16884 @chrisbenincasa, #25836 @alimd, #26880 @theshadow27, #31078 @countradooku, #25403 @abelcha, #44854 and #44860 @deepshekhardas.
- Not in this set: #41890 @steipete (only statements a `StatementSync` owns may be finalized), #28161 @kernoeb (found the 21-byte nonce first). Reports and measurements: @bompus (#44084, #44187), @gerardp (#35120, his burst script is a test), @Dex4Sure (#38163), @filipesarturi (#33190), @KilianB (#30037).

### Replaces

#8558 #12631 #22109 #23350 #24482 #25623 #25703 #26627 #26711 #28912 #30037 #30648 #30848 #31209 #31669 #31738 #31747 #32040 #32100 #32422 #32558 #32841 #32917 #33189 #33190 #33370 #33504 #33579 #33583 #33740 #34453 #34898 #35111 #35117 #35118 #35120 #35912 #36241 #36301 #36371 #36451 #36948 #37006 #37114 #37123 #37125 #37238 #37240 #37418 #37807 #37819 #37960 #38163 #38219 #38220 #38221 #38417 #38438 #38468 #38625 #38634 #39452 #39881 #40005 #40193 #40194 #40434 #40537 #40633 #40913 #41042 #41051 #41094 #41104 #41176 #41301 #41570 #41605 #41611 #41703 #41725 #41744 #41758 #41813 #41815 #41816 #41817 #41886 #41970 #41976 #42587 #42637 #43116 #43205 #43249 #43255 #43257 #43261 #43263 #43281 #43293 #43306 #43335 #43336 #43652 #43789 #43818 #43918 #43958 #43959 #44022 #44035 #44042 #44043 #44089 #44103 #44105 #44118 #44197 #44204 #44315 #44373 #44483 #44625 #44646 #44784 #44799 #44850 #44908 #44932 #44938 #44983 #44998 #45014 #45065 #45079 #45080 #45096 #45111 #45118 #45151 #45153 #45160 #45207 #45229 #45305 #45332 #45367 #45387

### Closed without porting

- #16884: the compile option is already on main
- #22934: those suppressions are already gone (#30875)
- #25403: would make the private adapter interface public
- #25836: part landed in #43649, two lines mistype
- #26503: four claims already fixed on main; default `sslmode` stays `disable`
- #26880: superseded by the author's own #26884
- #27442: already fixed on main (#35246)
- #31078: nothing can call `interrupt()` while a statement runs
- #31751: no observable bug on a real server
- #34435: `numeric` is read as text; binary decoder deleted
- #34673: its Structure slots are never freed on eviction
- #35738: the mock repeats a real-server test
- #35753: duplicate export already removed (#44614)
- #37544: main already holds statements in `RefPtr` (#40516)
- #37620: its leak was fixed on main (#40238)
- #44040: superseded by #44105's synchronous start
- #44854: makes the page's spelling less consistent
- #44860: makes the guide's wording less consistent

### Left open

- #26884: backup API is a product decision
- #30636: main's docs promise the eager fill; a lazy pool is a product decision
- #34281: `mkdir -p` on open is a product decision
- #34863: `AsyncDatabase` is a product decision
- #36712: the `better-sqlite3` alias is a product decision
- #37013: SQL part obsolete since #44618; the rest is not SQL
- #38133: the SCRAM floor is kept; only its better error is here
- #38289: MySQL half is here; `Bun.Image` is not SQL
- #38427: its `url` half is here; the `?` half changes what main opens
- #38443: `console.log(rows)` keeps showing the four properties, as on main, decided
- #38514: bun:sqlite part is here; the rest is not SQL
- #39850: SQL gates are here; the rest is not SQL
- #40250: the SQL code it repairs is deleted here; the rest is not SQL
- #40944: main works, like postgres.js; the change breaks stored data
- #41586: two parts are here; the rest is decided: as on main (Limits)
- #41589: decided: as on main (Limits)
- #41888: `real` follows the wire format, as on main, decided
- #42054: SQL half is here; the Redis half is not
- #42206: SQL half is here; the `nodejs-compat` half is not
- #42868: SQLite parts are here; the rest is not SQL
- #43797: SQLite half is here; the V8 addon half is not
- #44745: its SQLite part is here; `parts.map(sql.unsafe)` works on main and in postgres.js
- #44867: its SQLite part is here; on the servers `sql.unsafe(text, 0)` runs on main
- #44897: SQL half is here; the node:http2 half is not
- #44924: handing unsent requests back can half-commit at `max: 1`; needs a maintainer's yes
- #44962: SQLite 3.54.0 should merge, and revert, alone
- #45132: an `async` callback without `await` works on main
- #45211: NULLs and dimensions on the binary paths are here; typed arrays stay, decided
- #45287: decided: as on main (Limits)
- #45437: a source break in the types, on its own

### Partly addressed

- #39877: a bare key binds; an unknown key in default mode still binds NULL (`strict: true` throws).
- #43113: not reproduced; the one route found to its errors (an open `iterate()` cursor) is closed.
- #19130, #40077: `expect()` no longer hangs on a lazy `Query`; #19130's own script (Windows) didn't hang on Linux, #40077's other items are untouched.
- #27102: the process no longer exits with a query in flight; its `CONNECTION_CLOSED` under load wasn't reproduced. #38277: MySQL half only.
- #15088: its COPY item. #15087: `SQL.toCamel` and friends cover its SQL use. #18866: `columns` and `statement`, not `state`. #23175: `cancel()` works, no `signal`. #16665: `new Bun.sql(url, options)` applies the options.
- #39450: Date and object are fixed; a Date into `time` / `json` with `prepare: false` still differs.
- #28819, #40942: not changed, see Limits; `sql.json()` and the docs now say how to store each kind of value.
- #26629: changed by reading, no such build was made. #16717: documented (macOS reports Apple's version). #26369: already fixed on main (#44618).
- #44187: memory statistics and the connection mutex are gone; scaling like Node was measured on a loaded box only.
- #20662: not changed; the docs give the `PRAGMA synchronous = NORMAL` tip.
- #24662: async support isn't added; only a promise that is already rejected at the return rolls back, and the docs warn.
- #45276: not changed, decided as on main (Limits).

### Issues fixed

Fixes #1332.
Fixes #1474.
Fixes #4175.
Fixes #5051.
Fixes #9410.
Fixes #13409.
Fixes #15876.
Fixes #16469.
Fixes #18326.
Fixes #18456.
Fixes #23215.
Fixes #23954.
Fixes #24050.
Fixes #25405.
Fixes #25472.
Fixes #26195.
Fixes #26622.
Fixes #26684.
Fixes #26809.
Fixes #28911.
Fixes #28980.
Fixes #29010.
Fixes #29484.
Fixes #29551.
Fixes #30221.
Fixes #30646.
Fixes #30811.
Fixes #30843.
Fixes #32005.
Fixes #32035.
Fixes #32038.
Fixes #32096.
Fixes #32148.
Fixes #37415.
Fixes #39879.
Fixes #40001.
Fixes #40432.
Fixes #41010.
Fixes #41039.
Fixes #41050.
Fixes #41242.
Fixes #41740.
Fixes #41856.
Fixes #42720.
Fixes #42804.
Fixes #44084.
Fixes #44367.
Fixes #45039.
Fixes #45062.
Fixes #45064.
Fixes #45275.
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

caching_sha2_password hashed all 21 bytes of auth-plugin-data, terminating NUL included, so the fast-auth token never verified and the RSA path XORed the password modulo 21. Both showed on main, on MySQL 8.4 and 8.0: a warm account over plain TCP still got ERR_MYSQL_PUBLIC_KEY_RETRIEVAL_NOT_ALLOWED, and with allowPublicKeyRetrieval: true a password of 20 bytes or more failed with errno 1045 (#26195).

The fix in #45304 is the same, in another shape. This PR cuts the nonce in three places. Now the nonce is [u8; 20] by type (Nonce in src/sql/mysql/protocol/Auth.rs), filled by one nonce() function at the two places that store it, the handshake and the AuthSwitchRequest. Both scrambles take &Nonce, so their length and empty-nonce checks are gone.

After the first login, fast auth now works with default options, and any password length works on the RSA path.

The core tests moved to real servers. "caching_sha2_password with a password of 6 / 19 / 20 / 32 bytes" in test/js/sql/sql-mysql.auth.test.ts logs in cold, with the opt-in, warm, and warm with a wrong password. It runs on mysql_plain (handshake nonce) and on mysql_native_password, where the same account is reached through an AuthSwitchRequest. The mock test on main that accepted either the 20- or the 21-byte token now accepts only the 20-byte one. Short nonces are in test/js/sql/sql-mysql-auth-short-nonce.test.ts.

On the same code, #45304 also makes sha256_password work (over TLS, or with the opt-in); it always failed with 1045. @kernoeb found the handshake half first in #28161, and the description of #45304 says so.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

caching_sha2_password auth to MySQL 8.0 not working with passwords longer than 19 chars

2 participants