diff --git a/route_path.go b/route_path.go new file mode 100644 index 000000000..7d4aacec6 --- /dev/null +++ b/route_path.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import "strings" + +// routePathPart is one parsed piece of a route pattern. A backslash before a +// colon makes the colon static. After a parameter name it starts an inline verb +// (`/:name\:cancel`) when the rest of that path segment is static. +type routePathPart struct { + kind kind + value string +} + +func parseRoutePath(path string) []routePathPart { + var parts []routePathPart + walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) }) + return parts +} + +// walkRoutePath is the common syntax scanner. Reverse uses it directly to +// avoid allocating a parts slice for each URL it builds. +func walkRoutePath(path string, emit func(routePathPart)) { + for i := 0; i < len(path); { + if isEscapedColon(path, i) { + emit(routePathPart{kind: staticKind, value: ":"}) + i += 2 + } else if path[i] == ':' { + start := i + 1 + i = start + plainName := true // an escaped colon only starts an inline verb after a name without ':' or '*' + for i < len(path) && path[i] != '/' { + if isEscapedColon(path, i) { + if plainName && isInlineVerb(path[i+2:]) { + break + } + // not an inline verb: the rest of the segment is the param name, as before inline verbs + for i < len(path) && path[i] != '/' { + i++ + } + break + } + if path[i] == ':' || path[i] == '*' { + plainName = false + } + i++ + } + emit(routePathPart{kind: paramKind, value: path[start:i]}) + } else if path[i] == '*' { + start := i + for i < len(path) && path[i] != '/' { + i++ + } + emit(routePathPart{kind: anyKind, value: path[start:i]}) + } else { + start := i + for i < len(path) && path[i] != ':' && path[i] != '*' && !isEscapedColon(path, i) { + i++ + } + emit(routePathPart{kind: staticKind, value: path[start:i]}) + } + } +} + +func isEscapedColon(path string, i int) bool { + return path[i] == '\\' && i+1 < len(path) && path[i+1] == ':' +} + +// isInlineVerb reports whether the route text after an escaped colon stays +// static up to the end of its path segment. Only then can the router find where +// the parameter value ends by trying the colons in the requested segment. Other +// escaped colons keep the older meaning and remain part of the parameter name. +func isInlineVerb(rest string) bool { + for i := 0; i < len(rest) && rest[i] != '/'; i++ { + switch rest[i] { + case '*': + return false + case ':': + if i == 0 || rest[i-1] != '\\' { + return false + } + } + } + return true +} + +func routeTreePath(parts []routePathPart) (string, []int) { + var path strings.Builder + var paramMarkers []int + for _, part := range parts { + switch part.kind { + case staticKind: + path.WriteString(part.value) + case paramKind: + paramMarkers = append(paramMarkers, path.Len()) + path.WriteByte(':') + case anyKind: + path.WriteByte(anyLabel) + return path.String(), paramMarkers + } + } + return path.String(), paramMarkers +} diff --git a/route_syntax_test.go b/route_syntax_test.go new file mode 100644 index 000000000..e53ee91bf --- /dev/null +++ b/route_syntax_test.go @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors + +package echo + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func assertInlineVerbResponse(t *testing.T, e *Echo, path, want string) { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, path, nil) + if !assert.NotPanics(t, func() { e.ServeHTTP(rec, req) }) { + return + } + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, want, rec.Body.String()) +} + +func TestRouterInlineVerbRoutes(t *testing.T) { + for _, order := range [][]string{{"cancel", "get"}, {"get", "cancel"}} { + e := New() + for _, verb := range order { + e.GET("/r/:name\\:"+verb, func(c Context) error { + return c.String(http.StatusOK, verb+":"+c.Param("name")) + }) + } + assertInlineVerbResponse(t, e, "/r/foo:cancel", "cancel:foo") + assertInlineVerbResponse(t, e, "/r/foo:get", "get:foo") + assertInlineVerbResponse(t, e, "/r/foo:bar:cancel", "cancel:foo:bar") + } +} + +func TestRouterInlineVerbLongestSuffix(t *testing.T) { + e := New() + e.GET(`/r/:name\:foo\:bar`, func(c Context) error { + return c.String(http.StatusOK, "long:"+c.Param("name")) + }) + e.GET(`/r/:name\:bar`, func(c Context) error { + return c.String(http.StatusOK, "short:"+c.Param("name")) + }) + assertInlineVerbResponse(t, e, "/r/a:foo:bar", "long:a") + assertInlineVerbResponse(t, e, "/r/a:bar", "short:a") +} + +func TestRouterInlineVerbWithFollowingParam(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel/:action`, func(c Context) error { + return c.String(http.StatusOK, c.Param("name")+":"+c.Param("action")) + }) + assertInlineVerbResponse(t, e, "/r/foo:cancel/bar", "foo:bar") +} + +func TestRouterInlineVerbWithWildcard(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel/*`, func(c Context) error { + return c.String(http.StatusOK, c.Param("name")+":"+c.Param("*")) + }) + assertInlineVerbResponse(t, e, "/r/foo:cancel/bar", "foo:bar") + assertInlineVerbResponse(t, e, "/r/foo:cancel/", "foo:") +} + +func TestRouterInlineVerbAndGenericParam(t *testing.T) { + e := New() + e.GET("/r/:name", func(c Context) error { + return c.String(http.StatusOK, "generic:"+c.Param("name")) + }) + e.GET(`/r/:name\:cancel`, func(c Context) error { + return c.String(http.StatusOK, "cancel:"+c.Param("name")) + }) + assertInlineVerbResponse(t, e, "/r/foo:cancel", "cancel:foo") + assertInlineVerbResponse(t, e, "/r/foo:other", "generic:foo:other") +} + +func TestRouterReverseInlineVerb(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel`, func(c Context) error { return nil }).Name = "inline-verb" + assert.Equal(t, "/r/foo:cancel", e.Reverse("inline-verb", "foo")) + assert.Equal(t, "/r/:name:cancel", e.Reverse("inline-verb")) +} + +func TestRouterInlineVerbBacktracksToGenericRoute(t *testing.T) { + e := New() + e.GET(`/r/:name\:v/:id/end`, func(c Context) error { return c.String(http.StatusOK, "verb") }) + e.GET(`/r/:name/other`, func(c Context) error { return c.String(http.StatusOK, c.Param("name")) }) + assertInlineVerbResponse(t, e, "/r/a:vq/other", "a:vq") + assertInlineVerbResponse(t, e, "/r/a:v/other", "a:v") + assertInlineVerbResponse(t, e, "/r/a:v/q/end", "verb") +} + +func TestRouterInlineVerbMethodFallback(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel`, func(c Context) error { return c.String(http.StatusOK, "verb") }) + e.POST(`/r/:name`, func(c Context) error { return c.String(http.StatusOK, c.Param("name")) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/r/foo:cancel", nil)) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "foo:cancel", rec.Body.String()) +} + +func TestRouterInlineVerbRequiresNonemptyParameter(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel`, func(c Context) error { return c.String(http.StatusOK, c.Param("name")) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/:cancel", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestRouterInlineVerbAndStaticSibling(t *testing.T) { + e := New() + e.GET(`/r/:name\:x/:id`, func(c Context) error { return c.String(http.StatusOK, "verb:"+c.Param("name")+":"+c.Param("id")) }) + e.GET(`/r/:name/q`, func(c Context) error { return c.String(http.StatusOK, "static:"+c.Param("name")) }) + assertInlineVerbResponse(t, e, "/r/a:x/q", "verb:a:q") + assertInlineVerbResponse(t, e, "/r/a:y/q", "static:a:y") +} + +func TestRouterInlineVerbMustEndPathSegment(t *testing.T) { + // An escaped colon that is followed by a param or wildcard in the same segment keeps its older meaning: it is part + // of the param name. Trying every colon in a request segment for such routes could not be bounded. + e := New() + e.GET(`/r/:name\:x:id`, func(c Context) error { return c.String(http.StatusOK, strings.Join(c.ParamNames(), ",")) }) + e.GET(`/s/:name\:x*`, func(c Context) error { return c.String(http.StatusOK, strings.Join(c.ParamNames(), ",")) }) + assertInlineVerbResponse(t, e, "/r/foo", `name\:x:id`) + assertInlineVerbResponse(t, e, "/s/foo", `name\:x*`) + // the first escaped colon decides, so a later one that is followed only by static text does not start a verb + e.GET(`/t/:a\:x:y\:z`, func(c Context) error { return c.String(http.StatusOK, strings.Join(c.ParamNames(), ",")) }).Name = "legacy" + assertInlineVerbResponse(t, e, "/t/q:z", `a\:x:y\:z`) + assert.Equal(t, "/t/:a:x:y:z", e.Reverse("legacy")) +} + +func TestRouterInlineVerbBeforeWholeSegment(t *testing.T) { + // a matching inline verb split is tried before the whole segment, also when a wildcard follows the verb + e := New() + e.GET(`/r/:name\:x/*`, func(c Context) error { return c.String(http.StatusOK, "verb:"+c.Param("name")+"|"+c.Param("*")) }) + e.GET(`/r/:id/info`, func(c Context) error { return c.String(http.StatusOK, "info:"+c.Param("id")) }) + assertInlineVerbResponse(t, e, "/r/a:x/info", "verb:a|info") + assertInlineVerbResponse(t, e, "/r/a:y/info", "info:a:y") +} + +func TestRouterInlineVerbLeafParamAfterVerb(t *testing.T) { + e := New() + e.GET(`/r/:name\:x/:rest`, func(c Context) error { return c.String(http.StatusOK, c.Param("name")+"|"+c.Param("rest")) }) + assertInlineVerbResponse(t, e, "/r/a:x/b/c", "a|b/c") +} + +func TestRouterInlineVerbWithGroupMiddlewareAndCatchAll(t *testing.T) { + e := New() + g := e.Group("/r", func(next HandlerFunc) HandlerFunc { return next }) + g.GET("/:name", func(c Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + g.GET(`/:name\:cancel`, func(c Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + assertInlineVerbResponse(t, e, "/r/foo:other", "generic:foo:other") + assertInlineVerbResponse(t, e, "/r/foo:cancel", "cancel:foo") + + e = New() + e.GET("/r/:name", func(c Context) error { return c.String(http.StatusOK, "generic:"+c.Param("name")) }) + e.GET(`/r/:name\:cancel`, func(c Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + e.GET("/*", func(c Context) error { return c.String(http.StatusOK, "any") }) + assertInlineVerbResponse(t, e, "/r/foo:other", "generic:foo:other") + assertInlineVerbResponse(t, e, "/r/foo:cancel", "cancel:foo") +} + +func TestRouterInlineVerbManyColons(t *testing.T) { + // Every colon in the segment is a possible split. Each is tried at most once, so a long run of colons is routed + // in linear time. + e := New() + e.GET(`/r/:name\:cancel`, func(c Context) error { return c.String(http.StatusOK, "cancel:"+c.Param("name")) }) + e.GET(`/r/:name\:c`, func(c Context) error { return c.String(http.StatusOK, "c:"+c.Param("name")) }) + e.GET(`/r/:name\:x/:a\:y/z`, func(c Context) error { return c.String(http.StatusOK, "nested") }) + colons := strings.Repeat(":", 1<<16) + start := time.Now() + defer func() { + // linear routing takes milliseconds here; trying splits quadratically would take minutes + assert.Less(t, time.Since(start), 10*time.Second) + }() + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"b", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+colons+"x/b"+colons+"y/nope", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + // every ":c" enters the shared ":c" verb node before failing, so each split is retried + rec = httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a"+strings.Repeat(":c", 1<<15)+"b", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) + + assertInlineVerbResponse(t, e, "/r/a"+colons+"cancel", "cancel:a"+colons[1:]) +} + +func TestRouterStaticParamNamesRemainEmptySlice(t *testing.T) { + e := New() + e.GET("/static", func(c Context) error { + assert.NotNil(t, c.ParamNames()) + assert.Empty(t, c.ParamNames()) + return c.NoContent(http.StatusOK) + }) + assertInlineVerbResponse(t, e, "/static", "") +} + +func TestRouterInlineVerbEncodedColonUsesGenericRoute(t *testing.T) { + e := New() + e.GET(`/r/:name\:cancel`, func(c Context) error { + return c.String(http.StatusOK, "verb") + }) + e.GET(`/r/:name`, func(c Context) error { + return c.String(http.StatusOK, "generic:"+c.Param("name")) + }) + assertInlineVerbResponse(t, e, "/r/foo%3Acancel", "generic:foo%3Acancel") +} + +func TestRouterInlineVerbMethodNotAllowedWithoutFallback(t *testing.T) { + e := New() + e.POST(`/r/:name\:cancel`, func(c Context) error { return c.NoContent(http.StatusOK) }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo:cancel", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestRouterInlineVerbRetriedAfterWildcard(t *testing.T) { + // a wildcard ends the search, but the split above it is still retried with the next split and the whole segment + e := New() + e.POST(`/r/:n\:v/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n/*`, func(c Context) error { return c.String(http.StatusOK, "get:"+c.Param("n")+"|"+c.Param("*")) }) + assertInlineVerbResponse(t, e, "/r/a:v/q", "get:a:v|q") + + e = New() + e.POST(`/r/:n\:a\:b/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n\:b/x`, func(c Context) error { return c.String(http.StatusOK, "get:"+c.Param("n")) }) + assertInlineVerbResponse(t, e, "/r/q:a:b/x", "get:q:a") + + e = New() + e.POST(`/r/:n\:v/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/a:v/q", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) +} + +func TestRouterInlineVerbChangesEscapedColonAfterParam(t *testing.T) { + // Before inline verbs, `/:name\:cancel` was a single param named `name\:cancel` that matched any segment. + e := New() + e.GET(`/r/:name\:cancel`, func(c Context) error { + return c.String(http.StatusOK, strings.Join(c.ParamNames(), ",")+"="+c.Param("name")) + }) + assertInlineVerbResponse(t, e, "/r/foo:cancel", "name=foo") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/r/foo", nil)) + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestRouterReverseEscapedColonPlaceholder(t *testing.T) { + e := New() + e.GET(`/r/:n\:x:id`, func(c Context) error { return nil }).Name = "legacy" + e.GET(`/r/:name\:cancel`, func(c Context) error { return nil }).Name = "verb" + assert.Equal(t, "/r/:n:x:id", e.Reverse("legacy")) + assert.Equal(t, "/r/foo", e.Reverse("legacy", "foo")) + assert.Equal(t, "/r/:name:cancel", e.Reverse("verb")) + assert.Equal(t, "/r/foo:cancel", e.Reverse("verb", "foo")) +} + +func TestRouterInlineVerbWildcardBacktracksBelowSplit(t *testing.T) { + // after a wildcard below a split fails, the other routes below that split are tried before the next split + e := New() + e.POST(`/r/:n\:v/a/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:n\:v/:p/b`, func(c Context) error { return c.String(http.StatusOK, "verb:"+c.Param("n")+"|"+c.Param("p")) }) + e.GET(`/r/:n/a/b`, func(c Context) error { return c.String(http.StatusOK, "generic:"+c.Param("n")) }) + assertInlineVerbResponse(t, e, "/r/q:v/a/b", "verb:q|a") + + // nested splits: the nearest pending split is retried first, then the outer one + e = New() + e.POST(`/r/:a\:x/:b\:y/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a/:b\:y/*`, func(c Context) error { + return c.String(http.StatusOK, c.Param("a")+"|"+c.Param("b")+"|"+c.Param("*")) + }) + assertInlineVerbResponse(t, e, "/r/p:x/q:y/z", "p:x|q|z") + + e = New() + e.POST(`/r/:a\:x/:b\:y/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a\:x/:b/*`, func(c Context) error { + return c.String(http.StatusOK, c.Param("a")+"|"+c.Param("b")+"|"+c.Param("*")) + }) + assertInlineVerbResponse(t, e, "/r/p:x/q:y/z", "p|q:y|z") + + // a RouteNotFound wildcard below a split handles the request like any other RouteNotFound route + e = New() + e.RouteNotFound(`/r/:a\:x/*`, func(c Context) error { return c.String(http.StatusOK, "not found:"+c.Param("a")) }) + e.GET(`/r/:a/k`, func(c Context) error { return c.String(http.StatusOK, "k") }) + assertInlineVerbResponse(t, e, "/r/p:x/k", "not found:p") +} + +func TestRouterInlineVerbMisc(t *testing.T) { + e := New() + e.POST(`/r/:n\:v/*`, func(c Context) error { return c.String(http.StatusOK, "post") }).Name = "verb" + e.RouteNotFound(`/r/:n/*`, func(c Context) error { return c.String(http.StatusOK, "not found:"+c.Param("n")) }) + // the whole segment reaches the RouteNotFound route, as a static sibling would + assertInlineVerbResponse(t, e, "/r/a:v/q", "not found:a:v") + assert.Equal(t, "/r/:n:v/*", e.Reverse("verb")) + assert.Equal(t, "/r/a:v/b/c", e.Reverse("verb", "a", "b/c")) + + // a param name with ':' keeps an escaped colon as part of the name + e.GET(`/s/:a:b\:v`, func(c Context) error { return c.String(http.StatusOK, strings.Join(c.ParamNames(), ",")) }) + assertInlineVerbResponse(t, e, "/s/x", `a:b\:v`) +} + +func TestRouterInlineVerbKeepsLeafParam(t *testing.T) { + // a param with only an inline verb child still takes the rest of the path when no split matches + e := New() + e.GET("/files/:path", func(c Context) error { return c.String(http.StatusOK, "get:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c Context) error { return c.String(http.StatusOK, "upload:"+c.Param("name")) }) + assertInlineVerbResponse(t, e, "/files/a/b", "get:a/b") + assertInlineVerbResponse(t, e, "/files/a:upload/b", "get:a:upload/b") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/files/a:upload", nil)) + assert.Equal(t, "upload:a", rec.Body.String()) + + // with another child the param stops at the slash, as before + e.GET("/files/:path/meta", func(c Context) error { return c.String(http.StatusOK, "meta:"+c.Param("path")) }) + assertInlineVerbResponse(t, e, "/files/a/meta", "meta:a") +} + +func TestRouterInlineVerbPendingAboveParam(t *testing.T) { + // the pending split is found above a param without a split + e := New() + e.POST(`/r/:a\:v/:b/*`, func(c Context) error { return c.String(http.StatusOK, "post") }) + e.GET(`/r/:a/:b/q`, func(c Context) error { return c.String(http.StatusOK, "get:"+c.Param("a")+"|"+c.Param("b")) }) + assertInlineVerbResponse(t, e, "/r/x:v/y/q", "get:x:v|y") +} + +func TestRouterInlineVerbKeepsLeafParamFallbacks(t *testing.T) { + e := New() + e.GET("/files/:path", func(c Context) error { return c.String(http.StatusOK, "get:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c Context) error { return c.String(http.StatusOK, "upload") }) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, httptest.NewRequest(http.MethodPut, "/files/a/b", nil)) + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Equal(t, "OPTIONS, GET", rec.Header().Get(HeaderAllow)) + + e = New() + e.RouteNotFound("/files/:path", func(c Context) error { return c.String(http.StatusOK, "not found:"+c.Param("path")) }) + e.POST(`/files/:name\:upload`, func(c Context) error { return c.String(http.StatusOK, "upload") }) + assertInlineVerbResponse(t, e, "/files/a/b", "not found:a/b") + + e = New() + e.POST(`/files/:name\:upload`, func(c Context) error { return c.String(http.StatusOK, "upload") }) + e.GET("/files/*", func(c Context) error { return c.String(http.StatusOK, "any:"+c.Param("*")) }) + assertInlineVerbResponse(t, e, "/files/a/b", "any:a/b") +} diff --git a/router.go b/router.go index 912cfeac0..de0824d79 100644 --- a/router.go +++ b/router.go @@ -7,6 +7,8 @@ import ( "bytes" "fmt" "net/http" + "slices" + "strings" ) // Router is the registry of all registered routes for an `Echo` instance for @@ -33,7 +35,8 @@ type node struct { // isLeaf indicates that node does not have child routes isLeaf bool // isHandler indicates that node has at least one handler registered to it - isHandler bool + isHandler bool + hasColonChild bool } type kind uint8 @@ -66,8 +69,7 @@ const ( paramKind anyKind - paramLabel = byte(':') - anyLabel = byte('*') + anyLabel = byte('*') ) func (m *routeMethods) isHandler() bool { @@ -158,26 +160,23 @@ func (r *Router) Routes() []*Route { // Reverse generates a URL from route name and provided parameters. func (r *Router) Reverse(name string, params ...interface{}) string { uri := new(bytes.Buffer) - ln := len(params) - n := 0 for _, route := range r.routes { if route.Name == name { - for i, l := 0, len(route.Path); i < l; i++ { - hasBackslash := route.Path[i] == '\\' - if hasBackslash && i+1 < l && route.Path[i+1] == ':' { - i++ // backslash before colon escapes that colon. in that case skip backslash - } - if n < ln && (route.Path[i] == '*' || (!hasBackslash && route.Path[i] == ':')) { - // in case of `*` wildcard or `:` (unescaped colon) param we replace everything till next slash or end of path - for ; i < l && route.Path[i] != '/'; i++ { - } - uri.WriteString(fmt.Sprintf("%v", params[n])) + n := 0 + walkRoutePath(route.Path, func(part routePathPart) { + if part.kind == staticKind { + uri.WriteString(part.value) + } else if n < len(params) { + fmt.Fprint(uri, params[n]) n++ + } else { + // placeholder for a missing value. An escaped colon in a param name is written without its backslash. + if part.kind == paramKind { + uri.WriteByte(':') + } + uri.WriteString(strings.ReplaceAll(part.value, `\:`, ":")) } - if i < l { - uri.WriteByte(route.Path[i]) - } - } + }) break } } @@ -213,49 +212,48 @@ func (r *Router) Add(method, path string, h HandlerFunc) { func (r *Router) insert(method, path string, h HandlerFunc) { path = normalizePathSlash(path) - pnames := []string{} // Param names - ppath := path // Pristine path - if h == nil && r.echo.Logger != nil { // FIXME: in future we should return error r.echo.Logger.Errorf("Adding route without handler function: %v:%v", method, path) } - - for i, lcpIndex := 0, len(path); i < lcpIndex; i++ { - if path[i] == ':' { - if i > 0 && path[i-1] == '\\' { - path = path[:i-1] + path[i:] - i-- - lcpIndex-- - continue - } - j := i + 1 - - r.insertNode(method, path[:i], staticKind, routeMethod{}) - for ; i < lcpIndex && path[i] != '/'; i++ { + parts := parseRoutePath(path) + pnames := []string{} + for _, part := range parts { + if part.kind == paramKind { + pnames = append(pnames, part.value) + } else if part.kind == anyKind { + pnames = append(pnames, "*") + break + } + } + rm := routeMethod{ppath: path, pnames: pnames, handler: h} + treePath, paramMarkers := routeTreePath(parts) + pathEnd := 0 + for i, part := range parts { + switch part.kind { + case staticKind: + pathEnd += len(part.value) + if i == len(parts)-1 { + r.insertNode(method, treePath[:pathEnd], staticKind, rm, paramMarkers) } - - pnames = append(pnames, path[j:i]) - path = path[:j] + path[i:] - i, lcpIndex = j, len(path) - - if i == lcpIndex { - // path node is last fragment of route path. ie. `/users/:id` - r.insertNode(method, path[:i], paramKind, routeMethod{ppath: ppath, pnames: pnames, handler: h}) + case paramKind: + r.insertNode(method, treePath[:pathEnd], staticKind, routeMethod{}, paramMarkers) + pathEnd++ + if i == len(parts)-1 { + r.insertNode(method, treePath[:pathEnd], paramKind, rm, paramMarkers) } else { - r.insertNode(method, path[:i], paramKind, routeMethod{}) + r.insertNode(method, treePath[:pathEnd], paramKind, routeMethod{}, paramMarkers) } - } else if path[i] == '*' { - r.insertNode(method, path[:i], staticKind, routeMethod{}) - pnames = append(pnames, "*") - r.insertNode(method, path[:i+1], anyKind, routeMethod{ppath: ppath, pnames: pnames, handler: h}) + case anyKind: + r.insertNode(method, treePath[:pathEnd], staticKind, routeMethod{}, paramMarkers) + pathEnd++ + r.insertNode(method, treePath[:pathEnd], anyKind, rm, paramMarkers) + return } } - - r.insertNode(method, path, staticKind, routeMethod{ppath: ppath, pnames: pnames, handler: h}) } -func (r *Router) insertNode(method, path string, t kind, rm routeMethod) { +func (r *Router) insertNode(method, path string, t kind, rm routeMethod, paramMarkers []int) { // Adjust max param paramLen := len(rm.pnames) if *r.echo.maxParam < paramLen { @@ -267,6 +265,7 @@ func (r *Router) insertNode(method, path string, t kind, rm routeMethod) { panic("echo: invalid method") } search := path + searchOffset := 0 for { searchLen := len(search) @@ -360,8 +359,10 @@ func (r *Router) insertNode(method, path string, t kind, rm routeMethod) { } currentNode.isLeaf = currentNode.staticChildren == nil && currentNode.paramChild == nil && currentNode.anyChild == nil } else if lcpLen < searchLen { + searchOffset += lcpLen search = search[lcpLen:] - c := currentNode.findChildWithLabel(search[0]) + isParamMarker := slices.Contains(paramMarkers, searchOffset) + c := currentNode.findChildWithLabel(search[0], isParamMarker) if c != nil { // Go deeper currentNode = c @@ -426,6 +427,56 @@ func newNode( func (n *node) addStaticChild(c *node) { n.staticChildren = append(n.staticChildren, c) + // param nodes are never split (their prefix is a single byte), so this is where their inline verb child is set + if n.kind == paramKind && c.label == ':' { + n.hasColonChild = true + } +} + +// hasPendingInlineVerbSplit reports whether a param node from n up to the root has a value in paramValues that ended at +// an inline verb split and so can still be retried. searchIndex and paramIndex are the routing state at n. It does +// not change that state, so a request that ends here keeps its param values. +func hasPendingInlineVerbSplit(n *node, path string, searchIndex, paramIndex int, paramValues []string) bool { + for ; n != nil; n = n.parent { + if n.hasColonChild && searchIndex < len(path) && path[searchIndex] == ':' { + return true + } + if n.kind == staticKind { + searchIndex -= len(n.prefix) + } else { + paramIndex-- + searchIndex -= len(paramValues[paramIndex]) + } + } + return false +} + +// inlineVerbSplit returns where a param value in search ends: at the first literal colon at or after from where this +// node's inline verb child could match, otherwise at the end of the path segment (or of the path when that child is the +// node's only child). A split value is never empty. The scan stops at the next slash, so trying every split of a +// segment in turn is linear in its length. +// +// A split is only chosen when the whole prefix of the inline verb child matches. Routing therefore never backtracks +// into the param node from a prefix mismatch of that child, and the split only needs to be retried when backtracking +// from within the child's subtree. +func (n *node) inlineVerbSplit(search string, from int) int { + verbs := n.findStaticChild(':') + for i := from; i < len(search); i++ { + switch search[i] { + case '/': + if len(n.staticChildren) == 1 { + // the inline verb child is the only child (a param node never has a param or any child): without a + // split the param takes the rest of the path, as a leaf param does + return len(search) + } + return i + case ':': + if i > 0 && verbs != nil && strings.HasPrefix(search[i:], verbs.prefix) { + return i + } + } + } + return len(search) } func (n *node) findStaticChild(l byte) *node { @@ -437,13 +488,13 @@ func (n *node) findStaticChild(l byte) *node { return nil } -func (n *node) findChildWithLabel(l byte) *node { +func (n *node) findChildWithLabel(l byte, isParamMarker bool) *node { + if isParamMarker { + return n.paramChild + } if c := n.findStaticChild(l); c != nil { return c } - if l == paramLabel { - return n.paramChild - } if l == anyLabel { return n.anyChild } @@ -618,7 +669,7 @@ func (r *Router) Find(method, path string, c Context) { // No matching prefix, let's backtrack to the first possible alternative node of the decision path nk, ok := backtrackToNextNodeKind(staticKind) if !ok { - return // No other possibilities on the decision path, handler will be whatever context is reset to. + break // No other possibilities on the decision path. } else if nk == paramKind { goto Param // NOTE: this case (backtracking from static node to previous any node) can not happen by current any matching logic. Any node is end of search currently @@ -671,9 +722,12 @@ func (r *Router) Find(method, path string, c Context) { // when param node does not have any children (path param is last piece of route path) then param node should // act similarly to any node - consider all remaining search as match i = l - } else { - for ; i < l && search[i] != '/'; i++ { - } + } else if currentNode.hasColonChild { + // an inline verb (`/:name\:verb`) can end the param value at a literal colon. Start with the first + // possible split, the param node is retried with the next one before backtracking (see below). + i = currentNode.inlineVerbSplit(search, 0) + } else if i = strings.IndexByte(search, '/'); i < 0 { + i = l } paramValues[paramIndex] = search[:i] @@ -711,16 +765,35 @@ func (r *Router) Find(method, path string, c Context) { // Let's backtrack to the first possible alternative node of the decision path nk, ok := backtrackToNextNodeKind(anyKind) + Backtracked: if !ok { break // No other possibilities on the decision path } else if nk == paramKind { + if currentNode.hasColonChild && search != "" && search[0] == ':' { + goto InlineVerbSplit + } goto Param } else if nk == anyKind { goto Any + } else if hasPendingInlineVerbSplit(currentNode, path, searchIndex, paramIndex, paramValues) { + // A wildcard ends the search, except below a param value that ended at an inline verb split: keep + // backtracking, so the other routes below that split and then the next split are still tried. + nk, ok = backtrackToNextNodeKind(anyKind) + goto Backtracked } else { // Not found break } + + InlineVerbSplit: + // A param value that ended at an inline verb split is a decision point of the param node itself. When its + // inline verb child fails, retry the node with the next split, and finally with the whole path segment, + // before backtracking to its parent. + start := searchIndex - len(paramValues[paramIndex-1]) + searchIndex = start + currentNode.inlineVerbSplit(path[start:], len(paramValues[paramIndex-1])+1) + paramValues[paramIndex-1] = path[start:searchIndex] + search = path[searchIndex:] + continue } if currentNode == nil && previousBestMatchNode == nil { diff --git a/router_test.go b/router_test.go index 203d014ec..04c63d6fd 100644 --- a/router_test.go +++ b/router_test.go @@ -1449,6 +1449,47 @@ func TestRouterParamStaticConflict(t *testing.T) { } } +func TestRouterParamLiteralByteConflictServeHTTP(t *testing.T) { + tests := []struct { + name, literalRoute, literalRequest string + }{ + {"escaped colon", `/name\:verb/x`, "/name:verb/x"}, + {"encoded NUL", "/name\x00verb/x", "/name%00verb/x"}, + } + for _, tc := range tests { + for _, literalFirst := range []bool{true, false} { + name := tc.name + "/parameter-first" + routes := []string{"/name:id", tc.literalRoute} + if literalFirst { + name = tc.name + "/literal-first" + routes[0], routes[1] = routes[1], routes[0] + } + t.Run(name, func(t *testing.T) { + e := New() + for _, route := range routes { + e.GET(route, func(c Context) error { + return c.String(http.StatusOK, c.Path()) + }) + } + for _, request := range []struct{ path, want string }{ + {tc.literalRequest, tc.literalRoute}, + {"/name1", "/name:id"}, + } { + t.Run(request.path, func(t *testing.T) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, request.path, nil) + if !assert.NotPanics(t, func() { e.ServeHTTP(rec, req) }) { + return + } + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, request.want, rec.Body.String()) + }) + } + }) + } + } +} + 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