Skip to content

4.0.0 release prep (part 2): injection, memory-safety and date fixes; CRUD 3.0.1 - #73

Merged
taplin merged 18 commits into
mainfrom
release-prep
Oct 13, 2026
Merged

taplin merged 18 commits into
mainfrom
release-prep

Conversation

@taplin

@taplin taplin commented Oct 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

Finishes Perfect-MySQL 4.0.0 release prep. #72 merged the earlier part of this branch (e0b8c0c). These 18 commits on top of it fix what three further adversarial reviews found, and move to Perfect-CRUD 3.0.1. Perfect-MariaDB #26 carries identical fixes; the two packages' shared code is kept the same.

Security

  • listTables / listDatabases wildcard injection (1d17314, 5c9c5fd, 7420e31). With CLIENT_MULTI_STATEMENTS set, a crafted wildcard could end the string literal and run more SQL. One test run dropped a table. There were three causes:

    • Connector/C builds didn't escape at all. libmariadb's mysql_list_tables leaves the wildcard as is, so a plain ' was enough.
    • Multibyte charsets. Under big5, cp932, gbk, gb18030 and sjis, a backslash can become the trail byte of a character, so an escape can be swallowed. Connector/C on x86_64 showed this.
    • A stale NO_BACKSLASH_ESCAPES status bit. After a stored procedure or function changes sql_mode, the client library escapes for the wrong mode.

    The fix:

    • A wildcard containing ' has each ' doubled and nothing else changed. That's a quote in either mode, and 0x27 is never a trail byte.
    • A wildcard with both ' and \ is refused, returning [].
    • Any other wildcard goes through the library's escaping, so a wrong mode bit can only cause a syntax error.
    • The multibyte guard runs first.

Memory safety

  • Heap corruption when reusing a MySQLStmt (376a1c3, e7e401a). The client library kept pointers to freed result buffers. A Results now detaches the binds only while it still owns them, sized to the current column count.
  • Blob parameter buffers leaked on reset (f073d1e).
  • Re-executing a prepared CALL read freed metadata (bc8700b).
  • Executing after reset() without rebinding sent freed parameter memory (75b1359). It's now refused with error 2031.
  • Binding more values than placeholders wrote past the bind array (4503f62). This was reachable through db.sql(_:bindings:). It's now refused with error 2034.

Correctness

  • CALL errors. An error a CALL raises after its first result set now throws, where it used to be dropped (354664c).
  • Dates (ce82e24, 1bffe69, 7221f26):
    • fractions are whole microseconds, so 9999-12-31 23:59:59.999999 no longer becomes year 10000;
    • the proleptic Gregorian calendar, as the server uses, so 1582-10-05 to 10-14 parse;
    • year 0, so Date.distantPast round-trips;
    • non-finite or out-of-range Dates throw instead of trapping.
  • Negative [Int8] values read back without trapping (1b3190b). With Perfect-CRUD 3.0.1 they now round-trip.
  • Wildcards work again under NO_BACKSLASH_ESCAPES on libmysqlclient (7420e31).
  • Depends on Perfect-CRUD from: "3.0.1" (53b0217).

Testing

Run under TZ=America/Denver with -warnings-as-errors, against MySQL 8.4 live:

Leg XCTest Swift Testing
macOS Xcode 27, libmysqlclient 72 passed, 3 skipped 60 passed
macOS Xcode 27, Connector/C 73 passed, 2 skipped 60 passed
macOS Xcode 26.3 release 72 passed, 3 skipped 60 passed
Linux amd64, libmysqlclient 73 passed, 2 skipped 60 passed
Linux amd64, libmariadb, debug and release 74 passed, 1 skipped 60 passed

The skips are SSL cases that need a different environment.

  • Guard Malloc (banner checked): the reuse, reassign, CALL-walk, column-count-change, execute-after-reset and overbind probes are clean on both macOS libraries. leaks shows 0 on the reuse loop.
  • Injection hunt: 11 server setups (stale-mode procedures and functions, untracked SET NAMES) × 13 charsets × 26 payloads, on both libraries, macOS and Linux amd64. 0 injections at HEAD; 257 failures at the previous head.
  • Each fix has a test that fails or crashes before it.

After merge, CI on main, then tag 4.0.0.

🤖 Generated with Claude Code

taplin and others added 18 commits October 13, 2026 08:35
A procedure that returns a result set and then fails (a duplicate-key
INSERT, error 1062; a scalar subquery returning 2 rows, 1242; SIGNAL
SQLSTATE '45000') reported success with the first set's rows. The error
arrives with a later result, and discardRemainingResults() stopped
draining on any non-zero mysql_stmt_next_result() and dropped it.
Found by the final verification.

discardRemainingResults() now returns the first error: from
mysql_stmt_next_result() > 0, or from a failing mysql_stmt_store_result
(that result is freed and draining continues). hasNext() throws it as
MySQLCRUDError after reset(), so the connection is reusable. deinit
(a caller that stopped early) can only log it through CRUDLogging:
Perfect-CRUD's untyped db.sql("CALL ...") calls hasNext() once, so it
still can't report such an error. That limit is documented.

New parameterized test StoredProcedureTests.anErrorAfterTheFirstResultSetThrows
(1062, 1242, SIGNAL): the typed db.sql(_:_:) and DynamicSQL paths throw
with the server's message, and the connection then runs another query;
the untyped path leaves the connection usable. On the previous commit
it fails with 12 issues (nothing thrown); with the fix the full suite
passes against MySQL 8.4 (macOS, Swift 6.4, TZ=America/Denver).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bindParam([UInt8]), bindParam([Int8]) and bindParam(_:length:) copy the
value into a buffer, but resetBinds() skipped MYSQL_TYPE_LONG_BLOB, so
every blob parameter leaked its whole value. That includes every
Data / [UInt8] / [Int8] value Perfect-CRUD binds in an insert, update or
where. resetBinds() (used by reset() and clearBinds()) now frees it.
22e9fed fixed only the MYSQL_BIND array; this is the rest of that leak.
Found by the final verification.

`leaks --atExit` on a probe that executes a 3-blob INSERT 300 times
(bind [UInt8], [Int8], (_:length:), execute, reset), Homebrew
libmysqlclient, MySQL 8.4:
  before: Process ...: 891 leaks for 37984 total leaked bytes.
  after:  Process ...: 0 leaks for 0 total leaked bytes.

New test ParameterBindTests.repeatedBlobBindsWithResetAndRebind: 200
executions of a reused INSERT with blobs of varying length (including
empty), pending binds dropped by reset() and rebound, and pending blob
binds dropped by clearBinds(). Every row reads back with the right
bytes. It checks that the freeing is correct; the leak itself was
measured with `leaks`, not in the test. It passes before and after (the
leak doesn't change values).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
'9999-12-31 23:59:59.999999' (a common "forever" value in DATETIME(6))
parsed as 10000-01-01 00:00:00: the fraction was added as a Double, and
a Date (a Double count of seconds since 2001) can't hold six fractional
digits near year 9999. mysqlFormatted() then wrote '10000-01-01
00:00:00', which the server rejects. The writer's DateFormatter also
rounded, so '1969-12-31 23:59:59.999999' was written back as
'1970-01-01 00:00:00'. Found by the final verification.

The parser now carries the fraction as whole microseconds and builds the
Date with Date(mysqlSeconds:microseconds:), which keeps the result
inside its second (the largest Date below the next second when the sum
would round up). The writer floors to whole seconds before formatting,
so it truncates toward the past and never carries into the next second,
minute or year. It still writes whole seconds, as before.

New test DateTests.fractionsStayInsideTheirSecond. On the previous
commit 4 of its expectations fail (year 10000, 1969 -> 1970, rounding
up on write); with the fix all DateTests pass (TZ=America/Denver).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Date parsing and formatting went through Foundation's .gregorian
calendar, which switches to the Julian calendar before 1582-10-15. So
the valid MySQL dates 1582-10-05...14 didn't parse (decode threw
"Invalid Date string"), and earlier dates became instants 10 or more
days away from what the server's proleptic calendar means (0001-01-01
came out 2 days off). Found by the final verification.

Reader and writer now compute days from and to 1970-01-01 with Howard
Hinnant's days_from_civil / civil_from_days, with explicit range checks
(month 1-12, day within the month by the Gregorian leap rule, hour < 24,
minute and second < 60). DateFormatter and Calendar are no longer used,
so neither direction depends on the process locale, calendar or time
zone. The writer still floors to whole seconds.

New tests:
- DateTests.datesUseTheProlepticGregorianCalendar: Unix times for
  0001-01-01, 1000-03-01, 1582-10-04/10/15, 1970-01-01, 2000-02-29 and
  9999-12-31 (from Python's proleptic datetime), the leap rule, and a
  round trip of every day from 1600-01-01 to 2000-01-01.
- DateTests.oldDatesMatchTheServersCalendar (live): dates written through
  Perfect-CRUD read back with DATE_FORMAT as the same text, and through
  CRUD again.
On the previous commit the first fails (four Unix times are off, and
1582-10-10 doesn't parse). The live one passes there too, since the old
reader and writer were consistent with each other. With the fix both
pass against MySQL 8.4 (TZ=America/Denver).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reusing a MySQLStmt (bind, execute, results(), reset, execute again)
corrupted the heap. mysql_stmt_bind_result keeps a copy of the
MYSQL_BINDs, including their buffer / length / is_null pointers, until
the next bind_result. Results.deinit freed those buffers, and the next
execute() re-ran the library's fetch setup over the copy, writing
through the dangling length pointer (libmysqlclient
setup_one_fetch_function). Found while writing the blob-leak probe; it
predates this release: origin/main crashes too.
- With the system allocator a 300-iteration loop crashed after ~17
  iterations (SIGSEGV or SIGABRT in malloc).
- Under Guard Malloc it crashed on the second execute.
- Same with and without parameters.
Perfect-CRUD makes one statement per query, so it wasn't affected;
direct users of MySQLStmt (and pooled code reusing one) were.

Results.deinit now rebinds the statement to MYSQL_TYPE_NULL binds with
no pointers, so the library uses its own length/is_null storage, before
freeing its buffers. Results.init also zero-initialises the bind array
before binding, as Perfect-MariaDB does (unbind() reads every element).

Checks:
- New ResultErrorTests.aStatementCanBeReusedAfterReadingResults: 300
  reuses of one statement with int, double, string and blob parameters,
  checking each row. It crashes the test process on the previous commit
  and passes with the fix.
- The standalone probe (300 reuses) runs clean under Guard Malloc
  (libgmalloc), and `leaks --atExit` reports 0 leaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both passed the wildcard to mysql_list_tables / mysql_list_dbs, which
build "SHOW ... LIKE '<wild>'" themselves. Two holes, both reproduced
live against MySQL 8.4 with CLIENT_MULTI_STATEMENTS, where the rest of
the wildcard ran as SQL ("x'; DROP TABLE victim; -- "):

- Built against MariaDB Connector/C (libmariadb-dev-compat), the
  wildcard isn't escaped at all, so a plain `'` ended the literal under
  any charset (utf8mb4, latin1, big5 all injected). Perfect-MariaDB
  fixed this in its own code in #25; it was never ported here.
- With Oracle's libmysqlclient, append_wild puts a backslash before `'`
  and `\` byte by byte. Under big5, cp932, gbk, gb18030 or sjis a byte
  >= 0x80 just before the quote takes that backslash as its second byte:
  "中'" (E4 B8 AD 27) became E4 B8 AD 5C 27, read as 中, AD5C, then a
  closing quote. Injected under all five on macOS arm64 (Homebrew
  libmysqlclient 9.x) and Linux amd64 (Ubuntu 24.04 libmysqlclient 8.0);
  Connector/C injected under all five as well.

Fix, identical in approach to Perfect-MariaDB:
- listTables / listDatabases build the query themselves (ported from
  Perfect-MariaDB's listNames), escaping the pattern with
  mysql_real_escape_string. That follows the connection charset and
  NO_BACKSLASH_ESCAPES as the client library knows them.
- Before that, a guard refuses (returns [], the API's existing failure
  value) a wildcard that has a byte >= 0x80 and a `'` or `\` while the
  connection charset is big5, cp932, gbk, gb18030 or sjis.
  libmariadb's mysql_real_escape_string mis-escapes big5 on x86_64 (the
  Perfect-MariaDB finding), so escaping alone isn't trusted there.
- The charset is the one the client knows (MYSQL_SET_CHARSET_NAME), not
  a SET NAMES it may not see. That's documented on the methods, along
  with the LIKE semantics.

New ListingWildcardTests (live, MYSQL_TESTS):
- multibyte payloads under the five charsets;
- ASCII-quote payloads under utf8mb4, latin1 and big5;
- names containing `'` still match literally;
- ordinary wildcards still work;
- the guard's exact conditions.
Before the fix the payloads leave the connection "out of sync" (the
injected statements ran) and dropped the victim table; after it they're
refused or escaped and the connection stays in sync. They pass on macOS
(libmysqlclient) and on Linux swift:6.4-noble arm64 and amd64
(--arch amd64), each with libmysqlclient-dev and libmariadb-dev-compat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row reader converted a blob into [Int8] with Int8(_:), which traps
on any byte >= 0x80, so reading back an [Int8] property holding a
negative value crashed the process ("Not enough bits to represent the
passed value"). It now uses Int8(bitPattern:). Perfect-MariaDB has the
same line and is getting the same fix.

New test ByteArrayTests.negativeInt8ValuesRoundTrip (live): [Int8] with
-1, -128, 0, 127, 1, an empty array and all 256 values, next to [UInt8],
read back through Perfect-CRUD and matched with a `where` on the [Int8]
key path. The rows are inserted with SQL, because Perfect-CRUD 3.0.0's
own encoder traps the same way writing a negative [Int8]
(CodingBindings.swift: UInt8($0)); that's fixed in Perfect-CRUD 3.0.1,
after which a full CRUD round trip works. Before this commit the test
crashes the process; after, it passes against MySQL 8.4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…statement reuse, [Int8]

Also: CI now has a Connector/C job, so it is no longer "checked by hand".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376a1c3 made Results.deinit rebind the statement to pointer-free binds
before freeing its buffers. Two regressions, found by the pre-push
verification:

- F1: it detached whatever the statement was bound to, including a newer
  Results. `r = stmt.results()` builds the new Results before the old
  one is released, so the old one's deinit unbound the new one and every
  row read 0 / "" (uninitialised heap values on Linux). Walking a CALL's
  result sets with one variable failed the same way.
- F2: it allocated numFields binds, but mysql_stmt_bind_result copies
  the statement's *current* field count. When a Results outlived
  nextResult() to a wider set, or a re-prepare with more columns, that
  read past the allocation (SIGSEGV under Guard Malloc, both libraries).
  With field count 0, libmysqlclient also set CR_NO_STMT_METADATA.

The statement now records which Results it's bound to (resultOwner, set
in bind()). deinit detaches only if it's still the owner, uses
Int(stmt.fieldCount()) binds, skips it when that's 0, and clears the
owner. bind() no longer calls mysql_stmt_bind_result for a result with
no columns.

Tests (ParameterBindTests):
- reassigningAResultsVariableKeepsTheNewOneBound: 50 re-executions
  read through a reassigned variable.
- olderResultsDontDisturbLaterResultSets: a 3-set CALL walked with one
  variable, 3 times, then an older Results released after a re-prepare
  from 1 to 8 columns.
On the previous commit they fail with 150 and 3 issues. The verifier's
probes (reassign, callsets, stale, stalecall), run under Guard Malloc
via `timeout N env DYLD_INSERT_LIBRARIES=/usr/lib/libgmalloc.dylib`
with the GuardMalloc banner checked: on the previous commit 50/50 rows
wrong, ["1/x","0/","0/"], and SIGSEGV twice; with the fix all clean and
correct (libmysqlclient 9.6, MySQL 8.4).

The earlier claim in 376a1c3's message that the reuse probe "runs clean
under Guard Malloc" was made with DYLD_INSERT_LIBRARIES set directly on
the binary, not through /usr/bin/timeout, so it did load. But that probe
used a temporary Results and couldn't catch F1/F2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-executing one prepared CALL statement through the raw API read freed
memory on MariaDB Connector/C. MySQLStmt caches mysql_stmt_result_metadata;
its field pointers point into storage that libmariadb replaces when it
executes the statement again, and Results.bind read them
(EXC_BAD_ACCESS under Guard Malloc). Predates this release (same crash
at e0b8c0c); found by the pre-push verification.

execute() now frees the cached metadata after every successful execution,
so it's fetched afresh: Results.init and fieldNames() already refresh it.
fieldInfo(index:) now refreshes it too (it used the cache directly and
would pass nil after an execute) and rejects negative indexes.

Tests:
- New StoredProcedureTests.aPreparedCallCanBeExecutedRepeatedly: one
  prepared CALL executed 20 times with different arguments, checking
  fieldInfo and every row, then another statement on the connection.
  It passes with libmysqlclient before and after, since the bug needs
  Connector/C's memory handling.
- The verifier's `callerr` probe (raw CALL re-executed 20 times), built
  against Homebrew mariadb-connector-c 3.4.9 and run under Guard Malloc
  (banner checked): SIGSEGV on the previous commit, clean with this one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After reset() the parameter buffers are freed, but the client library
keeps the previous execution's MYSQL_BINDs. Calling execute() again
without rebinding made it read those freed buffers: SIGSEGV under Guard
Malloc, and "Got packet bigger than max_allowed_packet" or garbage with
the system allocator. Binding fewer values than the statement has
parameters left zeroed binds for the rest. This predates the release;
f073d1e (freeing blob buffers on reset) extended the window to blobs.
Found by the pre-push verification.

execute() now returns false unless the number of bound values equals
paramCount(). errorCode() / errorMessage() then report libmysqlclient's
own error for this case, 2031 CR_PARAMS_NOT_BOUND "No data supplied for
parameters in prepared statement", until the next prepare() or execute().
Statements without parameters are unaffected, and Perfect-CRUD always
binds every parameter.

Checks:
- New ParameterBindTests.executingWithoutBindingEveryParameterIsRefused:
  execute after reset without binding, and with one of two bound, is
  refused with 2031; a full rebind works; a parameterless statement
  re-executes. On the previous commit it fails 3 expectations.
- The verifier's `execafterreset` probe under Guard Malloc (banner
  checked): SIGSEGV before, refused cleanly after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1bffe69 switched dates to the proleptic Gregorian calendar, where
Date.distantPast is 0000-12-30. The writer emitted that and MySQL 8.4
stored it, but the reader rejected year < 1. Perfect-CRUD's
SelectIterator ends iteration at the first row that fails to decode, so
every row after it vanished silently. Example: ids 0...5 with
distantPast at id 1 returned only id 0. Found by the pre-push
verification.

- The reader accepts year 0 (month and day 0, the zero date, are still
  rejected). Year 0 is a leap year, as in MySQL.
- mysqlFormatted() now throws instead of trapping on a Date it can't
  represent: non-finite, or outside years 0...9999 (it trapped in
  Int(.infinity), and negative years produced text the server rejects).
  I chose throwing over clamping so a wrong value never reaches the
  database. The .date binding passes the error up, so the insert or
  update throws.

README: the Dates bullet now says that dates before 1582-10-15 written by
3.x (Julian, through Foundation's calendar, including distantPast stored
as 0001-01-01) read back 2 or more days different, documents year 0 and
the thrown error, and corrects "select() skips the row": it stops there
and drops every later row.

Tests (DateTests):
- sentinelDatesRoundTrip: distantPast and distantFuture text and back,
  year 0, the zero date, and six unrepresentable Dates that throw.
- sentinelDatesDontEndASelect (live): ids 0...5 with distantPast, year 1,
  distantFuture, -1 microsecond and 9999-12-31 all read back; inserting
  an infinite Date throws.
The verifier's `dates` probe on the previous commit reads back only id 0
of 6; with this commit all 6, with matching values (TZ=America/Denver,
MySQL 8.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1d17314 escaped the listing pattern with mysql_real_escape_string. On
libmysqlclient that refuses to work when the server is in
NO_BACKSLASH_ESCAPES mode (returns (ulong)-1, CR_INSECURE_API_ERR), so
listTables / listDatabases returned [] for every wildcard there. e0b8c0c
worked. Safe, but a regression. Found by the pre-push verification.

The pattern is now escaped by a new mysqlclient.h shim,
perfect_mysql_escape_string_quote():
- libmysqlclient: mysql_real_escape_string_quote(..., '\''), which
  doubles the quote under NO_BACKSLASH_ESCAPES.
- libmariadb (no _quote variant): mysql_real_escape_string, which already
  does that there.
The multibyte guard is unchanged, and the comment now says what each
library does, so the "follows NO_BACKSLASH_ESCAPES" wording is true.

Tests: new ListingWildcardTests.wildcardsWorkUnderNoBackslashEscapes
(utf8mb4, big5): vic%, a name containing ', and the schema match under
NBE, while three injection payloads stay inert and the connection stays
in sync. On the previous commit the matches return [] (6 issues).

Security re-checked with the verifier's `wild` probe: 11 payloads x 7
charsets x {MYSQL_SET_CHARSET_NAME, SET NAMES} x NBE on/off, with
CLIENT_MULTI_STATEMENTS. Result: 0 injections, connection always in
sync, victim table intact, on macOS with libmysqlclient 9.6 and with
Homebrew mariadb-connector-c 3.4.9. `wildfunc` matches under NBE on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- "Statements can be reused" now matches the code after e7e401a,
  bc8700b and 75b1359: reassigned results variables, walking a CALL's
  result sets, re-executing a prepared CALL on Connector/C, and
  execute() refusing (error 2031) unless every parameter is bound.
- The wildcards bullet says the pattern is escaped according to the
  charset and NO_BACKSLASH_ESCAPES, and that wildcards keep working in
  that mode (true since 7420e31).
The "select() stops, not skips" correction went in with 7221f26.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The client libraries escape for NO_BACKSLASH_ESCAPES according to the
server-status bit in the last OK packet. After a stored procedure or
function changes sql_mode, that bit is left as the routine set it, even
once the session's mode is back, and it stays wrong across later
statements. Found by the Perfect-MariaDB pre-push verification;
Perfect-MySQL's listNames is the same code.
- Session in NBE, then `CALL p()` where p clears sql_mode: the client
  sends `\'`, the server reads `\` literally, and
  `x'; DROP TABLE victim; -- ` ran.
- Session not in NBE, then a procedure or function that sets NBE: the
  client leaves `\` alone, and `x\'; DROP TABLE victim; -- ` ran.
Needs CLIENT_MULTI_STATEMENTS.

listNames now builds the literal so it doesn't depend on that bit:
- A wildcard with `'` is written with each `'` doubled. `''` is a quote
  in both modes, and 0x27 is never the second byte of a multibyte
  character. If it also contains `\`, it's refused (returns []), since
  the `\` would mean different things in the two modes.
- A wildcard without `'` still goes through
  perfect_mysql_escape_string_quote. Its output contains no `'`, so a
  wrong mode bit can at worst cause a syntax error.
- The big5/cp932/gbk/gb18030/sjis guard still runs first.
Ordinary wildcards still work in both modes on both libraries (F4).

Tests (ListingWildcardTests):
- New aStaleNoBackslashEscapesBitCannotEndTheLiteral (utf8mb4, latin1,
  big5): four stale-bit setups (CALL clearing NBE, with and without a
  later DO 1; CALL setting it; a function setting it), each with
  `x'...`, `x\'...` and a trailing `\`; the connection must stay in sync,
  and vic% must still match.
- The `it\'s` expectation changes from a match to refused.
On the previous commit the new test leaves the connection out of sync
(the injected statements ran), on macOS with libmysqlclient 9.6 and
Connector/C 3.4.9, and on Linux amd64 with libmysqlclient-dev and
libmariadb-dev-compat. With the fix the suite passes in all four. The
verifier's `wild` probe (11 payloads x 7 charsets x NBE on/off) shows 0
injections on both macOS libraries, and wildcards match under NBE.

README and release notes now say what is refused, instead of "escaped".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3.0.1 fixes four crashes in 3.0.0 reachable from public API, including
negative [Int8] values trapping in CRUD's encoder, so [Int8] now
round-trips through CRUD with this release's decode fix. Full suite
passes against 3.0.1 (MySQL 8.4, TZ=America/Denver): 72 XCTest + 3
skipped, 59 Swift Testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Port of Perfect-MariaDB 4d0020c and its follow-up ce6afa5; the MySQLStmt
code is the same, apart from this file's multi-line bindParam bodies.

Every bindParam wrote `paramBinds?[paramBindsOffset]` with no bound, so
binding more values than the prepared statement has parameters wrote past
the bind array, and resetBinds() then freed whatever was there. Reachable
through Perfect-CRUD: `db.sql("SELECT ? AS x", bindings: [3 values],
X.self)`. Pre-existing (main and 3.x have the same code).

- prepare() records the parameter count (paramBindsCapacity), and clears
  the binds before checking the result, so after a failed prepare
  execute() reports the library's own error instead of 2031.
- Each bindParam first calls reserveParamBind(). Past the count it sets
  error 2034 (CR_INVALID_PARAMETER_NO, "Invalid parameter number"),
  counts the value without allocating or storing anything, and returns.
- execute() refuses with 2031 (too few) or 2034 (too many, or values
  refused after clearBinds()) until reset() and a correct rebind.
- resetBinds() frees only min(offset, capacity) entries, always zeroes the
  offset (a stray value on a parameterless statement no longer sticks),
  and clears the wrapper's error (a 2034 doesn't outlive reset()).
- README "Statements can be reused" says so.

Checks:
- New ParameterBindTests.bindingTooManyParametersIsRefused, ported with
  the L1/L2 assertions. On the previous commit 3 of its assertions fail;
  this run didn't crash there.
- After a failed re-prepare the error isn't 2031. It's 1243 "Unknown
  prepared statement handler (14) given to mysql_stmt_precheck" with
  libmysqlclient 9.6, and 2014 "Commands out of sync" with Connector/C
  3.4.9, so the test asserts only != 2031.
- An `overbind` probe (CRUD with 3 values for 1 placeholder, and raw
  MySQLStmt with 4, 50 rounds): on the previous commit exit 133 with the
  system allocator and SIGSEGV under Guard Malloc (banner checked); with
  the fix clean under both, and every correct rebind reads its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Wildcards: "a wildcard with `'` has each `'` doubled and nothing else
  changed; any other wildcard is escaped by the client library".
- Dates: only non-finite dates used to crash; years outside 0-9999 were
  written and then rejected by the server. Both now throw when bound,
  from insert, update, count() and sql. In a select() whose where
  compares against such a Date, Perfect-CRUD catches the error, logs it
  and returns no rows (ported from Perfect-MariaDB acd61a3).
The MariaDB README mirrors this wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@taplin
taplin merged commit 58f1ceb into main Oct 13, 2026
20 checks passed
@taplin
taplin deleted the release-prep branch October 13, 2026 16:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant