Repository navigation
4.0.0 release prep (part 2): injection, memory-safety and date fixes; CRUD 3.0.1 - #73
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/listDatabaseswildcard injection (1d17314, 5c9c5fd, 7420e31). WithCLIENT_MULTI_STATEMENTSset, a crafted wildcard could end the string literal and run more SQL. One test run dropped a table. There were three causes:mysql_list_tablesleaves the wildcard as is, so a plain'was enough.sql_mode, the client library escapes for the wrong mode.The fix:
'has each'doubled and nothing else changed. That's a quote in either mode, and 0x27 is never a trail byte.'and\is refused, returning[].Memory safety
MySQLStmt(376a1c3, e7e401a). The client library kept pointers to freed result buffers. AResultsnow detaches the binds only while it still owns them, sized to the current column count.CALLread freed metadata (bc8700b).reset()without rebinding sent freed parameter memory (75b1359). It's now refused with error 2031.db.sql(_:bindings:). It's now refused with error 2034.Correctness
CALLraises after its first result set now throws, where it used to be dropped (354664c).9999-12-31 23:59:59.999999no longer becomes year 10000;Date.distantPastround-trips;[Int8]values read back without trapping (1b3190b). With Perfect-CRUD 3.0.1 they now round-trip.from: "3.0.1"(53b0217).Testing
Run under TZ=America/Denver with
-warnings-as-errors, against MySQL 8.4 live:The skips are SSL cases that need a different environment.
leaksshows 0 on the reuse loop.SET NAMES) × 13 charsets × 26 payloads, on both libraries, macOS and Linux amd64. 0 injections at HEAD; 257 failures at the previous head.After merge, CI on
main, then tag 4.0.0.🤖 Generated with Claude Code