From fcdaba4b150315f2fd5d86fe56f285fb1dbbf122 Mon Sep 17 00:00:00 2001 From: wofiporia <1029687661@qq.com> Date: Wed, 23 Sep 2026 00:15:55 +0800 Subject: [PATCH] fix(router): keep escaped colon and parameter routes reachable A route with a literal colon (written as `\:`) and a parameter route at the same tree position used the same `:` marker during insertion, so a parameter node could end up attached as a static child of its parent. Backtracking then treated it as a parameter that was never entered, panicking with `index out of range [-1]`; the reverse registration order left the escaped colon route unreachable. Insert parameters with an internal placeholder that can not collide with a literal `:`. --- router.go | 12 ++++++---- router_test.go | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/router.go b/router.go index 99950aae7..eb5f80982 100644 --- a/router.go +++ b/router.go @@ -184,6 +184,10 @@ const ( paramLabel = byte(':') anyLabel = byte('*') + + // paramPlaceholder marks a path parameter while a route is inserted into the + // tree, so that a literal ':' from an escaped `\:` stays a static node. + paramPlaceholder = "\x00" ) type routeMethod struct { @@ -554,7 +558,7 @@ func (r *DefaultRouter) Add(route Route) (RouteInfo, error) { } paramNames = append(paramNames, path[j:i]) - path = path[:j] + path[i:] + path = path[:j-1] + paramPlaceholder + path[i:] i, lcpIndex = j, len(path) if i == lcpIndex { @@ -814,7 +818,7 @@ func (n *node) findChildWithLabel(l byte) *node { if c := n.findStaticChild(l); c != nil { return c } - if l == paramLabel { + if l == paramPlaceholder[0] { return n.paramChild } if l == anyLabel { @@ -937,8 +941,8 @@ func (r *DefaultRouter) Route(c *Context) HandlerFunc { searchIndex -= len(previous.prefix) } else { paramIndex-- - // for param/any node.prefix value is always `:` so we can not deduce searchIndex from that and must use pValue - // for that index as it would also contain part of path we cut off before moving into node we are backtracking from + // param/any node prefixes are a single marker byte, so restore searchIndex + // from the value stored for that param instead searchIndex -= len(pathValues[paramIndex].Value) pathValues[paramIndex].Value = "" } diff --git a/router_test.go b/router_test.go index 22c1dc759..85582c558 100644 --- a/router_test.go +++ b/router_test.go @@ -1320,6 +1320,65 @@ func TestRouterParamStaticConflict(t *testing.T) { } } +// Issue #3111 +func TestRouterParam_escapeColonAndParamConflict(t *testing.T) { + var testCases = []struct { + name string + routes []string + whenURL string + expectRoute string + expectParam map[string]string + }{ + { + name: "escaped colon route first, request escaped colon route", + routes: []string{`/name\:verb/x`, `/name:id`}, + whenURL: "/name:verb/x", + expectRoute: `/name\:verb/x`, + expectParam: map[string]string{}, + }, + { + name: "escaped colon route first, request param route", + routes: []string{`/name\:verb/x`, `/name:id`}, + whenURL: "/name1", + expectRoute: "/name:id", + expectParam: map[string]string{"id": "1"}, + }, + { + name: "param route first, request escaped colon route", + routes: []string{`/name:id`, `/name\:verb/x`}, + whenURL: "/name:verb/x", + expectRoute: `/name\:verb/x`, + expectParam: map[string]string{}, + }, + { + name: "param route first, request param route", + routes: []string{`/name:id`, `/name\:verb/x`}, + whenURL: "/name1", + expectRoute: "/name:id", + expectParam: map[string]string{"id": "1"}, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + e := New() + for _, route := range tc.routes { + e.GET(route, handlerFunc) + } + + c := e.NewContext(httptest.NewRequest(http.MethodGet, tc.whenURL, nil), nil) + + handler := e.router.Route(c) + + assert.NoError(t, handler(c)) + assert.Equal(t, tc.expectRoute, c.Path()) + for param, expectedValue := range tc.expectParam { + assert.Equal(t, expectedValue, c.pathValues.GetOr(param, "---none---")) + } + checkUnusedParamValues(t, c, tc.expectParam) + }) + } +} + func TestRouterParam_escapeColon(t *testing.T) { // to allow Google cloud API like route paths with colon in them // i.e. https://service.name/v1/some/resource/name:customVerb <- that `:customVerb` is not path param. It is just a string