Skip to content

Commit f085ffb

Browse files
authored
fix(router): handle escaped colons and inline verbs in v4 (#3132)
* fix(router): keep escaped colon and parameter routes reachable in v4 * fix(router): parse inline verbs consistently in v4 * fix(router): backtrack inline verb splits in v4 * perf(router): preserve v4 fast path without inline verbs * fix(router): retry inline verb splits on the param node in v4 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). * fix(router): retry inline verb splits after a wildcard fails 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. * fix(router): keep backtracking below an inline verb split 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. * fix(router): keep escaped colons after complex param names in v4 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. * fix(router): keep leaf params matching the rest of the path 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. * test(router): cover leaf param fallbacks next to an inline verb 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.
1 parent 5440f99 commit f085ffb

4 files changed

Lines changed: 633 additions & 60 deletions

File tree

‎route_path.go‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package echo
5+
6+
import "strings"
7+
8+
// routePathPart is one parsed piece of a route pattern. A backslash before a
9+
// colon makes the colon static. After a parameter name it starts an inline verb
10+
// (`/:name\:cancel`) when the rest of that path segment is static.
11+
type routePathPart struct {
12+
kind kind
13+
value string
14+
}
15+
16+
func parseRoutePath(path string) []routePathPart {
17+
var parts []routePathPart
18+
walkRoutePath(path, func(part routePathPart) { parts = append(parts, part) })
19+
return parts
20+
}
21+
22+
// walkRoutePath is the common syntax scanner. Reverse uses it directly to
23+
// avoid allocating a parts slice for each URL it builds.
24+
func walkRoutePath(path string, emit func(routePathPart)) {
25+
for i := 0; i < len(path); {
26+
if isEscapedColon(path, i) {
27+
emit(routePathPart{kind: staticKind, value: ":"})
28+
i += 2
29+
} else if path[i] == ':' {
30+
start := i + 1
31+
i = start
32+
plainName := true // an escaped colon only starts an inline verb after a name without ':' or '*'
33+
for i < len(path) && path[i] != '/' {
34+
if isEscapedColon(path, i) {
35+
if plainName && isInlineVerb(path[i+2:]) {
36+
break
37+
}
38+
// not an inline verb: the rest of the segment is the param name, as before inline verbs
39+
for i < len(path) && path[i] != '/' {
40+
i++
41+
}
42+
break
43+
}
44+
if path[i] == ':' || path[i] == '*' {
45+
plainName = false
46+
}
47+
i++
48+
}
49+
emit(routePathPart{kind: paramKind, value: path[start:i]})
50+
} else if path[i] == '*' {
51+
start := i
52+
for i < len(path) && path[i] != '/' {
53+
i++
54+
}
55+
emit(routePathPart{kind: anyKind, value: path[start:i]})
56+
} else {
57+
start := i
58+
for i < len(path) && path[i] != ':' && path[i] != '*' && !isEscapedColon(path, i) {
59+
i++
60+
}
61+
emit(routePathPart{kind: staticKind, value: path[start:i]})
62+
}
63+
}
64+
}
65+
66+
func isEscapedColon(path string, i int) bool {
67+
return path[i] == '\\' && i+1 < len(path) && path[i+1] == ':'
68+
}
69+
70+
// isInlineVerb reports whether the route text after an escaped colon stays
71+
// static up to the end of its path segment. Only then can the router find where
72+
// the parameter value ends by trying the colons in the requested segment. Other
73+
// escaped colons keep the older meaning and remain part of the parameter name.
74+
func isInlineVerb(rest string) bool {
75+
for i := 0; i < len(rest) && rest[i] != '/'; i++ {
76+
switch rest[i] {
77+
case '*':
78+
return false
79+
case ':':
80+
if i == 0 || rest[i-1] != '\\' {
81+
return false
82+
}
83+
}
84+
}
85+
return true
86+
}
87+
88+
func routeTreePath(parts []routePathPart) (string, []int) {
89+
var path strings.Builder
90+
var paramMarkers []int
91+
for _, part := range parts {
92+
switch part.kind {
93+
case staticKind:
94+
path.WriteString(part.value)
95+
case paramKind:
96+
paramMarkers = append(paramMarkers, path.Len())
97+
path.WriteByte(':')
98+
case anyKind:
99+
path.WriteByte(anyLabel)
100+
return path.String(), paramMarkers
101+
}
102+
}
103+
return path.String(), paramMarkers
104+
}

0 commit comments

Comments
 (0)