Skip to content

fix(router): handle escaped colons and inline verbs in v4 - #3132

Merged
vishr merged 10 commits into
v4from
fix/router-escaped-colon-v4
Sep 30, 2026
Merged

vishr merged 10 commits into
v4from
fix/router-escaped-colon-v4

Conversation

@vishr

@vishr vishr commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Backports the escaped-colon router fix in #3113 and the inline-verb follow-up in #3133 to v4. Routes with escaped literal colons and parameter routes stay reachable in either registration order, including paths with encoded NUL bytes.

  • One route-syntax scanner for registration and reverse routing. An escaped colon after a parameter starts an inline verb (/:name\:cancel) when the rest of that path segment is static. Other escaped colons after a parameter keep their older meaning as part of the parameter name.
  • Inline verb matching works as in fix(router): share route syntax across routing operations #3133. A failed split is retried on the same param node, with the next split and finally the whole segment, before routing backtracks to its parent. Static > param > any priority, group middleware and catch-all routes are unaffected by a failed split, and a failed wildcard below a split keeps backtracking.
  • Linear time: O(path length × longest registered verb).
  • Leaf params keep working. When the inline verb child is a param node's only child, a value without a split still takes the rest of the path.
  • ParamNames() stays a non-nil empty slice for static routes. Reverse placeholders drop the backslash of escaped colons, as before.

v4 has no route removal API, so the removal fix in #3133 applies only to v5.

Compatibility notes (release notes)

Performance

Router benchmarks against v4, 8 interleaved runs on Apple M3 Max: geomean +0.15%, 0 allocs/op everywhere.

Verification

  • go test ./... -count=1, go test -race ., go vet ./..., staticcheck .
  • Same regression tests as fix(router): share route syntax across routing operations #3133, adapted to v4, plus a test pinning the escaped-colon-after-param change and %3A handling.
  • Reviewed in four high-effort rounds with differential fuzzing against origin/v4 (random route sets without escaped colons route identically) until no issues remained.

Refs #3111.

vishr pushed a commit that referenced this pull request Sep 29, 2026
Distinguish parameter markers without reserving a path byte; cover both route orders through ServeHTTP. Refs #3111; v4 backport in #3132.
@vishr vishr changed the title fix(router): keep escaped colon and parameter routes reachable in v4 fix(router): handle escaped colons and inline verbs in v4 Sep 29, 2026

@vishr vishr left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Review at 7d355b8. The backport matches #3113 + #3133 closely (removal correctly left out, since v4 has no Remove), no exported signatures change, CI is green, and Find stays at 0 allocs/op with no measurable slowdown on the router benchmarks.

It carries the same Find regressions as #3133 (see the review there), so it should take the same fix. As v4 is the LTS line, these matter more here: every item below changes behavior for routes that work on v4 today. Each was reproduced against this head and v4 (5440f99).

Must fix

  1. Method mismatch after the split (router.go#L466, #L700). canMatchStaticSuffix accepts any isHandler node regardless of method, and the split is never retried.
    • GET /r/:name\:cancel + POST /r/:name: POST /r/foo:cancel returns 405 here, 200 on v4 with name=foo:cancel.
  2. Split taken on a guess (router.go#L474). It returns true whenever the suffix node has a parameter or wildcard child, even if the rest of the path cannot match through it, and there is no fallback to the unsplit value.
    • /r/:name\:v:id/end + /r/:name/other: GET /r/a:vq/other returns 404 here, matches /r/:name/other on v4.
  3. Static-over-parameter priority broken (router.go#L700). A leaf parameter under the verb suffix takes the rest of the path, including segments a static sibling should match.
    • /r/:name\:x:id + /r/:name/q: GET /r/a:x/q goes to the first route with name=a, id=/q. v4 routes it to /r/:name/q.
  4. Empty parameter value (router.go#L699). The loop starts at split := 0, so GET /r/:cancel matches /r/:name\:cancel with name="". Start at 1.
  5. c.ParamNames() is now nil for static routes (router.go#L217). v4 used pnames := []string{}. Code that compares against []string{} or JSON-encodes it ([] becomes null) changes on a patch upgrade. Keep the empty slice.

Suggested direction for 1–3, same as #3133: drop canMatchStaticSuffix and make the colon split a backtrack point in Find (try the split; on failure, retry with the value running to the next /).

Should fix

  1. Keep v4 and v5 diffable. Use slices.Contains(paramMarkers, searchOffset) instead of the loop at router.go#L362 (v4 go.mod is go 1.25, and #3133 does this). Backport v5's routeTreePath helper instead of building the tree path inline in Add (router.go#L227).
  2. Reverse allocations (router.go#L168). uri.WriteString(fmt.Sprintf("%v", ...)) makes a temporary string per parameter, and parseRoutePath now runs on every call. fmt.Fprint(&uri, params[n]) matches v5.

Notes

  1. Sibling /r/:name stops matching slashes once /r/:name\:cancel is registered, because the parameter node is no longer a leaf. Consistent with adding /r/:name/edit, but on v4 LTS it deserves a line in the release notes.
  2. Encoded colon. Matching runs on the raw path, so /r/foo%3Acancel (what encodeURIComponent produces) reaches the generic route with name=foo%3Acancel instead of the verb route. Probably fine, but worth a test that pins the behavior.

@vishr vishr left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re-review at 9e3f23d. All eight findings from the previous review are fixed: method fallback, the guessed split, static sibling priority, empty values, non-nil ParamNames(), slices.Contains/routeTreePath parity, Reverse via fmt.Fprint, and a test pinning %3A. The inline-verb flag is set per host router, and the router benchmarks stay at 0 allocs/op.

The new retry matcher is the same algorithm as #3133 at 9b86d77 (see the review there), and it has the same problems. Each item below was reproduced against this head. On v4 LTS these change behavior for apps that work today, so they need fixing before a patch release.

Must fix

  1. CPU denial of service (router.go#L700). Every colon adds a full re-search pass that rescans the segment, so cost grows with the square of the colon count. GET /r/:name\:cancel, request /r/a + 40,000 :: 1.3 s, then 404. v4 today answers the same request in microseconds.
  2. Group middleware or a catch-all route disables the fallback (router.go#L767). The unsplit retry only runs after a whole pass fails, and an any-route or RouteNotFound node reached by backtracking counts as a match.
    • Group /r with middleware, routes /:name and /:name\:cancel: GET /r/foo:other returns 404.
    • Routes /r/:name, /r/:name\:cancel and /*: GET /r/foo:other goes to /*.
    • Group middleware + GET /:name\:cancel + POST /:name: POST /r/foo:cancel returns 404; v4 today returns 200.
  3. A trailing leaf param stops at / after any split (router.go#L685). GET /r/:name\:x/:rest: GET /r/a:x/b/c returns 404. It also affects unrelated branches reached by backtracking: /api/:name\:x/q, /:page, /*: GET /api/b:x/zzz goes to /* instead of /:page.
  4. Empty params for a RouteNotFound fallback (router.go#L770). fallbackValues is captured after backtracking has cleared paramValues. GET + RouteNotFound on /r/:name\:cancel, POST /r/:name: PUT /r/foo:cancel reaches the not-found handler with c.Param("name") == "". The copy at #L785 has no effect.

The cross-branch priority and Allow header issues from the #3133 review come from the same code and should be checked here too.

Root cause and suggestion

Same as #3133: the split is handled by restarting the search with a global plan instead of as a backtrack point on the param node.

  • Re-enter the same param with the next split position before backtracking to its parent.
  • Count only splits where the colon child's prefix matches (strings.HasPrefix(search[split:], child.prefix)). /r/urn:a:b:c:d:e currently runs 6 full passes before reaching the generic route.
  • Or limit inline verbs to the last route segment, which covers #3111 with a single check and no retries.

Should fix

  1. Two copies of Find (router_plain.go#L13). The ~200-line body is duplicated and has already diverged (return vs break on static-backtrack failure). Keep one body and gate the split block on r.hasInlineVerb && currentNode.hasColonChild. Please keep this in step with #3133 so v4 and v5 stay diffable.

Backport of the v5 change. Replace the whole-search retry plan with a
backtrack point on the param node: when a candidate split fails, the
same param node is retried with the next literal-colon split and finally
the whole path segment before routing backtracks to its parent. Static >
param > any priority of ancestors, the leaf rule of later params, group
middleware and catch-all routes are no longer affected by a failed split.

An escaped colon after a parameter starts an inline verb only when the
rest of that path segment is static (`/:name\:cancel`, optionally
followed by `/...`). Split candidates are then scanned once per segment,
so a request with many colons is routed in linear time. Other escaped
colons after a parameter keep their older meaning as part of the
parameter name.

The duplicated fast-path Find and the router-level inline verb flag are
removed. The param scan uses strings.IndexByte, which keeps router
benchmarks within about 0.3% of v4 (geomean).
A wildcard ends the route search, but a param value above it that ended
at an inline verb split is now retried with the next split and the whole
segment, so a verb route with a wildcard cannot shadow a generic route
for other methods.

The retry now runs only when routing backtracks from the inline verb
child into its param node, instead of on every dead end.

An escaped colon after a parameter keeps its older meaning (part of the
parameter name) unless the first one in the segment starts an inline
verb, so a later `\:` cannot turn such a legacy route into a verb route.
Reverse writes placeholders for such names without the backslash, as
before. Dead hasColonChild bookkeeping for split nodes is removed.
When a wildcard fails below a param value that ended at an inline verb
split, keep backtracking one node at a time instead of jumping to that
param node, so the other routes below the split are tried before the
next split. Without a pending split a failed wildcard still ends the
search as before, and the check does not change the routing state.

Adds tests for nested splits, routes below a split and a RouteNotFound
wildcard below a split.
An escaped colon after a param name that contains ':' or '*' keeps its
older meaning as part of the name. The escaped colon check is shared in
the route syntax scanner. Adds tests for RouteNotFound on the whole
segment and Reverse placeholders.
Registering an inline verb route under a param gave the param node a
static child, so a sibling route ending in that param (`/files/:path`)
stopped matching values across slashes. When the inline verb child is
the node's only child, a param value without a split now takes the rest
of the path, as a leaf param does.
Drop the param and any child checks that cannot fail for a param node, and pin 405, RouteNotFound and wildcard fallbacks for a param value that spans slashes next to an inline verb route.
@vishr
vishr merged commit f085ffb into v4 Sep 30, 2026
8 checks passed
@vishr
vishr deleted the fix/router-escaped-colon-v4 branch September 30, 2026 13:43
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