fix(dbconn): stop interpreting user DDL as an escape format string - #1037
Open
morgo wants to merge 1 commit into
Open
fix(dbconn): stop interpreting user DDL as an escape format string#1037morgo wants to merge 1 commit into
morgo wants to merge 1 commit into
Conversation
Several call sites built the sqlescape format string by concatenating raw user SQL onto a trusted prefix. %n / %? inside the user's string literals (e.g. COMMENT '100%new') failed escaping — a process panic on the ForceExec path — and %% was silently collapsed, so spirit executed different DDL than the user wrote. Add a %r verb to sqlescape that splices a string argument in verbatim (no quoting, no format interpretation; the spliced text is never re-scanned). Call sites keep a constant format string and pass the user's ALTER clause / statement as a %r argument. ForceExec now escapes with the error-returning EscapeSQL before the kill timer is armed, so a bad format string fails fast instead of panicking mid-flight. sqlescape is maintained as a hard fork (no longer synced from TiDB), so extending the verb set is safe; its README now says so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
morgo
force-pushed
the
fix/ddl-format-string-escape
branch
from
August 15, 2026 13:17
647a4ab to
39a907b
Compare
morgo
marked this pull request as ready for review
August 15, 2026 13:20
Contributor
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness/safety issue in Spirit’s SQL escaping layer where user-provided DDL was accidentally treated as a sqlescape format string, causing %n / %? / %% sequences inside string literals to either error (including a MustEscapeSQL panic on the ForceExec path) or be rewritten (e.g., %% collapsing to %). It introduces a raw-splice escape verb and updates migration/dbconn execution paths so user SQL is always treated as data, not a format.
Changes:
- Add a new
sqlescapeverb%rto splice raw SQL strings verbatim (no quoting, no format re-scan) into a trusted, constant format string. - Update migration execution paths (
ALTERclause embedding and non-ALTERstatement execution) to pass user SQL via%rinstead of concatenating it into the format string. - Make
dbconn.ForceExecescape the statement before arming the force-kill timer, returning a normal error for bad format strings instead of panicking mid-flight, and add regression tests covering these cases.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/migration/runner.go | Executes non-ALTER user statements via dbconn.Exec("%r", stmt) so % in literals isn’t format-interpreted. |
| pkg/migration/ddl_test.go | Adds end-to-end regression coverage ensuring %n/%?/%% in DDL literals are preserved and don’t crash the instant/copy/non-ALTER paths. |
| pkg/migration/change.go | Switches ALTER TABLE assembly to constant format strings using %r for user clauses across instant/inplace/copy paths. |
| pkg/dbconn/sqlescape/utils.go | Implements %r in the core SQL escape/format routine and documents the verb in code comments. |
| pkg/dbconn/sqlescape/utils_test.go | Adds unit tests validating %r behavior, composition, and error cases. |
| pkg/dbconn/sqlescape/README.md | Updates documentation to reflect the forked status and documents %r/EscapeIdentifier. |
| pkg/dbconn/dbconn.go | Escapes in ForceExec prior to arming the kill timer; updates docs to recommend %r for raw user SQL. |
| pkg/dbconn/dbconn_test.go | Adds integration tests for %r via Exec/ForceExec and verifies bad format strings fail safely. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
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.
Problem
Several call sites build the
sqlescapeformat string by concatenating raw user SQL onto a trusted prefix, e.g."ALTER TABLE %n ALGORITHM=INSTANT, " + c.stmt.Alter.sqlescape.EscapeSQLinterprets%n/%?/%%anywhere in the text — including inside string literals of the user's DDL:--altercontaining%nor%?in a literal (e.g.COMMENT '100%new',CHECK (name LIKE 'a%?')) fails escaping withmissing arguments. On the default force-kill path this goes throughsqlescape.MustEscapeSQL, which panics the process — with the kill timer already armed.%%in a literal (e.g.DEFAULT '50%% off') is silently collapsed to a single%, so spirit executes different DDL than the user wrote ('50% off'instead of'50%% off').Non-ALTER statements (
CREATE TABLE/DROP TABLE/RENAME TABLE) were also executed by passing the raw user statement as the format string with zero args.Fix
User SQL is now always data, never a format string.
sqlescapegains a%rverb that splices a string argument into the SQL verbatim — no quoting, no format interpretation. The spliced text is never re-scanned, so%n/%?/%%sequences inside it are inert. (sqlescapeis maintained as a hard fork and no longer synced from TiDB, so extending the verb set is safe; its README now documents this.)dbconn.ForceExec(..., "ALTER TABLE %n ALGORITHM=INSTANT, %r", tableName, alter). Non-ALTER user statements execute viadbconn.Exec(ctx, db, "%r", stmt).ForceExecnow escapes with the error-returningEscapeSQLbefore the kill timer is armed — a bad format string returns an error instead of panicking while a timer that kills other connections is pending. Kill-timer/retry semantics are unchanged.%rrejects non-string arguments and errors on missing arguments, same as%n/%?.Call sites audited and fixed (all places where a non-constant format string reached
Exec/ForceExec):pkg/migration/change.goalterNewTable—ALGORITHM=COPYattempt and the plain retrypkg/migration/change.goattemptInstantDDL—ForceExecandExecvariantspkg/migration/change.goattemptInplaceDDL—ForceExecandExecvariantspkg/migration/runner.go— non-ALTER single-statement execution (stmt.Statementwas the format string)All other
Exec/ForceExec/EscapeSQLcall sites were audited (re-verified against current main) and use compile-time constant format strings (incl.checkpoint.go's"CREATE TABLE %n " + tableDDL, which concatenates two package constants); they are unchanged.pkg/move/pkg/datasyncalready execute fetched DDL via plainExecContext.Testing
New regression tests (the instant-path test reproduces the pre-fix process panic
missing arguments, need 2-th arg, but only got 1 args):pkg/dbconn/sqlescapeTestEscapeSQLRawVerb: verbatim splice (with%n/%?/%%payloads), composition with%n/%?, missing-argument and non-string-argument errorspkg/dbconnTestExecRawVerb(literals reach the server verbatim; the same text placed in the format string still errors),TestForceExecRawVerb(no format interpretation and the MDL-blocker force-kill still works),TestForceExecBadFormatString(bad format returns an error before the kill timer is armed, instead of panicking)pkg/migrationTestPercentSignsInDDLLiterals: unchanged from the previous revision of this PR — instant path with%n/%?in a comment, instant path with%%in DEFAULT/COMMENT (byte-identical to a plain-client sibling table), copy path viaalterNewTable, and the non-ALTER--statementpathRuns against local compose MySQL 8.0:
./pkg/dbconn/...suite: passpkg/migrationTestPercentSignsInDDLLiterals: passgo build,go vet,gofmt,golangci-lint: clean🤖 Generated with Claude Code