fix(swift): remove catastrophic backtracking in Vapor route regex - #1547
Open
maxmilian wants to merge 1 commit into
Open
fix(swift): remove catastrophic backtracking in Vapor route regex#1547maxmilian wants to merge 1 commit into
maxmilian wants to merge 1 commit into
Conversation
The arg-list group `(?:[^,()]+,\s*)*` was ambiguous: the trailing `\s*` and the next iteration's `[^,()]+` could both claim the same run of spaces, so a `.METHOD(...)` call with many comma-separated args that never reaches `use:` forced an exponential search. Measured on `app.get(arg0: value0, ...)`: 40ms at 20 args, 647ms at 24, 41.7s at 30, and no result after 120s at 60. Anchoring each repetition at a comma (`(?:[^,()]+,)*\s*`) makes the split unique — `,` is outside the char class, so there is nothing to re-partition. Same input is now 0.09ms at 1000 args. Match behaviour is unchanged: all four capture groups are identical on 18 hand-written Vapor route shapes (no args, single/multi path segments, `X.parameter`, multi-line calls, Environment.get non-matches) and on 200k fuzzed inputs. Fixes colbymchenry#1544
There was a problem hiding this comment.
CodeGraph review
Overall risk: 🟡 Low — Only the Vapor route-extraction regex changed, and new tests cover both the prior long-argument hang and representative valid route forms.
Worth double-checking
- Vapor route extraction through the shared registry
What to look for in each
- Vapor route extraction through the shared registry — The only production change is
src/resolution/frameworks/swift.tsswitchingrouteRegexto/\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,)*\s*)use:\s*([A-Za-z_][\w.]*)/g; verify a Swift Vapor project indexed via the normal frameworks entrypoint insrc/resolution/frameworks/index.tsstill emits the same routes and handler references for grouped, multiline, and mixed string/non-string path arguments.
Business rules — 2 not applicable
| Status | Rule | Note |
|---|---|---|
| — Not applicable | Surfaces | This change only adjusts Vapor route parsing inside src/resolution/frameworks/swift.ts; it does not modify the CLI, MCP, or library surface definitions governed by src/resolution/frameworks/index.ts. |
| — Not applicable | Source strings must exclude interpolated template literals | The diff does not touch source-string extraction or template-literal handling; it only changes Swift Vapor route matching. |
Full assessment
This PR rewrites the Vapor route-matching regex in Swift framework extraction to remove the ambiguous whitespace/comma split that previously caused exponential backtracking on long argument lists without use:. It also adds focused tests for the hang scenario and for several valid Vapor route shapes after the rewrite. The main thing to scrutinize is whether the tighter regex still matches real-world Vapor route declarations when indexing runs through the shared framework registry.
QA checklist — 1 thing to verify in the running product
- cli — Index a small Swift Vapor project that contains a deliberately malformed route call with a very long comma-separated argument list but no handler label, and confirm the indexing command returns promptly instead of appearing hung. (Before this PR, that shape could trigger exponential regex backtracking; after this PR, the same input should fail fast and produce no extracted route from that line.)
Blast radius: 1 file affected beyond the diff · 4 symbols · 1 test file selected
Tests to run:
__tests__/frameworks.test.ts
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.
Fixes #1544.
Problem
vaporResolver.extractmatches Vapor routes with/\b(\w+)\.(get|post|put|patch|delete|head|options)\s*\(\s*((?:[^,()]+,\s*)*)use:\s*([A-Za-z_][\w.]*)/gThe arg-list group
(?:[^,()]+,\s*)*is ambiguous: the trailing\s*and thenext iteration's
[^,()]+can both claim the same run of spaces, so there areexponentially many ways to partition the same input. When a
.METHOD(...)callhas many comma-separated labelled args and never reaches
use:— exactly thegenerated request-builder shape in the issue — the engine explores all of them
before failing.
Measurements
app.get(arg0: value0, arg1: value1, …)with n args and nouse:(Node v24.14.0, macOS):
Time roughly ×4 per +2 args before the fix. After the fix it is linear:
1000 args / 17,787 chars → 0.090 ms.
Since
index,syncand the MCP connect-time catch-up sync all run thisresolver, one such file hangs the process indefinitely — which is what bricked
the reporter's MCP integration.
Fix
Anchor every repetition at a comma:
,is excluded from the character class, so each repetition ends at the nextcomma and the split is unique — there is nothing left to re-partition, and
matching is linear. The trailing
\s*absorbs the whitespace after the finalcomma, which the old pattern captured inside the group; whitespace after
non-final commas is absorbed by the following
[^,()]+, so capture group 3 isbyte-for-byte identical.
Why
\s*and not the[^,()]*?tail from the issueThe issue suggests
(?:[^,()]+,)*[^,()]*?, and that is the right idea — thewhole fix comes from it. Both versions kill the exponential blow-up by the same
mechanism: every repetition is forced to end at a comma, and
,is excludedfrom
[^,()], so there is exactly one way to split the arg list and nothing tobacktrack over. I only narrowed the tail by one notch, and I want to be explicit
about why in case you'd rather have the looser form.
[^,()]*?widens the match set:use:no longer has to be preceded by a comma,so text that the old regex rejected starts matching. Two cases from my
equivalence run:
…,)*[^,()]*?…,)*\s*(this PR)app.get("a" use: h)(missing comma, not valid Swift)req.get(foo.use: bar)(With the lazy tail the second one is indexed as a route node with handler
reference
bar— a false route in the graph, not just a wasted match.)\s*instead corresponds exactly to what the old pattern's final\s*did —"the whitespace after the last comma" — so the match set is unchanged rather
than merely a superset. That kept the equivalence check below a strict
comparison (identical
indexand identical capture groups) instead of "noregressions on the routes we happen to test".
If you'd prefer the more permissive tail — e.g. you want a route with a missing
comma to still be indexed — say so and I'll swap it; the performance
characteristics are the same either way.
Behaviour is unchanged
Old and new regex produce identical
index+ all four capture groups on:admin.get(use: self.list)),single/multiple path segments,
User.parameter,":id", dotted andself.-prefixed handlers, irregular whitespace, multi-line calls, severalroutes on one line, plus the non-route cases
Environment.get("X"),req.parameters.get("id"),app.get("a" use: h)andapp.get("a", foo(bar), use: h)(all 0 matches on both);Tests
Two regression tests in
__tests__/frameworks.test.ts:does not backtrack exponentially on a long arg list without use:—60 args, no
use:, asserts an explicit< 250 mswall-clock bound. On theold regex this input never returns (>120 s); dialled down to 24 args so the
old code can finish at all, it fails with
expected 668.119334 to be less than 250. The committed 60-arg versionfinishes in 0.005 ms, ~50,000× under the bound, so it is not flaky.
still parses every Vapor route shape after the arg-list rewrite—asserts the 5 route names and 5 handler references for no-arg, single-arg,
multi-arg +
X.parameter, irregular-whitespace and multi-line calls, so afuture ReDoS-style rewrite cannot silently break parsing.
__tests__/frameworks.test.ts115/115 pass, the four Swift-related test files162/162 pass, and
tsc --noEmitis clean. The 54 pre-existing failures in thefull suite (
cli-*,mcp-*,index-command,status-json,*-watchdog—they exec the built
dist/CLI) reproduce identically on an unmodifiedmain.