From 2e750bab46b32aaf80652dfa0548463c48e53ebe Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Mon, 16 Feb 2026 00:31:57 +0100 Subject: [PATCH 01/11] refactor(parser): extract route parsing into dedicated sub-parsers --- fox.go | 396 --------------- fox_test.go | 1266 ------------------------------------------------ go.mod | 8 +- go.sum | 12 +- node.go | 2 +- node_test.go | 2 +- parser.go | 460 ++++++++++++++++++ parser_test.go | 1188 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1660 insertions(+), 1674 deletions(-) create mode 100644 parser.go create mode 100644 parser_test.go diff --git a/fox.go b/fox.go index 4780eb37..5f949f3f 100644 --- a/fox.go +++ b/fox.go @@ -12,7 +12,6 @@ import ( "net" "net/http" "path" - "regexp" "slices" "strings" "sync" @@ -1075,401 +1074,6 @@ func internalFixedPathHandler(c *Context) { http.Redirect(c.Writer(), req, cleanedPath, code) } -const ( - stateDefault uint8 = iota - stateParam - stateCatchAll - stateRegex -) - -type parsedRoute struct { - token []token - paramCnt int - endHost int - startCatchAll int -} - -// parseRoute parse and validate the route in a single pass. -func (fox *Router) parseRoute(url string) (parsedRoute, error) { - endHost := strings.IndexByte(url, '/') - if endHost == -1 { - return parsedRoute{}, fmt.Errorf("%w: missing trailing '/' after hostname", ErrInvalidRoute) - } - if strings.HasPrefix(url, ".") { - return parsedRoute{}, fmt.Errorf("%w: illegal leading '.' in hostname label", ErrInvalidRoute) - } - if strings.HasPrefix(url, "-") { - return parsedRoute{}, fmt.Errorf("%w: illegal leading '-' in hostname label", ErrInvalidRoute) - } - - var delim byte - if endHost == 0 { - delim = slashDelim - } else { - delim = dotDelim - } - - state := stateDefault - previous := stateDefault - paramCnt := 0 - countStatic := 2 - startParam := 0 - inParam := false - nonNumeric := false // true once we've seen a letter or hyphen - startCatchAll := 0 // start index of +{foo} or *{foo} - partlen := 0 - totallen := 0 - last := dotDelim - tokens := make([]token, 0, 1) // At least one segment - sb := strings.Builder{} - - i := 0 - for i < len(url) { - switch state { - case stateParam: - if url[i] == '}' { - if !inParam { - return parsedRoute{}, fmt.Errorf("%w: missing parameter name between '{}'", ErrInvalidRoute) - } - inParam = false - - if i+1 < len(url) && url[i+1] != delim && url[i+1] != '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' after '{param}'", ErrInvalidRoute, string(url[i+1])) - } - - if i < endHost { - nonNumeric = true - } - - if previous != stateRegex { - tokens = append(tokens, token{ - typ: nodeParam, - value: url[startParam+1 : i], - }) - } - - countStatic = 1 - previous = state - state = stateDefault - i++ - continue - } - - if url[i] == ':' { - previous = state - state = stateRegex - i++ - continue - } - - if i-startParam > fox.maxParamKeyBytes { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrParamKeyTooLarge) - } - - if url[i] == delim || url[i] == '/' || url[i] == '*' || url[i] == '+' || url[i] == '{' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' in '{param}'", ErrInvalidRoute, string(url[i])) - } - inParam = true - i++ - case stateCatchAll: - if url[i] == '}' { - if !inParam { - return parsedRoute{}, fmt.Errorf("%w: missing parameter name between '%c{}'", ErrInvalidRoute, url[startCatchAll]) - } - inParam = false - - if i+1 < len(url) && url[i+1] != delim && url[i+1] != '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' after '%c{param}'", ErrInvalidRoute, string(url[i+1]), url[startCatchAll]) - } - - if previous == stateCatchAll && countStatic <= 1 { - return parsedRoute{}, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) - } - - if i < len(url)-1 { - if url[startCatchAll] == '*' { - return parsedRoute{}, fmt.Errorf("%w: '*{param}' allowed only as suffix", ErrInvalidRoute) - } - // reset - startCatchAll = 0 - } - - if i < endHost { - nonNumeric = true - } - - if previous != stateRegex { - tokens = append(tokens, token{ - typ: nodeWildcard, - value: url[startParam+1 : i], - }) - } - - countStatic = 0 - previous = state - state = stateDefault - i++ - continue - } - - if url[i] == ':' { - // Optional wildcards (*{param}) do not support regular expressions because they match - // empty strings, making it impossible to disambiguate routes with different regexps that - // both match the same empty-string path. - if url[startCatchAll] == '*' { - return parsedRoute{}, fmt.Errorf("%w: %w in optional wildcard", ErrInvalidRoute, ErrRegexpNotAllowed) - } - previous = state - state = stateRegex - i++ - continue - } - - if i-startParam > fox.maxParamKeyBytes { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrParamKeyTooLarge) - } - - if url[i] == delim || url[i] == '/' || url[i] == '*' || url[i] == '+' || url[i] == '{' { - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' in '%c{param}'", ErrInvalidRoute, string(url[i]), url[startCatchAll]) - } - inParam = true - i++ - case stateRegex: - if !fox.allowRegexp { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrRegexpNotAllowed) - } - if previous == stateCatchAll && countStatic <= 1 { - return parsedRoute{}, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) - } - - idx := braceIndice(url[i:], 1) - if idx == -1 { - return parsedRoute{}, fmt.Errorf("%w: unbalanced braces in regular expression", ErrInvalidRoute) - } - if idx == 0 { - return parsedRoute{}, fmt.Errorf("%w: missing regular expression", ErrInvalidRoute) - } - - pattern := url[i : i+idx] - re, err := regexp.Compile("^" + pattern + "$") - if err != nil { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, err) - } - - if re.NumSubexp() > 0 { - return parsedRoute{}, fmt.Errorf("%w: illegal capture group '%s': use (?:pattern) instead", ErrInvalidRoute, pattern) - } - - typ := nodeWildcard - if previous == stateParam { - typ = nodeParam - } - - tokens = append(tokens, token{ - typ: typ, - value: url[startParam+1 : i-1], - regexp: re, - }) - - // restore - state, previous = previous, state - i += idx - - default: - - if i == endHost { - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - hsplit: true, - }) - sb.Reset() - } - delim = slashDelim - countStatic = 2 // reset - } - - switch url[i] { - case '{': - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - hsplit: i < endHost, - }) - sb.Reset() - } - state = stateParam - startParam = i - paramCnt++ - case '*', '+': - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - hsplit: i < endHost, - }) - sb.Reset() - } - state = stateCatchAll - startCatchAll = i - i++ - if i < len(url) && url[i] != '{' { - return parsedRoute{}, fmt.Errorf("%w: missing '{param}' after '%c' catch-all delimiter", ErrInvalidRoute, url[startCatchAll]) - } - startParam = i - paramCnt++ - default: - sb.WriteByte(url[i]) - countStatic++ - if i < endHost { - c := url[i] - switch { - case 'a' <= c && c <= 'z' || c == '_': - nonNumeric = true - partlen++ - case '0' <= c && c <= '9': - // fine - partlen++ - case c == '-': - // Byte before dash cannot be dot. - if last == '.' { - return parsedRoute{}, fmt.Errorf("%w: illegal '-' after '.' in hostname label", ErrInvalidRoute) - } - partlen++ - nonNumeric = true - case c == '.': - // Byte before dot cannot be dot. - if last == '.' && url[i-1] != '}' { - return parsedRoute{}, fmt.Errorf("%w: unexpected consecutive '.' in hostname", ErrInvalidRoute) - } - // Byte before dot cannot be dash. - if last == '-' { - return parsedRoute{}, fmt.Errorf("%w: illegal '-' before '.' in hostname label", ErrInvalidRoute) - } - if partlen > 63 { - return parsedRoute{}, fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) - } - totallen += partlen + 1 // +1 count the current dot - partlen = 0 - case 'A' <= c && c <= 'Z': - return parsedRoute{}, fmt.Errorf("%w: illegal uppercase character '%s' in hostname label", ErrInvalidRoute, string(c)) - default: - return parsedRoute{}, fmt.Errorf("%w: illegal character '%s' in hostname label", ErrInvalidRoute, string(c)) - } - last = c - } else { - c := url[i] - // reject any ASCII control character. - if c < ' ' || c == 0x7f { - return parsedRoute{}, fmt.Errorf("%w: illegal control character in path", ErrInvalidRoute) - } - - // reject any consecutive slash - if i > endHost && c == '/' && url[i-1] == '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal consecutive slashes in path", ErrInvalidRoute) - } - - // reject dot-based traversal patterns - if i > endHost && c == '.' && url[i-1] == '/' { - nextIdx := i + 1 - if nextIdx < len(url) { - nextChar := url[nextIdx] - switch nextChar { - case '/': - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/./'", ErrInvalidRoute) - case '.': - nextNextIdx := nextIdx + 1 - if nextNextIdx < len(url) { - if url[nextNextIdx] == '/' { - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/../'", ErrInvalidRoute) - } - } else { - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/..' at end", ErrInvalidRoute) - } - } - } else { - return parsedRoute{}, fmt.Errorf("%w: illegal path traversal pattern '/.' at end", ErrInvalidRoute) - } - } - } - } - - if paramCnt > fox.maxParams { - return parsedRoute{}, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrTooManyParams) - } - i++ - } - } - - if endHost > 0 { - totallen += partlen - if last == '-' { - return parsedRoute{}, fmt.Errorf("%w: illegal trailing '-' in hostname label", ErrInvalidRoute) - } - if url[endHost-1] == '.' { - return parsedRoute{}, fmt.Errorf("%w: illegal trailing '.' in hostname label", ErrInvalidRoute) - } - if !nonNumeric { - return parsedRoute{}, fmt.Errorf("%w: invalid all numeric hostname", ErrInvalidRoute) - } - if partlen > 63 { - return parsedRoute{}, fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) - } - if totallen > 253 { - return parsedRoute{}, fmt.Errorf("%w: hostname exceed 253 characters", ErrInvalidRoute) - } - } - - if state == stateParam { - return parsedRoute{}, fmt.Errorf("%w: unclosed '{param}'", ErrInvalidRoute) - } - - if state == stateCatchAll { - prev := len(url) - 1 - if url[prev] == '*' || url[prev] == '+' { - return parsedRoute{}, fmt.Errorf("%w: missing '{param}' after '%c' catch-all delimiter", ErrInvalidRoute, url[prev]) - } - return parsedRoute{}, fmt.Errorf("%w: unclosed '%c{param}'", ErrInvalidRoute, url[prev]) - } - - if sb.Len() > 0 { - tokens = append(tokens, token{ - typ: nodeStatic, - value: sb.String(), - }) - } - - return parsedRoute{ - token: tokens, - paramCnt: paramCnt, - endHost: endHost, - startCatchAll: startCatchAll, - }, nil -} - -// braceIndices returns the index of the closing brace that balances an opening -// brace. It starts at startLevel opened brace. -// -// Example: For pattern "{id:[0-9]{1,3}}", the caller would pass "[0-9]{1,3}}" and 1 -// (everything after the initial '{'), and this returns 10 (index of the final '}'). -func braceIndice(s string, startLevel int) int { - level := startLevel - - for i := 0; i < len(s); i++ { - switch s[i] { - case '{': - level++ - case '}': - if level--; level == 0 { - return i - } - } - } - return -1 -} - func routingPath(r *http.Request) string { if r.URL.RawPath == "" { return r.URL.EscapedPath() diff --git a/fox_test.go b/fox_test.go index d58c2df0..7c9e1d6b 100644 --- a/fox_test.go +++ b/fox_test.go @@ -2195,1272 +2195,6 @@ func TestUpdateRoute(t *testing.T) { } } -func TestParseRoute(t *testing.T) { - f := MustRouter(AllowRegexpParam(true)) - - staticToken := func(v string, hsplit bool) token { - return token{ - value: v, - typ: nodeStatic, - hsplit: hsplit, - } - } - - paramToken := func(v, reg string) token { - tk := token{ - value: v, - typ: nodeParam, - } - if reg != "" { - tk.regexp = regexp.MustCompile("^" + reg + "$") - } - return tk - } - - wildcardToken := func(v, reg string) token { - tk := token{ - value: v, - typ: nodeWildcard, - } - if reg != "" { - tk.regexp = regexp.MustCompile("^" + reg + "$") - } - return tk - } - - cases := []struct { - wantErr error - name string - path string - wantN int - wantTokens []token - wantStartCatchAll int - }{ - { - name: "valid static route", - path: "/foo/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), - }, - { - name: "top level domain param", - path: "{tld}/foo/bar", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("tld", ""), - staticToken("/foo/bar", false), - )), - }, - { - name: "top level domain wildcard", - path: "+{tld}/foo/bar", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("tld", ""), - staticToken("/foo/bar", false), - )), - }, - { - name: "valid catch all route", - path: "/foo/bar/+{arg}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("arg", ""), - )), - wantStartCatchAll: 9, - }, - { - name: "valid param route", - path: "/foo/bar/{baz}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - paramToken("baz", ""), - )), - }, - { - name: "valid multi params route", - path: "/foo/{bar}/{baz}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - )), - }, - { - name: "valid same params route", - path: "/foo/{bar}/{bar}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", ""), - staticToken("/", false), - paramToken("bar", ""), - )), - }, - { - name: "valid multi params and catch all route", - path: "/foo/{bar}/{baz}/+{arg}", - wantN: 3, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - staticToken("/", false), - wildcardToken("arg", ""), - )), - wantStartCatchAll: 17, - }, - { - name: "valid inflight param", - path: "/foo/xyz:{bar}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/xyz:", false), - paramToken("bar", ""), - )), - }, - { - name: "valid inflight catchall", - path: "/foo/xyz:+{bar}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/xyz:", false), - wildcardToken("bar", ""), - )), - wantStartCatchAll: 9, - }, - { - name: "valid multi inflight param and catch all", - path: "/foo/xyz:{bar}/abc:{bar}/+{arg}", - wantN: 3, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/xyz:", false), - paramToken("bar", ""), - staticToken("/abc:", false), - paramToken("bar", ""), - staticToken("/", false), - wildcardToken("arg", ""), - )), - wantStartCatchAll: 25, - }, - { - name: "catch all with arg in the middle of the route", - path: "/foo/bar/+{bar}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/baz", false), - )), - }, - { - name: "multiple catch all suffix and inflight with arg in the middle of the route", - path: "/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", - wantN: 4, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/x", false), - wildcardToken("args", ""), - staticToken("/y/", false), - wildcardToken("z", ""), - staticToken("/", false), - paramToken("b", ""), - )), - }, - { - name: "inflight catch all with arg in the middle of the route", - path: "/foo/bar/damn+{bar}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/damn", false), - wildcardToken("bar", ""), - staticToken("/baz", false), - )), - }, - { - name: "catch all with arg in the middle of the route and param after", - path: "/foo/bar/+{bar}/{baz}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - )), - }, - { - name: "simple domain and path", - path: "foo/bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo", true), - staticToken("/bar", false), - )), - }, - { - name: "simple domain with trailing slash", - path: "foo/", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo", true), - staticToken("/", false), - )), - }, - { - name: "period in param path allowed", - path: "foo/{.bar}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo", true), - staticToken("/", false), - paramToken(".bar", ""), - )), - }, - { - name: "missing a least one slash", - path: "foo.com", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "empty parameter", - path: "/foo/bar{}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "missing arguments name after catch all", - path: "/foo/bar/*", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "missing arguments name after param", - path: "/foo/bar/{", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "catch all in the middle of the route", - path: "/foo/bar/*/baz", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "empty infix catch all", - path: "/foo/bar/+{}/baz", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "empty ending catch all", - path: "/foo/bar/baz/+{}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param", - path: "/foo/{{bar}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param", - path: "/foo/{*bar}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in catch-all", - path: "/foo/+{/bar}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "catch all not supported in hostname", - path: "a.b.c*/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal character in params hostname", - path: "a.b.c{/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal character in hostname label", - path: "a.b.c}/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param hostname", - path: "a.{.bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in wildcard hostname", - path: "a.+{.bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in param hostname", - path: "a.{/bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected character in wildcard hostname", - path: "a.+{/bar}.c/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "in flight catch-all after param in one route segment", - path: "/foo/{bar}+{baz}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "multiple param in one route segment", - path: "/foo/{bar}{baz}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "in flight param after catch all", - path: "/foo/+{args}{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive catch all with no slash", - path: "/foo/+{args}+{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive catch all", - path: "/foo/+{args}/+{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive catch all with inflight", - path: "/foo/ab+{args}/+{param}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected char after inflight catch all", - path: "/foo/ab+{args}a", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "unexpected char after catch all", - path: "/foo/+{args}a", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "prefix catch-all in hostname", - path: "+{any}.com/foo", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("any", ""), - staticToken(".com", true), - staticToken("/foo", false), - )), - }, - { - name: "infix catch-all in hostname", - path: "a.+{any}.com/foo", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.", true), - wildcardToken("any", ""), - staticToken(".com", true), - staticToken("/foo", false), - )), - }, - { - name: "illegal catch-all in hostname", - path: "a.b.+{any}/foo", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.b.", true), - wildcardToken("any", ""), - staticToken("/foo", false), - )), - }, - { - name: "static hostname with catch-all path", - path: "a.b.com/+{any}", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.b.com", true), - staticToken("/", false), - wildcardToken("any", ""), - )), - wantStartCatchAll: 8, - }, - { - name: "illegal control character in path", - path: "example.com/foo\x00", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal leading hyphen in hostname", - path: "-a.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal leading dot in hostname", - path: ".a.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal trailing hyphen in hostname", - path: "a.com-/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal trailing dot in hostname", - path: "a.com./", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal trailing dot in hostname after param", - path: "{tld}./foo/bar", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal single dot in hostname", - path: "./", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal hyphen before dot", - path: "a-.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal hyphen after dot", - path: "a.-com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal double dot", - path: "a..com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal double dot with param state", - path: "{b}..com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal double dot with inflight param state", - path: "a{b}..com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "param not finishing with delimiter in hostname", - path: "{a}b{b}.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "consecutive parameter in hostname", - path: "{a}{b}.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "leading hostname label exceed 63 characters", - path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "middle hostname label exceed 63 characters", - path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "trailing hostname label exceed 63 characters", - path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "illegal character in domain", - path: "a.b!.com/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "invalid all-numeric label", - path: "123/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "all-numeric label with param", - path: "123.{a}.456/", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("123.", true), - paramToken("a", ""), - staticToken(".456", true), - staticToken("/", false), - )), - }, - { - name: "all-numeric label with wildcard", - path: "123.+{a}.456/", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("123.", true), - wildcardToken("a", ""), - staticToken(".456", true), - staticToken("/", false), - )), - }, - { - name: "all-numeric label with path wildcard", - path: "123.456/{abc}", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "hostname exceed 255 character", - path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "invalid all-numeric label", - path: "11.22.33/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "invalid uppercase label", - path: "ABC/", - wantErr: ErrInvalidRoute, - wantN: 0, - }, - { - name: "2 regular params in domain", - path: "{a}.{b}.com/", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", ""), - staticToken(".", true), - paramToken("b", ""), - staticToken(".com", true), - staticToken("/", false), - )), - }, - { - name: "253 character with .", - path: "78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), - staticToken("/", false), - )), - }, - { - name: "param does not count at character", - path: "{a}.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", ""), - staticToken(".78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), - staticToken("/", false), - )), - }, - { - name: "hostname variant with multiple catch all suffix and inflight with arg in the middle of the route", - path: "example.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", - wantN: 4, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("example.com", true), - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/x", false), - wildcardToken("args", ""), - staticToken("/y/", false), - wildcardToken("z", ""), - staticToken("/", false), - paramToken("b", ""), - )), - }, - { - name: "hostname variant with inflight catch all with arg in the middle of the route", - path: "example.com/foo/bar/damn+{bar}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("example.com", true), - staticToken("/foo/bar/damn", false), - wildcardToken("bar", ""), - staticToken("/baz", false), - )), - }, - { - name: "hostname variant catch all with arg in the middle of the route and param after", - path: "example.com/foo/bar/+{bar}/{baz}", - wantN: 2, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("example.com", true), - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/", false), - paramToken("baz", ""), - )), - }, - { - name: "complex domain and path", - path: "{ab}.{c}.de{f}.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", - wantN: 7, - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("ab", ""), - staticToken(".", true), - paramToken("c", ""), - staticToken(".de", true), - paramToken("f", ""), - staticToken(".com", true), - staticToken("/foo/bar/", false), - wildcardToken("bar", ""), - staticToken("/x", false), - wildcardToken("args", ""), - staticToken("/y/", false), - wildcardToken("z", ""), - staticToken("/", false), - paramToken("b", ""), - )), - }, - // Reject path with traversal pattern - { - name: "path with double slash", - path: "/foo//bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with > double slash", - path: "/foo///bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with slash dot slash", - path: "/foo/./bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with slash dot slash", - path: "/foo/././bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with double dot parent reference", - path: "/foo/../bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path with double dot parent reference", - path: "/foo/../../bar", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash dot", - path: "/foo/.", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash double dot", - path: "/foo/..", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash dot", - path: "/.", - wantErr: ErrInvalidRoute, - }, - { - name: "path ending with slash double dot", - path: "/..", - wantErr: ErrInvalidRoute, - }, - // Allowed dot and slash combinaison - { - name: "last path segment starting with slash dot and text", - path: "/foo/.bar", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.bar", false), - )), - }, - { - name: "last path segment starting with slash dot and text", - path: "/foo/..bar", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/..bar", false), - )), - }, - { - name: "path segment starting with slash dot and text", - path: "/foo/.bar/baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.bar/baz", false), - )), - }, - { - name: "path segment starting with slash dot and param", - path: "/foo/.{foo}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.", false), - paramToken("foo", ""), - staticToken("/baz", false), - )), - }, - { - name: "path segment starting with slash dot and text", - path: "/foo/..bar/baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/..bar/baz", false), - )), - }, - { - name: "path segment starting with slash dot and param", - path: "/foo/..{foo}/baz", - wantN: 1, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/..", false), - paramToken("foo", ""), - staticToken("/baz", false), - )), - }, - { - name: "path segment ending with dot slash", - path: "/foo/bar./baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar./baz", false), - )), - }, - { - name: "path segment ending with double dot slash", - path: "/foo/bar../baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar../baz", false), - )), - }, - { - name: "path segment with > double dot", - path: "/foo/.../baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/.../baz", false), - )), - }, - { - name: "path segment ending with slash and > double dot", - path: "/foo/...", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/...", false), - )), - }, - { - name: "last path segment ending with dot", - path: "/foo/bar.", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar.", false), - )), - }, - { - name: "last path segment ending with double dot", - path: "/foo/bar..", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar..", false), - )), - }, - { - name: "path segment with dot", - path: "/foo/a.b.c", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/a.b.c", false), - )), - }, - { - name: "path segment with double dot", - path: "/foo/a..b..c", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/a..b..c", false), - )), - }, - // Regexp - { - name: "simple ending param with regexp", - path: "/foo/{bar:[A-z]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "[A-z]+"), - )), - wantN: 1, - }, - { - name: "simple ending param with regexp", - path: "/foo/+{bar:[A-z]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("bar", "[A-z]+"), - )), - wantN: 1, - wantStartCatchAll: 5, - }, - { - name: "simple infix param with regexp", - path: "/foo/{bar:[A-z]+}/baz", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "[A-z]+"), - staticToken("/baz", false), - )), - wantN: 1, - }, - { - name: "multi infix and ending param with regexp", - path: "/foo/{bar:[A-z]+}/{baz:[0-9]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "[A-z]+"), - staticToken("/", false), - paramToken("baz", "[0-9]+"), - )), - wantN: 2, - }, - { - name: "multi infix and ending wildcard with regexp", - path: "/foo/+{bar:[A-z]+}/a+{baz:[0-9]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("bar", "[A-z]+"), - staticToken("/a", false), - wildcardToken("baz", "[0-9]+"), - )), - wantN: 2, - wantStartCatchAll: 20, - }, - { - name: "consecutive infix regexp wildcard and regexp param allowed", - path: "/foo/+{bar:[A-z]+}/{baz:[0-9]+}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("bar", "[A-z]+"), - staticToken("/", false), - paramToken("baz", "[0-9]+"), - )), - wantN: 2, - }, - { - name: "hostname starting with regexp", - path: "{a:[A-z]+}.b.c/foo", - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", "[A-z]+"), - staticToken(".b.c", true), - staticToken("/foo", false), - )), - wantN: 1, - }, - { - name: "hostname with middle param regexp", - path: "a.{b:[A-z]+}.c/foo", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.", true), - paramToken("b", "[A-z]+"), - staticToken(".c", true), - staticToken("/foo", false), - )), - wantN: 1, - }, - { - name: "hostname ending with param regexp", - path: "a.b.{c:[A-z]+}/foo", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("a.b.", true), - paramToken("c", "[A-z]+"), - staticToken("/foo", false), - )), - wantN: 1, - }, - { - name: "non capturing group allowed in regexp", - path: "/foo/{bar:(?:foo|bar)}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - paramToken("bar", "(?:foo|bar)"), - )), - wantN: 1, - }, - { - name: "regexp wildcard at the beginning of the path", - path: "/+{foo:[A-z]+}/bar", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/", false), - wildcardToken("foo", "[A-z]+"), - staticToken("/bar", false), - )), - wantN: 1, - }, - { - name: "regexp wildcard at the beginning of the host", - path: "+{a:[A-z]+}.b.c/", - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("a", "[A-z]+"), - staticToken(".b.c", true), - staticToken("/", false), - )), - wantN: 1, - }, - { - name: "consecutive wildcard from hostname to path", - path: "+{foo}/+{bar}", - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("foo", ""), - staticToken("/", false), - wildcardToken("bar", ""), - )), - wantN: 2, - wantStartCatchAll: 7, - }, - { - name: "consecutive wildcard with empty catch all from hostname to path", - path: "+{foo}/*{bar}", - wantTokens: slices.Collect(iterutil.SeqOf( - wildcardToken("foo", ""), - staticToken("/", false), - wildcardToken("bar", ""), - )), - wantN: 2, - wantStartCatchAll: 7, - }, - { - name: "param then wildcard regexp", - path: "{a}.+{b:b}/", - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", ""), - staticToken(".", true), - wildcardToken("b", "b"), - staticToken("/", false), - )), - wantN: 2, - }, - { - name: "param regexp then wildcard regexp", - path: "{a:a}.+{b:b}/", - wantTokens: slices.Collect(iterutil.SeqOf( - paramToken("a", "a"), - staticToken(".", true), - wildcardToken("b", "b"), - staticToken("/", false), - )), - wantN: 2, - }, - { - name: "catch all empty as suffix", - path: "/foo/*{any}", - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - wildcardToken("any", ""), - )), - wantN: 1, - wantStartCatchAll: 5, - }, - { - name: "consecutive infix wildcard at start with regexp not allowed", - path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive wildcard with catch all empty not allowed", - path: "/+{foo}/*{baz}", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", - path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard at start with regexp not allowed", - path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard at start with and without regexp not allowed", - path: "/+{foo:[A-z]+}/+{baz}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard at start with and without regexp not allowed", - path: "+{foo:[A-z]+}.+{baz}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard at start with regexp not allowed", - path: "/+{foo}/+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard at start with regexp not allowed", - path: "+{foo}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with regexp not allowed", - path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard with regexp not allowed", - path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with first regexp not allowed", - path: "/foo/+{bar:[A-z]+}/+{baz}", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard with first regexp not allowed", - path: "foo.+{bar:[A-z]+}.+{baz}/", - wantErr: ErrInvalidRoute, - }, - { - name: "consecutive infix wildcard with second regexp not allowed", - path: "/foo/+{bar}/+{baz:[A-z]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "hostname consecutive infix wildcard with second regexp not allowed", - path: "foo.+{bar}.+{baz:[A-z]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "non slash char after regexp param not allowed", - path: "/foo/{bar:[A-z]+}a/", - wantErr: ErrInvalidRoute, - }, - { - name: "non slash char after regexp wildcard not allowed", - path: "/foo/+{bar:[A-z]+}a/", - wantErr: ErrInvalidRoute, - }, - { - name: "regexp wildcard not allowed in hostname", - path: "+{a.{b:[A-z]+}}.c/", - wantErr: ErrInvalidRoute, - }, - { - name: "regexp wildcard not allowed in hostname", - path: "+{a.b.{c:[A-z]+}/", - wantErr: ErrInvalidRoute, - }, - { - name: "missing param name with regexp", - path: "/foo/{:[A-z]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "missing wildcard name with regexp", - path: "/foo/+{:[A-z]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "missing regular expression", - path: "/foo/{a:}", - wantErr: ErrInvalidRoute, - }, - { - name: "missing regular expression with only ':'", - path: "/foo/{:}", - wantErr: ErrInvalidRoute, - }, - { - name: "unsupported regexp in optional wildcard", - path: "/foo/*{any:[A-z]+}", - wantErr: ErrInvalidRoute, - }, - { - name: "unbalanced braces in param regexp", - path: "/foo/{bar:[A-z]+", - wantErr: ErrInvalidRoute, - }, - { - name: "unbalanced braces in wildcard regexp", - path: "/foo/+{bar:[A-z]+", - wantErr: ErrInvalidRoute, - }, - { - name: "balanced braces in param regexp with invalid char after", - path: "/foo/{bar:{}}a", - wantErr: ErrInvalidRoute, - }, - { - name: "balanced braces in wildcard regexp with invalid brace after", - path: "/foo/{bar:{}}}", - wantErr: ErrInvalidRoute, - }, - { - name: "unbalanced braces in regexp complex", - path: "/foo/{bar:{{{{}}}}", - wantErr: ErrInvalidRoute, - }, - { - name: "invalid regular expression", - path: "/foo/{bar:a{5,2}}", - wantErr: ErrInvalidRoute, - }, - { - name: "invalid regular expression", - path: "/foo/{bar:\\k}", - wantErr: ErrInvalidRoute, - }, - { - name: "capture group in regexp are not allowed", - path: "/foo/{bar:(foo|bar)}", - wantErr: ErrInvalidRoute, - }, - { - name: "no opening brace after * wildcard", - path: "/foo/*:bar}", - wantErr: ErrInvalidRoute, - }, - { - name: "no infix catch all empty", - path: "/foo/*{any}/bar", - wantErr: ErrInvalidRoute, - }, - { - name: "no infix inflight catch all empty", - path: "/foo/uuid_*{any}/bar", - wantErr: ErrInvalidRoute, - }, - { - name: "no suffix catch all empty in hostname", - path: "a.b.*{any}/", - wantErr: ErrInvalidRoute, - }, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - parsed, err := f.parseRoute(tc.path) - require.ErrorIs(t, err, tc.wantErr) - assert.Equal(t, tc.wantN, parsed.paramCnt) - assert.Equal(t, tc.wantTokens, parsed.token) - assert.Equal(t, tc.wantStartCatchAll, parsed.startCatchAll) - if err == nil { - assert.Equal(t, strings.IndexByte(tc.path, '/'), parsed.endHost) - } - }) - } -} - -func TestParseRouteParamsConstraint(t *testing.T) { - t.Run("param limit", func(t *testing.T) { - f, _ := NewRouter(WithMaxRouteParams(3)) - _, err := f.parseRoute("/{1}/{2}/{3}") - assert.NoError(t, err) - _, err = f.parseRoute("/{1}/{2}/{3}/{4}") - assert.Error(t, err) - _, err = f.parseRoute("/ab{1}/{2}/cd/{3}/{4}/ef") - assert.Error(t, err) - }) - t.Run("param key limit", func(t *testing.T) { - f, _ := NewRouter(WithMaxRouteParamKeyBytes(3)) - _, err := f.parseRoute("/{abc}/{abc}/{abc}") - assert.NoError(t, err) - _, err = f.parseRoute("/{abcd}/{abc}/{abc}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/{abcd}/{abc}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/{abc}/{abcd}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/+{abcd}/{abc}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc}/{abc}/+{abcdef}") - assert.Error(t, err) - }) - t.Run("param key limit with regexp", func(t *testing.T) { - f, _ := NewRouter(WithMaxRouteParamKeyBytes(3), AllowRegexpParam(true)) - _, err := f.parseRoute("/{abc:a}/{abc:a}/{abc:a}") - assert.NoError(t, err) - _, err = f.parseRoute("/{abcd:a}/{abc:a}/{abc:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/{abcd:a}/{abc:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/{abc:a}/{abcd:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/+{abcd:a}/{abc:a}") - assert.Error(t, err) - _, err = f.parseRoute("/{abc:a}/{abc:a}/+{abcdef:a}") - assert.Error(t, err) - }) - t.Run("disabled regexp support for param", func(t *testing.T) { - f, _ := NewRouter() - _, err := f.parseRoute("/{a}/{b}/{c}") - assert.NoError(t, err) - // path params - _, err = f.parseRoute("/{a:a}/{b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/{b:b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/{b}/{c:c}") - assert.Error(t, err) - // hostname params - _, err = f.parseRoute("{a:a}.{b}.{c}/") - assert.Error(t, err) - _, err = f.parseRoute("{a}.{b:b}.{c}/") - assert.Error(t, err) - _, err = f.parseRoute("{a}.{b}.{c:c}/") - assert.Error(t, err) - }) - t.Run("disabled regexp support for wildcard", func(t *testing.T) { - f, _ := NewRouter() - _, err := f.parseRoute("/{a}/{b}/{c}") - assert.NoError(t, err) - // wildcard - _, err = f.parseRoute("/+{a:a}/{b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/+{b:b}/{c}") - assert.Error(t, err) - _, err = f.parseRoute("/{a}/{b}/+{c:c}") - assert.Error(t, err) - }) -} - func TestRouteMatchersConstraint(t *testing.T) { t.Run("insert: enforce max route matchers", func(t *testing.T) { f, _ := NewRouter(WithMaxRouteMatchers(3)) diff --git a/go.mod b/go.mod index 28ffda27..fe5f4033 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/fox-toolkit/fox -go 1.24.0 +go 1.26.0 require ( github.com/google/gofuzz v1.2.0 github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.49.0 - golang.org/x/sys v0.40.0 + golang.org/x/net v0.50.0 + golang.org/x/sys v0.41.0 ) require ( @@ -14,7 +14,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 1206058d..4dce4599 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,12 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/node.go b/node.go index 66cf1064..d017a34b 100644 --- a/node.go +++ b/node.go @@ -935,7 +935,7 @@ func parseBraceSegment(pattern string) (int, string) { key = "*" } - end := braceIndice(pattern, 0) + end := braceIndex(pattern, 0) if end <= 0 { return 0, "" } diff --git a/node_test.go b/node_test.go index ef6a0190..ae769f2c 100644 --- a/node_test.go +++ b/node_test.go @@ -196,7 +196,7 @@ func TestParseBraceSegment(t *testing.T) { { name: "double opening brace", pattern: "{{name}", - wantEnd: 0, // braceIndice needs balanced braces at level 0, {{name} ends at level 1 + wantEnd: 0, // braceIndex needs balanced braces at level 0, {{name} ends at level 1 wantKey: "", }, { diff --git a/parser.go b/parser.go new file mode 100644 index 00000000..3307986d --- /dev/null +++ b/parser.go @@ -0,0 +1,460 @@ +package fox + +import ( + "fmt" + "regexp" + "strings" +) + +type pattern struct { + // Canonical cleaned pattern: hostname + CleanPath(path). + str string + tokens []token + optionalCatchAll bool + endHost int +} + +// parsePattern parses and validates a route pattern by splitting it into hostname and path, +// cleaning the path, and delegating to focused sub-parsers. +func (fox *Router) parsePattern(raw string) (*pattern, int, error) { + endHost := strings.IndexByte(raw, '/') + if endHost == -1 { + return nil, 0, fmt.Errorf("%w: missing trailing '/' after hostname", ErrInvalidRoute) + } + + var ( + paramCount int + hostTokens []token + ) + + if endHost > 0 { + var err error + hostTokens, paramCount, err = fox.parseHostname(raw[:endHost]) + if err != nil { + return nil, 0, err + } + } + + // Clean the path to normalize traversal patterns (e.g. /foo/../bar -> /bar). + cleanedPath := CleanPath(raw[endHost:]) + + pathTokens, optCatchAll, paramCount, err := fox.parsePath(cleanedPath, paramCount) + if err != nil { + return nil, 0, err + } + + tokens := make([]token, 0, len(hostTokens)+len(pathTokens)) + tokens = append(tokens, hostTokens...) + tokens = append(tokens, pathTokens...) + + return &pattern{ + str: raw[:endHost] + cleanedPath, + tokens: tokens, + endHost: endHost, + optionalCatchAll: optCatchAll, + }, paramCount, nil +} + +// hostnameValidator tracks RFC 5890 hostname label state during parsing. +// It validates static characters one at a time, while the caller handles parameter tokenization. +type hostnameValidator struct { + partLen int // Current label length in bytes. + totalLen int // Total hostname length in bytes. + last byte // Last static char for dot/dash adjacency rules. + nonNumeric bool // True once we've seen a letter, hyphen, or parameter. +} + +// checkByte validates a single static hostname character against RFC 5890 rules +// (dot/dash adjacency, label length, uppercase, illegal characters) and updates tracking state. +func (v *hostnameValidator) checkByte(c byte) error { + switch { + case 'a' <= c && c <= 'z' || c == '_': + v.nonNumeric = true + v.partLen++ + case '0' <= c && c <= '9': + v.partLen++ + case c == '-': + if v.last == '.' { + return fmt.Errorf("%w: illegal '-' after '.' in hostname label", ErrInvalidRoute) + } + v.partLen++ + v.nonNumeric = true + case c == '.': + if v.last == '.' { + return fmt.Errorf("%w: unexpected consecutive '.' in hostname", ErrInvalidRoute) + } + if v.last == '-' { + return fmt.Errorf("%w: illegal '-' before '.' in hostname label", ErrInvalidRoute) + } + if v.partLen > 63 { + return fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) + } + v.totalLen += v.partLen + 1 // +1 counts the current dot. + v.partLen = 0 + case 'A' <= c && c <= 'Z': + return fmt.Errorf("%w: illegal uppercase character '%s' in hostname label", ErrInvalidRoute, string(c)) + default: + return fmt.Errorf("%w: illegal character '%s' in hostname label", ErrInvalidRoute, string(c)) + } + v.last = c + return nil +} + +// skipParam resets adjacency state after a {param} and marks the hostname as non-numeric. +func (v *hostnameValidator) skipParam() { + v.last = 0 + v.nonNumeric = true +} + +// postCheck runs final hostname validation: trailing dash/dot, all-numeric, label and total length. +func (v *hostnameValidator) postCheck() error { + v.totalLen += v.partLen + if v.last == '-' { + return fmt.Errorf("%w: illegal trailing '-' in hostname label", ErrInvalidRoute) + } + if v.last == '.' { + return fmt.Errorf("%w: illegal trailing '.' in hostname label", ErrInvalidRoute) + } + if !v.nonNumeric { + return fmt.Errorf("%w: invalid all numeric hostname", ErrInvalidRoute) + } + if v.partLen > 63 { + return fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) + } + if v.totalLen > 253 { + return fmt.Errorf("%w: hostname exceed 253 characters", ErrInvalidRoute) + } + return nil +} + +// parseHostname validates and tokenizes the hostname portion of a route pattern. +// It enforces RFC 5890 rules for labels and returns the number of parameters found. +func (fox *Router) parseHostname(hostname string) ([]token, int, error) { + var sb strings.Builder + sb.Grow(len(hostname)) + tokens := make([]token, 0, 1) // At least one token. + // Initialize last to dotDelim so that a leading '-' is caught by the "dash after dot" rule. + validator := hostnameValidator{last: dotDelim} + var ( + paramCount int + prevWild bool + staticSinceWild int + ) + + i := 0 + for i < len(hostname) { + c := hostname[i] + + switch c { + case '{', '+': + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String(), hsplit: true}) + sb.Reset() + } + isWild := c == '+' + if isWild { + i++ + if i >= len(hostname) || hostname[i] != '{' { + return nil, 0, fmt.Errorf("%w: missing '{param}' after '+' catch-all delimiter", ErrInvalidRoute) + } + if prevWild && staticSinceWild <= 1 { + return nil, 0, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) + } + } + name, re, n, err := fox.parseBrace(hostname[i:], dotDelim, false) + if err != nil { + return nil, 0, err + } + paramCount++ + if paramCount > fox.maxParams { + return nil, 0, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrTooManyParams) + } + + kind := nodeParam + if isWild { + kind = nodeWildcard + prevWild = true + staticSinceWild = 0 + } else { + prevWild = false + } + tokens = append(tokens, token{typ: kind, value: name, regexp: re}) + i += n + validator.skipParam() + // After closing brace, next char must be '.' (hostname delimiter) or end. + if i < len(hostname) && hostname[i] != '.' { + return nil, 0, fmt.Errorf("%w: illegal character '%s' after '{param}'", ErrInvalidRoute, string(hostname[i])) + } + + case '*': + // Optional wildcard *{param} is suffix-only; hostname always has a path after it. + i++ + if i < len(hostname) && hostname[i] == '{' { + return nil, 0, fmt.Errorf("%w: '*{param}' allowed only as suffix", ErrInvalidRoute) + } + return nil, 0, fmt.Errorf("%w: missing '{param}' after '*' catch-all delimiter", ErrInvalidRoute) + + default: + if err := validator.checkByte(c); err != nil { + return nil, 0, err + } + sb.WriteByte(c) + staticSinceWild++ + i++ + } + } + + if err := validator.postCheck(); err != nil { + return nil, 0, err + } + + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String(), hsplit: true}) + sb.Reset() + } + + return tokens, paramCount, nil +} + +// parsePath validates and tokenizes the path portion of a route pattern. +// The path must already be cleaned via CleanPath. paramCount is the number of parameters +// already parsed (e.g. from the hostname). Returns tokens, whether the path ends with an +// optional catch-all *{param}, and the updated total parameter count. +func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, error) { + var sb strings.Builder + sb.Grow(len(path)) + tokens := make([]token, 0, 1) // At least one token. + var ( + prevWild bool + staticSinceWild int + optCatchAll bool + ) + + i := 0 + for i < len(path) { + c := path[i] + + switch c { + case '{', '+', '*': + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String()}) + sb.Reset() + } + isOpt := c == '*' + isWild := c == '+' || isOpt + if isWild { + i++ + if i >= len(path) || path[i] != '{' { + return nil, false, 0, fmt.Errorf( + "%w: missing '{param}' after '%c' catch-all delimiter", ErrInvalidRoute, c, + ) + } + if prevWild && staticSinceWild <= 1 { + return nil, false, 0, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) + } + } + name, re, n, err := fox.parseBrace(path[i:], slashDelim, isOpt) + if err != nil { + return nil, false, 0, err + } + paramCount++ + if paramCount > fox.maxParams { + return nil, false, 0, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrTooManyParams) + } + + kind := nodeParam + if isWild { + kind = nodeWildcard + prevWild = true + staticSinceWild = 0 + } else { + prevWild = false + } + tokens = append(tokens, token{typ: kind, value: name, regexp: re}) + i += n + // Optional wildcard *{param} is only allowed as suffix (last thing in path). + if isOpt { + if i < len(path) { + return nil, false, 0, fmt.Errorf("%w: '*{param}' allowed only as suffix", ErrInvalidRoute) + } + optCatchAll = true + } + // After closing brace, next char must be '/' or end of path. + if i < len(path) && path[i] != '/' { + return nil, false, 0, fmt.Errorf( + "%w: illegal character '%s' after '{param}'", ErrInvalidRoute, string(path[i]), + ) + } + + default: + // Reject ASCII control characters. + if c < ' ' || c == 0x7f { + return nil, false, 0, fmt.Errorf("%w: illegal control character in path", ErrInvalidRoute) + } + sb.WriteByte(c) + staticSinceWild++ + i++ + } + } + + if sb.Len() > 0 { + tokens = append(tokens, token{typ: nodeStatic, value: sb.String()}) + sb.Reset() + } + return tokens, optCatchAll, paramCount, nil +} + +// parseBrace parses a parameter starting at '{' in s. It returns the parameter name, +// compiled regexp (nil if none), and total bytes consumed (including '{' and '}'). +// delim is the segment delimiter ('/' for path, '.' for hostname). +// isOptional indicates *{} (optional catch-all, which disallows regexp constraints). +func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *regexp.Regexp, int, error) { + // Skip s[0] (the opening '{') and start at nesting level 1 to account for it. + idx := braceIndex(s[1:], 1) + if idx == -1 { + return "", nil, 0, fmt.Errorf("%w: unbalanced braces in parameter definition", ErrInvalidRoute) + } + + content := s[1 : 1+idx] // Everything between { and }. + consumed := 1 + idx + 1 // { + content + } + + // Split into name and optional regex on first ':'. + name := content + var rawRegex string + hasRegex := false + if colonIdx := strings.IndexByte(content, ':'); colonIdx >= 0 { + name = content[:colonIdx] + rawRegex = content[colonIdx+1:] + hasRegex = true + } + + // Validate name length before possibly expensive regexp compilation. + if len(name) > fox.maxParamKeyBytes { + return "", nil, 0, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrParamKeyTooLarge) + } + + if len(name) == 0 { + if hasRegex { + return "", nil, 0, fmt.Errorf("%w: missing parameter name", ErrInvalidRoute) + } + return "", nil, 0, fmt.Errorf("%w: missing parameter name between '{}'", ErrInvalidRoute) + } + + // Validate name characters: no delimiters, no special chars. + for j := 0; j < len(name); j++ { + switch name[j] { + // TODO: just put . and /, add also } + case delim, '/', '*', '+', '{': + return "", nil, 0, fmt.Errorf( + "%w: illegal character '%s' in '{param}'", ErrInvalidRoute, string(name[j]), + ) + } + } + + if !hasRegex { + return name, nil, consumed, nil + } + + // Optional wildcards (*{param}) cannot have regexps because they match empty strings, + // making it impossible to disambiguate routes with different regexps. + if isOptional { + return "", nil, 0, fmt.Errorf("%w: %w in optional wildcard", ErrInvalidRoute, ErrRegexpNotAllowed) + } + + re, err := fox.compileParamRegexp(rawRegex) + if err != nil { + return "", nil, 0, err + } + return name, re, consumed, nil +} + +// compileParamRegexp validates and compiles a regular expression constraint for a parameter. +func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, error) { + if !fox.allowRegexp { + return nil, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrRegexpNotAllowed) + } + if rawRegex == "" { + return nil, fmt.Errorf("%w: missing regular expression", ErrInvalidRoute) + } + + re, err := regexp.Compile("^" + rawRegex + "$") + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrInvalidRoute, err) + } + if re.NumSubexp() > 0 { + return nil, fmt.Errorf( + "%w: illegal capture group '%s': use (?:pattern) instead", ErrInvalidRoute, rawRegex, + ) + } + + return re, nil +} + +// braceIndex returns the index of the closing brace that balances an opening +// brace. It starts at startLevel opened brace. +// +// Example: For pattern "{id:[0-9]{1,3}}", the caller would pass "[0-9]{1,3}}" and 1 +// (everything after the initial '{'), and this returns 10 (index of the final '}'). +func braceIndex(s string, startLevel int) int { + level := startLevel + + for i := 0; i < len(s); i++ { + switch s[i] { + case '{': + level++ + case '}': + if level--; level == 0 { + return i + } + } + } + return -1 +} + +// parsedRoute is a compatibility bridge for callers that have not yet migrated to parsePattern. +// It translates the new pattern type back into the old field layout. +type parsedRoute struct { + token []token + paramCnt int + endHost int + startCatchAll int +} + +// parseRoute wraps parsePattern to provide the old parsedRoute return type. +// Callers should migrate to parsePattern directly. +func (fox *Router) parseRoute(url string) (parsedRoute, error) { + p, _, err := fox.parsePattern(url) + if err != nil { + return parsedRoute{}, err + } + + // Backward compatibility: callers store the original url as the route pattern, + // so we must reject paths that CleanPath would normalize (e.g. //, ./, ../). + // Once callers migrate to parsePattern (which returns the cleaned canonical form), + // this check can be removed. + if p.str != url { + return parsedRoute{}, fmt.Errorf("%w: path is not clean, use CleanPath", ErrInvalidRoute) + } + + paramCnt := 0 + for _, tk := range p.tokens { + if tk.typ != nodeStatic { + paramCnt++ + } + } + + startCatchAll := 0 + if p.optionalCatchAll { + // Reconstruct the startCatchAll index for backwards compatibility. + // Callers use: startCatchAll > 0 && pattern[startCatchAll] == '*' + // So we need the index of '*' in the original pattern string. + startCatchAll = strings.LastIndexByte(url, '*') + } + + return parsedRoute{ + token: p.tokens, + paramCnt: paramCnt, + endHost: p.endHost, + startCatchAll: startCatchAll, + }, nil +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 00000000..2510122c --- /dev/null +++ b/parser_test.go @@ -0,0 +1,1188 @@ +package fox + +import ( + "regexp" + "slices" + "strings" + "testing" + + "github.com/fox-toolkit/fox/internal/iterutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePattern(t *testing.T) { + f := MustRouter(AllowRegexpParam(true)) + + staticToken := func(v string, hsplit bool) token { + return token{ + value: v, + typ: nodeStatic, + hsplit: hsplit, + } + } + + paramToken := func(v, reg string) token { + tk := token{ + value: v, + typ: nodeParam, + } + if reg != "" { + tk.regexp = regexp.MustCompile("^" + reg + "$") + } + return tk + } + + wildcardToken := func(v, reg string) token { + tk := token{ + value: v, + typ: nodeWildcard, + } + if reg != "" { + tk.regexp = regexp.MustCompile("^" + reg + "$") + } + return tk + } + + cases := []struct { + wantErr error + name string + path string + wantStr string // Expected parsed.str (defaults to path if empty). + wantTokens []token + wantOptionalCatchAll bool + }{ + { + name: "valid static route", + path: "/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + }, + { + name: "top level domain param", + path: "{tld}/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("tld", ""), + staticToken("/foo/bar", false), + )), + }, + { + name: "top level domain wildcard", + path: "+{tld}/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("tld", ""), + staticToken("/foo/bar", false), + )), + }, + { + name: "valid catch all route", + path: "/foo/bar/+{arg}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("arg", ""), + )), + }, + { + name: "valid param route", + path: "/foo/bar/{baz}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + paramToken("baz", ""), + )), + }, + { + name: "valid multi params route", + path: "/foo/{bar}/{baz}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + )), + }, + { + name: "valid same params route", + path: "/foo/{bar}/{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", ""), + staticToken("/", false), + paramToken("bar", ""), + )), + }, + { + name: "valid multi params and catch all route", + path: "/foo/{bar}/{baz}/+{arg}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + staticToken("/", false), + wildcardToken("arg", ""), + )), + }, + { + name: "valid inflight param", + path: "/foo/xyz:{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/xyz:", false), + paramToken("bar", ""), + )), + }, + { + name: "valid inflight catchall", + path: "/foo/xyz:+{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/xyz:", false), + wildcardToken("bar", ""), + )), + }, + { + name: "valid multi inflight param and catch all", + path: "/foo/xyz:{bar}/abc:{bar}/+{arg}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/xyz:", false), + paramToken("bar", ""), + staticToken("/abc:", false), + paramToken("bar", ""), + staticToken("/", false), + wildcardToken("arg", ""), + )), + }, + { + name: "catch all with arg in the middle of the route", + path: "/foo/bar/+{bar}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/baz", false), + )), + }, + { + name: "multiple catch all suffix and inflight with arg in the middle of the route", + path: "/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/x", false), + wildcardToken("args", ""), + staticToken("/y/", false), + wildcardToken("z", ""), + staticToken("/", false), + paramToken("b", ""), + )), + }, + { + name: "inflight catch all with arg in the middle of the route", + path: "/foo/bar/damn+{bar}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/damn", false), + wildcardToken("bar", ""), + staticToken("/baz", false), + )), + }, + { + name: "catch all with arg in the middle of the route and param after", + path: "/foo/bar/+{bar}/{baz}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + )), + }, + { + name: "simple domain and path", + path: "foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo", true), + staticToken("/bar", false), + )), + }, + { + name: "simple domain with trailing slash", + path: "foo/", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo", true), + staticToken("/", false), + )), + }, + { + name: "period in param path allowed", + path: "foo/{.bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo", true), + staticToken("/", false), + paramToken(".bar", ""), + )), + }, + { + name: "missing a least one slash", + path: "foo.com", + wantErr: ErrInvalidRoute, + }, + { + name: "empty parameter", + path: "/foo/bar{}", + wantErr: ErrInvalidRoute, + }, + { + name: "missing arguments name after catch all", + path: "/foo/bar/*", + wantErr: ErrInvalidRoute, + }, + { + name: "missing arguments name after param", + path: "/foo/bar/{", + wantErr: ErrInvalidRoute, + }, + { + name: "catch all in the middle of the route", + path: "/foo/bar/*/baz", + wantErr: ErrInvalidRoute, + }, + { + name: "empty infix catch all", + path: "/foo/bar/+{}/baz", + wantErr: ErrInvalidRoute, + }, + { + name: "empty ending catch all", + path: "/foo/bar/baz/+{}", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in param", + path: "/foo/{{bar}", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in param", + path: "/foo/{*bar}", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in catch-all", + path: "/foo/+{/bar}", + wantErr: ErrInvalidRoute, + }, + { + name: "catch all not supported in hostname", + path: "a.b.c*/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal character in params hostname", + path: "a.b.c{/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal character in hostname label", + path: "a.b.c}/", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in param hostname", + path: "a.{.bar}.c/", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in wildcard hostname", + path: "a.+{.bar}.c/", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in param hostname", + path: "a.{/bar}.c/", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected character in wildcard hostname", + path: "a.+{/bar}.c/", + wantErr: ErrInvalidRoute, + }, + { + name: "in flight catch-all after param in one route segment", + path: "/foo/{bar}+{baz}", + wantErr: ErrInvalidRoute, + }, + { + name: "multiple param in one route segment", + path: "/foo/{bar}{baz}", + wantErr: ErrInvalidRoute, + }, + { + name: "in flight param after catch all", + path: "/foo/+{args}{param}", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive catch all with no slash", + path: "/foo/+{args}+{param}", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive catch all", + path: "/foo/+{args}/+{param}", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive catch all with inflight", + path: "/foo/ab+{args}/+{param}", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected char after inflight catch all", + path: "/foo/ab+{args}a", + wantErr: ErrInvalidRoute, + }, + { + name: "unexpected char after catch all", + path: "/foo/+{args}a", + wantErr: ErrInvalidRoute, + }, + { + name: "prefix catch-all in hostname", + path: "+{any}.com/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("any", ""), + staticToken(".com", true), + staticToken("/foo", false), + )), + }, + { + name: "infix catch-all in hostname", + path: "a.+{any}.com/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.", true), + wildcardToken("any", ""), + staticToken(".com", true), + staticToken("/foo", false), + )), + }, + { + name: "illegal catch-all in hostname", + path: "a.b.+{any}/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.b.", true), + wildcardToken("any", ""), + staticToken("/foo", false), + )), + }, + { + name: "static hostname with catch-all path", + path: "a.b.com/+{any}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.b.com", true), + staticToken("/", false), + wildcardToken("any", ""), + )), + }, + { + name: "illegal control character in path", + path: "example.com/foo\x00", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal leading hyphen in hostname", + path: "-a.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal leading dot in hostname", + path: ".a.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal trailing hyphen in hostname", + path: "a.com-/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal trailing dot in hostname", + path: "a.com./", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal trailing dot in hostname after param", + path: "{tld}./foo/bar", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal single dot in hostname", + path: "./", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal hyphen before dot", + path: "a-.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal hyphen after dot", + path: "a.-com/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal double dot", + path: "a..com/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal double dot with param state", + path: "{b}..com/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal double dot with inflight param state", + path: "a{b}..com/", + wantErr: ErrInvalidRoute, + }, + { + name: "param not finishing with delimiter in hostname", + path: "{a}b{b}.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive parameter in hostname", + path: "{a}{b}.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "leading hostname label exceed 63 characters", + path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "middle hostname label exceed 63 characters", + path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "trailing hostname label exceed 63 characters", + path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", + wantErr: ErrInvalidRoute, + }, + { + name: "illegal character in domain", + path: "a.b!.com/", + wantErr: ErrInvalidRoute, + }, + { + name: "invalid all-numeric label", + path: "123/", + wantErr: ErrInvalidRoute, + }, + { + name: "all-numeric label with param", + path: "123.{a}.456/", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("123.", true), + paramToken("a", ""), + staticToken(".456", true), + staticToken("/", false), + )), + }, + { + name: "all-numeric label with wildcard", + path: "123.+{a}.456/", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("123.", true), + wildcardToken("a", ""), + staticToken(".456", true), + staticToken("/", false), + )), + }, + { + name: "all-numeric label with path wildcard", + path: "123.456/{abc}", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname exceed 255 character", + path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", + wantErr: ErrInvalidRoute, + }, + { + name: "invalid all-numeric label", + path: "11.22.33/", + wantErr: ErrInvalidRoute, + }, + { + name: "invalid uppercase label", + path: "ABC/", + wantErr: ErrInvalidRoute, + }, + { + name: "2 regular params in domain", + path: "{a}.{b}.com/", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", ""), + staticToken(".", true), + paramToken("b", ""), + staticToken(".com", true), + staticToken("/", false), + )), + }, + { + name: "253 character with .", + path: "78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), + staticToken("/", false), + )), + }, + { + name: "param does not count at character", + path: "{a}.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", ""), + staticToken(".78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), + staticToken("/", false), + )), + }, + { + name: "hostname variant with multiple catch all suffix and inflight with arg in the middle of the route", + path: "example.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("example.com", true), + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/x", false), + wildcardToken("args", ""), + staticToken("/y/", false), + wildcardToken("z", ""), + staticToken("/", false), + paramToken("b", ""), + )), + }, + { + name: "hostname variant with inflight catch all with arg in the middle of the route", + path: "example.com/foo/bar/damn+{bar}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("example.com", true), + staticToken("/foo/bar/damn", false), + wildcardToken("bar", ""), + staticToken("/baz", false), + )), + }, + { + name: "hostname variant catch all with arg in the middle of the route and param after", + path: "example.com/foo/bar/+{bar}/{baz}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("example.com", true), + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/", false), + paramToken("baz", ""), + )), + }, + { + name: "complex domain and path", + path: "{ab}.{c}.de{f}.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("ab", ""), + staticToken(".", true), + paramToken("c", ""), + staticToken(".de", true), + paramToken("f", ""), + staticToken(".com", true), + staticToken("/foo/bar/", false), + wildcardToken("bar", ""), + staticToken("/x", false), + wildcardToken("args", ""), + staticToken("/y/", false), + wildcardToken("z", ""), + staticToken("/", false), + paramToken("b", ""), + )), + }, + // CleanPath normalizes traversal patterns instead of rejecting them. + { + name: "double slash cleaned", + path: "/foo//bar", + wantStr: "/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + }, + { + name: "triple slash cleaned", + path: "/foo///bar", + wantStr: "/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + }, + { + name: "slash dot slash cleaned", + path: "/foo/./bar", + wantStr: "/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + }, + { + name: "slash dot slash dot slash cleaned", + path: "/foo/././bar", + wantStr: "/foo/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + }, + { + name: "double dot parent reference cleaned", + path: "/foo/../bar", + wantStr: "/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/bar", false))), + }, + { + name: "double parent reference cleaned", + path: "/foo/../../bar", + wantStr: "/bar", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/bar", false))), + }, + { + name: "trailing slash dot cleaned", + path: "/foo/.", + wantStr: "/foo/", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/", false))), + }, + { + name: "trailing slash double dot cleaned", + path: "/foo/..", + wantStr: "/", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/", false))), + }, + { + name: "root slash dot cleaned", + path: "/.", + wantStr: "/", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/", false))), + }, + { + name: "root slash double dot cleaned", + path: "/..", + wantStr: "/", + wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/", false))), + }, + // Allowed dot and slash combination + { + name: "last path segment starting with slash dot and text", + path: "/foo/.bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.bar", false), + )), + }, + { + name: "last path segment starting with slash dot and text", + path: "/foo/..bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/..bar", false), + )), + }, + { + name: "path segment starting with slash dot and text", + path: "/foo/.bar/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.bar/baz", false), + )), + }, + { + name: "path segment starting with slash dot and param", + path: "/foo/.{foo}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.", false), + paramToken("foo", ""), + staticToken("/baz", false), + )), + }, + { + name: "path segment starting with slash dot and text", + path: "/foo/..bar/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/..bar/baz", false), + )), + }, + { + name: "path segment starting with slash dot and param", + path: "/foo/..{foo}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/..", false), + paramToken("foo", ""), + staticToken("/baz", false), + )), + }, + { + name: "path segment ending with dot slash", + path: "/foo/bar./baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar./baz", false), + )), + }, + { + name: "path segment ending with double dot slash", + path: "/foo/bar../baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar../baz", false), + )), + }, + { + name: "path segment with > double dot", + path: "/foo/.../baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/.../baz", false), + )), + }, + { + name: "path segment ending with slash and > double dot", + path: "/foo/...", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/...", false), + )), + }, + { + name: "last path segment ending with dot", + path: "/foo/bar.", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar.", false), + )), + }, + { + name: "last path segment ending with double dot", + path: "/foo/bar..", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar..", false), + )), + }, + { + name: "path segment with dot", + path: "/foo/a.b.c", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/a.b.c", false), + )), + }, + { + name: "path segment with double dot", + path: "/foo/a..b..c", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/a..b..c", false), + )), + }, + // Regexp + { + name: "simple ending param with regexp", + path: "/foo/{bar:[A-z]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "[A-z]+"), + )), + }, + { + name: "simple ending param with regexp", + path: "/foo/+{bar:[A-z]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("bar", "[A-z]+"), + )), + }, + { + name: "simple infix param with regexp", + path: "/foo/{bar:[A-z]+}/baz", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "[A-z]+"), + staticToken("/baz", false), + )), + }, + { + name: "multi infix and ending param with regexp", + path: "/foo/{bar:[A-z]+}/{baz:[0-9]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "[A-z]+"), + staticToken("/", false), + paramToken("baz", "[0-9]+"), + )), + }, + { + name: "multi infix and ending wildcard with regexp", + path: "/foo/+{bar:[A-z]+}/a+{baz:[0-9]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("bar", "[A-z]+"), + staticToken("/a", false), + wildcardToken("baz", "[0-9]+"), + )), + }, + { + name: "consecutive infix regexp wildcard and regexp param allowed", + path: "/foo/+{bar:[A-z]+}/{baz:[0-9]+}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("bar", "[A-z]+"), + staticToken("/", false), + paramToken("baz", "[0-9]+"), + )), + }, + { + name: "hostname starting with regexp", + path: "{a:[A-z]+}.b.c/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", "[A-z]+"), + staticToken(".b.c", true), + staticToken("/foo", false), + )), + }, + { + name: "hostname with middle param regexp", + path: "a.{b:[A-z]+}.c/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.", true), + paramToken("b", "[A-z]+"), + staticToken(".c", true), + staticToken("/foo", false), + )), + }, + { + name: "hostname ending with param regexp", + path: "a.b.{c:[A-z]+}/foo", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("a.b.", true), + paramToken("c", "[A-z]+"), + staticToken("/foo", false), + )), + }, + { + name: "non capturing group allowed in regexp", + path: "/foo/{bar:(?:foo|bar)}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + paramToken("bar", "(?:foo|bar)"), + )), + }, + { + name: "regexp wildcard at the beginning of the path", + path: "/+{foo:[A-z]+}/bar", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/", false), + wildcardToken("foo", "[A-z]+"), + staticToken("/bar", false), + )), + }, + { + name: "regexp wildcard at the beginning of the host", + path: "+{a:[A-z]+}.b.c/", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("a", "[A-z]+"), + staticToken(".b.c", true), + staticToken("/", false), + )), + }, + { + name: "consecutive wildcard from hostname to path", + path: "+{foo}/+{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("foo", ""), + staticToken("/", false), + wildcardToken("bar", ""), + )), + }, + { + name: "consecutive wildcard with empty catch all from hostname to path", + path: "+{foo}/*{bar}", + wantTokens: slices.Collect(iterutil.SeqOf( + wildcardToken("foo", ""), + staticToken("/", false), + wildcardToken("bar", ""), + )), + wantOptionalCatchAll: true, + }, + { + name: "param then wildcard regexp", + path: "{a}.+{b:b}/", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", ""), + staticToken(".", true), + wildcardToken("b", "b"), + staticToken("/", false), + )), + }, + { + name: "param regexp then wildcard regexp", + path: "{a:a}.+{b:b}/", + wantTokens: slices.Collect(iterutil.SeqOf( + paramToken("a", "a"), + staticToken(".", true), + wildcardToken("b", "b"), + staticToken("/", false), + )), + }, + { + name: "catch all empty as suffix", + path: "/foo/*{any}", + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + wildcardToken("any", ""), + )), + wantOptionalCatchAll: true, + }, + { + name: "consecutive infix wildcard at start with regexp not allowed", + path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive wildcard with catch all empty not allowed", + path: "/+{foo}/*{baz}", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", + path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname consecutive infix wildcard at start with regexp not allowed", + path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive infix wildcard at start with and without regexp not allowed", + path: "/+{foo:[A-z]+}/+{baz}", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname consecutive infix wildcard at start with and without regexp not allowed", + path: "+{foo:[A-z]+}.+{baz}/", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive infix wildcard at start with regexp not allowed", + path: "/+{foo}/+{baz:[0-9]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname consecutive infix wildcard at start with regexp not allowed", + path: "+{foo}.+{baz:[0-9]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive infix wildcard with regexp not allowed", + path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname consecutive infix wildcard with regexp not allowed", + path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive infix wildcard with first regexp not allowed", + path: "/foo/+{bar:[A-z]+}/+{baz}", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname consecutive infix wildcard with first regexp not allowed", + path: "foo.+{bar:[A-z]+}.+{baz}/", + wantErr: ErrInvalidRoute, + }, + { + name: "consecutive infix wildcard with second regexp not allowed", + path: "/foo/+{bar}/+{baz:[A-z]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "hostname consecutive infix wildcard with second regexp not allowed", + path: "foo.+{bar}.+{baz:[A-z]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "non slash char after regexp param not allowed", + path: "/foo/{bar:[A-z]+}a/", + wantErr: ErrInvalidRoute, + }, + { + name: "non slash char after regexp wildcard not allowed", + path: "/foo/+{bar:[A-z]+}a/", + wantErr: ErrInvalidRoute, + }, + { + name: "regexp wildcard not allowed in hostname", + path: "+{a.{b:[A-z]+}}.c/", + wantErr: ErrInvalidRoute, + }, + { + name: "regexp wildcard not allowed in hostname", + path: "+{a.b.{c:[A-z]+}/", + wantErr: ErrInvalidRoute, + }, + { + name: "missing param name with regexp", + path: "/foo/{:[A-z]+}", + wantErr: ErrInvalidRoute, + }, + { + name: "missing wildcard name with regexp", + path: "/foo/+{:[A-z]+}", + wantErr: ErrInvalidRoute, + }, + { + name: "missing regular expression", + path: "/foo/{a:}", + wantErr: ErrInvalidRoute, + }, + { + name: "missing regular expression with only ':'", + path: "/foo/{:}", + wantErr: ErrInvalidRoute, + }, + { + name: "unsupported regexp in optional wildcard", + path: "/foo/*{any:[A-z]+}", + wantErr: ErrInvalidRoute, + }, + { + name: "unbalanced braces in param regexp", + path: "/foo/{bar:[A-z]+", + wantErr: ErrInvalidRoute, + }, + { + name: "unbalanced braces in wildcard regexp", + path: "/foo/+{bar:[A-z]+", + wantErr: ErrInvalidRoute, + }, + { + name: "balanced braces in param regexp with invalid char after", + path: "/foo/{bar:{}}a", + wantErr: ErrInvalidRoute, + }, + { + name: "balanced braces in wildcard regexp with invalid brace after", + path: "/foo/{bar:{}}}", + wantErr: ErrInvalidRoute, + }, + { + name: "unbalanced braces in regexp complex", + path: "/foo/{bar:{{{{}}}}", + wantErr: ErrInvalidRoute, + }, + { + name: "invalid regular expression", + path: "/foo/{bar:a{5,2}}", + wantErr: ErrInvalidRoute, + }, + { + name: "invalid regular expression", + path: "/foo/{bar:\\k}", + wantErr: ErrInvalidRoute, + }, + { + name: "capture group in regexp are not allowed", + path: "/foo/{bar:(foo|bar)}", + wantErr: ErrInvalidRoute, + }, + { + name: "no opening brace after * wildcard", + path: "/foo/*:bar}", + wantErr: ErrInvalidRoute, + }, + { + name: "no infix catch all empty", + path: "/foo/*{any}/bar", + wantErr: ErrInvalidRoute, + }, + { + name: "no infix inflight catch all empty", + path: "/foo/uuid_*{any}/bar", + wantErr: ErrInvalidRoute, + }, + { + name: "no suffix catch all empty in hostname", + path: "a.b.*{any}/", + wantErr: ErrInvalidRoute, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + parsed, _, err := f.parsePattern(tc.path) + require.ErrorIs(t, err, tc.wantErr) + if err != nil { + return + } + wantStr := tc.wantStr + if wantStr == "" { + wantStr = tc.path + } + assert.Equal(t, wantStr, parsed.str) + assert.Equal(t, tc.wantTokens, parsed.tokens) + assert.Equal(t, tc.wantOptionalCatchAll, parsed.optionalCatchAll) + assert.Equal(t, strings.IndexByte(tc.path, '/'), parsed.endHost) + }) + } +} + +func TestParsePatternParamsConstraint(t *testing.T) { + t.Run("param limit", func(t *testing.T) { + f, _ := NewRouter(WithMaxRouteParams(3)) + _, _, err := f.parsePattern("/{1}/{2}/{3}") + assert.NoError(t, err) + _, _, err = f.parsePattern("/{1}/{2}/{3}/{4}") + assert.Error(t, err) + _, _, err = f.parsePattern("/ab{1}/{2}/cd/{3}/{4}/ef") + assert.Error(t, err) + }) + t.Run("param key limit", func(t *testing.T) { + f, _ := NewRouter(WithMaxRouteParamKeyBytes(3)) + _, _, err := f.parsePattern("/{abc}/{abc}/{abc}") + assert.NoError(t, err) + _, _, err = f.parsePattern("/{abcd}/{abc}/{abc}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/{abcd}/{abc}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/{abc}/{abcd}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/+{abcd}/{abc}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc}/{abc}/+{abcdef}") + assert.Error(t, err) + }) + t.Run("param key limit with regexp", func(t *testing.T) { + f, _ := NewRouter(WithMaxRouteParamKeyBytes(3), AllowRegexpParam(true)) + _, _, err := f.parsePattern("/{abc:a}/{abc:a}/{abc:a}") + assert.NoError(t, err) + _, _, err = f.parsePattern("/{abcd:a}/{abc:a}/{abc:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/{abcd:a}/{abc:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/{abc:a}/{abcd:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/+{abcd:a}/{abc:a}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{abc:a}/{abc:a}/+{abcdef:a}") + assert.Error(t, err) + }) + t.Run("disabled regexp support for param", func(t *testing.T) { + f, _ := NewRouter() + _, _, err := f.parsePattern("/{a}/{b}/{c}") + assert.NoError(t, err) + // path params + _, _, err = f.parsePattern("/{a:a}/{b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/{b:b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/{b}/{c:c}") + assert.Error(t, err) + // hostname params + _, _, err = f.parsePattern("{a:a}.{b}.{c}/") + assert.Error(t, err) + _, _, err = f.parsePattern("{a}.{b:b}.{c}/") + assert.Error(t, err) + _, _, err = f.parsePattern("{a}.{b}.{c:c}/") + assert.Error(t, err) + }) + t.Run("disabled regexp support for wildcard", func(t *testing.T) { + f, _ := NewRouter() + _, _, err := f.parsePattern("/{a}/{b}/{c}") + assert.NoError(t, err) + // wildcard + _, _, err = f.parsePattern("/+{a:a}/{b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/+{b:b}/{c}") + assert.Error(t, err) + _, _, err = f.parsePattern("/{a}/{b}/+{c:c}") + assert.Error(t, err) + }) +} From b6450c8c56b8e1aaf888fb43e302d709490156e0 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:46:24 +0100 Subject: [PATCH 02/11] feat(parser): add structured PatternError with position tracking for diagnostics --- parser.go | 233 ++++++++++++++++--------- parser_test.go | 455 +++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 526 insertions(+), 162 deletions(-) diff --git a/parser.go b/parser.go index 3307986d..260558f1 100644 --- a/parser.go +++ b/parser.go @@ -6,6 +6,55 @@ import ( "strings" ) +// PatternError is a structured error for invalid route patterns. It carries the reason, +// the offending position, and the pattern itself, enabling programmatic diagnostics. +type PatternError struct { + Pattern string // canonical form of the route pattern + Type string // hostname | path + Reason string // syntax | parameter | regexp | constraint + Hint string // hint + Start int // start offset of the offending segment + End int // end offset of the offending segment +} + +// Error returns a human-readable error message with a visual pointer to the offending segment. +func (e *PatternError) Error() string { + var sb strings.Builder + sb.WriteString("pattern: ") + if e.Type != "" { + sb.WriteString(e.Type) + sb.WriteString(": ") + } + sb.WriteString(e.Reason) + sb.WriteString(": ") + sb.WriteString(e.Hint) + sb.WriteByte('\n') + sb.WriteString(" ") + sb.WriteString(e.Pattern) + sb.WriteByte('\n') + sb.WriteString(" ") + for i := 0; i < e.Start; i++ { + sb.WriteByte(' ') + } + n := e.End - e.Start + if n <= 0 { + n = 1 + } + for i := 0; i < n; i++ { + sb.WriteByte('^') + } + return sb.String() +} + +func newPatternError(reason string, start, end int, msg string) *PatternError { + return &PatternError{ + Reason: reason, + Start: start, + End: end, + Hint: msg, + } +} + type pattern struct { // Canonical cleaned pattern: hostname + CleanPath(path). str string @@ -19,28 +68,42 @@ type pattern struct { func (fox *Router) parsePattern(raw string) (*pattern, int, error) { endHost := strings.IndexByte(raw, '/') if endHost == -1 { - return nil, 0, fmt.Errorf("%w: missing trailing '/' after hostname", ErrInvalidRoute) + return nil, 0, &PatternError{ + Pattern: raw, + Reason: "syntax", + Start: 0, + End: len(raw), + Hint: "missing trailing '/'", + } } + // Build canonical pattern early so sub-parser errors can reference it. + cleanedPath := CleanPath(raw[endHost:]) + canonicalPattern := raw[:endHost] + cleanedPath + var ( paramCount int hostTokens []token ) if endHost > 0 { - var err error - hostTokens, paramCount, err = fox.parseHostname(raw[:endHost]) - if err != nil { - return nil, 0, err + var pe *PatternError + hostTokens, paramCount, pe = fox.parseHostname(raw[:endHost]) + if pe != nil { + pe.Pattern = canonicalPattern + pe.Type = "hostname" + // hostname offset is 0, no adjustment needed. + return nil, 0, pe } } - // Clean the path to normalize traversal patterns (e.g. /foo/../bar -> /bar). - cleanedPath := CleanPath(raw[endHost:]) - - pathTokens, optCatchAll, paramCount, err := fox.parsePath(cleanedPath, paramCount) - if err != nil { - return nil, 0, err + pathTokens, optCatchAll, paramCount, pe := fox.parsePath(cleanedPath, paramCount) + if pe != nil { + pe.Pattern = canonicalPattern + pe.Type = "path" + pe.Start += endHost + pe.End += endHost + return nil, 0, pe } tokens := make([]token, 0, len(hostTokens)+len(pathTokens)) @@ -48,7 +111,7 @@ func (fox *Router) parsePattern(raw string) (*pattern, int, error) { tokens = append(tokens, pathTokens...) return &pattern{ - str: raw[:endHost] + cleanedPath, + str: canonicalPattern, tokens: tokens, endHost: endHost, optionalCatchAll: optCatchAll, @@ -66,7 +129,8 @@ type hostnameValidator struct { // checkByte validates a single static hostname character against RFC 5890 rules // (dot/dash adjacency, label length, uppercase, illegal characters) and updates tracking state. -func (v *hostnameValidator) checkByte(c byte) error { +// pos is the byte offset of c in the hostname string. +func (v *hostnameValidator) checkByte(c byte, pos int) *PatternError { switch { case 'a' <= c && c <= 'z' || c == '_': v.nonNumeric = true @@ -75,26 +139,26 @@ func (v *hostnameValidator) checkByte(c byte) error { v.partLen++ case c == '-': if v.last == '.' { - return fmt.Errorf("%w: illegal '-' after '.' in hostname label", ErrInvalidRoute) + return newPatternError("syntax", pos, pos+1, "illegal character after '.'") } v.partLen++ v.nonNumeric = true case c == '.': if v.last == '.' { - return fmt.Errorf("%w: unexpected consecutive '.' in hostname", ErrInvalidRoute) + return newPatternError("syntax", pos, pos+1, "illegal consecutive '.'") } if v.last == '-' { - return fmt.Errorf("%w: illegal '-' before '.' in hostname label", ErrInvalidRoute) + return newPatternError("syntax", pos-1, pos, "label ends with '-'") } if v.partLen > 63 { - return fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) + return newPatternError("constraint", pos-v.partLen, pos, "label exceeds 63 characters") } v.totalLen += v.partLen + 1 // +1 counts the current dot. v.partLen = 0 case 'A' <= c && c <= 'Z': - return fmt.Errorf("%w: illegal uppercase character '%s' in hostname label", ErrInvalidRoute, string(c)) + return newPatternError("syntax", pos, pos+1, "uppercase character in label") default: - return fmt.Errorf("%w: illegal character '%s' in hostname label", ErrInvalidRoute, string(c)) + return newPatternError("syntax", pos, pos+1, "illegal character in label") } v.last = c return nil @@ -107,29 +171,31 @@ func (v *hostnameValidator) skipParam() { } // postCheck runs final hostname validation: trailing dash/dot, all-numeric, label and total length. -func (v *hostnameValidator) postCheck() error { +// hostnameLen is len(hostname), used to compute error positions. +func (v *hostnameValidator) postCheck(hostnameLen int) *PatternError { v.totalLen += v.partLen if v.last == '-' { - return fmt.Errorf("%w: illegal trailing '-' in hostname label", ErrInvalidRoute) + return newPatternError("syntax", hostnameLen-1, hostnameLen, "illegal trailing '-'") } if v.last == '.' { - return fmt.Errorf("%w: illegal trailing '.' in hostname label", ErrInvalidRoute) + return newPatternError("syntax", hostnameLen-1, hostnameLen, "illegal trailing '.'") } if !v.nonNumeric { - return fmt.Errorf("%w: invalid all numeric hostname", ErrInvalidRoute) + return newPatternError("syntax", 0, hostnameLen, "all numeric") } if v.partLen > 63 { - return fmt.Errorf("%w: hostname label exceed 63 characters", ErrInvalidRoute) + return newPatternError("constraint", hostnameLen-v.partLen, hostnameLen, "label exceeds 63 characters") } if v.totalLen > 253 { - return fmt.Errorf("%w: hostname exceed 253 characters", ErrInvalidRoute) + return newPatternError("constraint", 0, hostnameLen, "exceeds 253 characters") } return nil } // parseHostname validates and tokenizes the hostname portion of a route pattern. // It enforces RFC 5890 rules for labels and returns the number of parameters found. -func (fox *Router) parseHostname(hostname string) ([]token, int, error) { +// Positions in the returned PatternError are relative to hostname. +func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) { var sb strings.Builder sb.Grow(len(hostname)) tokens := make([]token, 0, 1) // At least one token. @@ -152,22 +218,25 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, error) { sb.Reset() } isWild := c == '+' + paramStart := i if isWild { i++ if i >= len(hostname) || hostname[i] != '{' { - return nil, 0, fmt.Errorf("%w: missing '{param}' after '+' catch-all delimiter", ErrInvalidRoute) + return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") } if prevWild && staticSinceWild <= 1 { - return nil, 0, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) + return nil, 0, newPatternError("syntax", i-1, i, "consecutive wildcard") } } - name, re, n, err := fox.parseBrace(hostname[i:], dotDelim, false) - if err != nil { - return nil, 0, err + name, re, n, pe := fox.parseBrace(hostname[i:], dotDelim, false) + if pe != nil { + pe.Start += i + pe.End += i + return nil, 0, pe } paramCount++ if paramCount > fox.maxParams { - return nil, 0, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrTooManyParams) + return nil, 0, newPatternError("constraint", paramStart, i+n, "too many parameters") } kind := nodeParam @@ -183,20 +252,20 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, error) { validator.skipParam() // After closing brace, next char must be '.' (hostname delimiter) or end. if i < len(hostname) && hostname[i] != '.' { - return nil, 0, fmt.Errorf("%w: illegal character '%s' after '{param}'", ErrInvalidRoute, string(hostname[i])) + return nil, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") } case '*': // Optional wildcard *{param} is suffix-only; hostname always has a path after it. i++ if i < len(hostname) && hostname[i] == '{' { - return nil, 0, fmt.Errorf("%w: '*{param}' allowed only as suffix", ErrInvalidRoute) + return nil, 0, newPatternError("syntax", i-1, i+1, "optional wildcard allowed only as suffix") } - return nil, 0, fmt.Errorf("%w: missing '{param}' after '*' catch-all delimiter", ErrInvalidRoute) + return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") default: - if err := validator.checkByte(c); err != nil { - return nil, 0, err + if pe := validator.checkByte(c, i); pe != nil { + return nil, 0, pe } sb.WriteByte(c) staticSinceWild++ @@ -204,8 +273,8 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, error) { } } - if err := validator.postCheck(); err != nil { - return nil, 0, err + if pe := validator.postCheck(len(hostname)); pe != nil { + return nil, 0, pe } if sb.Len() > 0 { @@ -220,7 +289,8 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, error) { // The path must already be cleaned via CleanPath. paramCount is the number of parameters // already parsed (e.g. from the hostname). Returns tokens, whether the path ends with an // optional catch-all *{param}, and the updated total parameter count. -func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, error) { +// Positions in the returned PatternError are relative to path. +func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, *PatternError) { var sb strings.Builder sb.Grow(len(path)) tokens := make([]token, 0, 1) // At least one token. @@ -242,24 +312,25 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, e } isOpt := c == '*' isWild := c == '+' || isOpt + paramStart := i if isWild { i++ if i >= len(path) || path[i] != '{' { - return nil, false, 0, fmt.Errorf( - "%w: missing '{param}' after '%c' catch-all delimiter", ErrInvalidRoute, c, - ) + return nil, false, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") } if prevWild && staticSinceWild <= 1 { - return nil, false, 0, fmt.Errorf("%w: consecutive wildcard not allowed", ErrInvalidRoute) + return nil, false, 0, newPatternError("syntax", i-1, i, "consecutive wildcard") } } - name, re, n, err := fox.parseBrace(path[i:], slashDelim, isOpt) - if err != nil { - return nil, false, 0, err + name, re, n, pe := fox.parseBrace(path[i:], slashDelim, isOpt) + if pe != nil { + pe.Start += i + pe.End += i + return nil, false, 0, pe } paramCount++ if paramCount > fox.maxParams { - return nil, false, 0, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrTooManyParams) + return nil, false, 0, newPatternError("constraint", paramStart, i+n, "too many parameters") } kind := nodeParam @@ -275,21 +346,19 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, e // Optional wildcard *{param} is only allowed as suffix (last thing in path). if isOpt { if i < len(path) { - return nil, false, 0, fmt.Errorf("%w: '*{param}' allowed only as suffix", ErrInvalidRoute) + return nil, false, 0, newPatternError("syntax", paramStart, i, "optional wildcard allowed only as suffix") } optCatchAll = true } // After closing brace, next char must be '/' or end of path. if i < len(path) && path[i] != '/' { - return nil, false, 0, fmt.Errorf( - "%w: illegal character '%s' after '{param}'", ErrInvalidRoute, string(path[i]), - ) + return nil, false, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") } default: // Reject ASCII control characters. if c < ' ' || c == 0x7f { - return nil, false, 0, fmt.Errorf("%w: illegal control character in path", ErrInvalidRoute) + return nil, false, 0, newPatternError("syntax", i, i+1, "illegal control character") } sb.WriteByte(c) staticSinceWild++ @@ -308,11 +377,12 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, e // compiled regexp (nil if none), and total bytes consumed (including '{' and '}'). // delim is the segment delimiter ('/' for path, '.' for hostname). // isOptional indicates *{} (optional catch-all, which disallows regexp constraints). -func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *regexp.Regexp, int, error) { +// Positions in the returned PatternError are relative to s. +func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *regexp.Regexp, int, *PatternError) { // Skip s[0] (the opening '{') and start at nesting level 1 to account for it. idx := braceIndex(s[1:], 1) if idx == -1 { - return "", nil, 0, fmt.Errorf("%w: unbalanced braces in parameter definition", ErrInvalidRoute) + return "", nil, 0, newPatternError("syntax", 0, len(s), "unbalanced braces") } content := s[1 : 1+idx] // Everything between { and }. @@ -322,7 +392,9 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r name := content var rawRegex string hasRegex := false - if colonIdx := strings.IndexByte(content, ':'); colonIdx >= 0 { + colonIdx := -1 + if ci := strings.IndexByte(content, ':'); ci >= 0 { + colonIdx = ci name = content[:colonIdx] rawRegex = content[colonIdx+1:] hasRegex = true @@ -330,14 +402,11 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r // Validate name length before possibly expensive regexp compilation. if len(name) > fox.maxParamKeyBytes { - return "", nil, 0, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrParamKeyTooLarge) + return "", nil, 0, newPatternError("constraint", 1, 1+len(name), "key too large") } if len(name) == 0 { - if hasRegex { - return "", nil, 0, fmt.Errorf("%w: missing parameter name", ErrInvalidRoute) - } - return "", nil, 0, fmt.Errorf("%w: missing parameter name between '{}'", ErrInvalidRoute) + return "", nil, 0, newPatternError("parameter", 0, consumed, "missing name") } // Validate name characters: no delimiters, no special chars. @@ -345,9 +414,7 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r switch name[j] { // TODO: just put . and /, add also } case delim, '/', '*', '+', '{': - return "", nil, 0, fmt.Errorf( - "%w: illegal character '%s' in '{param}'", ErrInvalidRoute, string(name[j]), - ) + return "", nil, 0, newPatternError("parameter", 1+j, 1+j+1, "illegal character in name") } } @@ -358,33 +425,36 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r // Optional wildcards (*{param}) cannot have regexps because they match empty strings, // making it impossible to disambiguate routes with different regexps. if isOptional { - return "", nil, 0, fmt.Errorf("%w: %w in optional wildcard", ErrInvalidRoute, ErrRegexpNotAllowed) + return "", nil, 0, newPatternError("regexp", 0, consumed, "not allowed in optional wildcard") } - re, err := fox.compileParamRegexp(rawRegex) - if err != nil { - return "", nil, 0, err + re, pe := fox.compileParamRegexp(rawRegex) + if pe != nil { + // Adjust positions: rawRegex starts at 1 ('{') + colonIdx + 1 (':') within s. + regexOffset := 1 + colonIdx + 1 + pe.Start += regexOffset + pe.End += regexOffset + return "", nil, 0, pe } return name, re, consumed, nil } // compileParamRegexp validates and compiles a regular expression constraint for a parameter. -func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, error) { +// Positions in the returned PatternError are relative to rawRegex. +func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *PatternError) { if !fox.allowRegexp { - return nil, fmt.Errorf("%w: %w", ErrInvalidRoute, ErrRegexpNotAllowed) + return nil, newPatternError("regexp", 0, len(rawRegex), "not enabled") } if rawRegex == "" { - return nil, fmt.Errorf("%w: missing regular expression", ErrInvalidRoute) + return nil, newPatternError("regexp", 0, 0, "missing expression") } re, err := regexp.Compile("^" + rawRegex + "$") if err != nil { - return nil, fmt.Errorf("%w: %w", ErrInvalidRoute, err) + return nil, newPatternError("regexp", 0, len(rawRegex), fmt.Sprintf("compile error: %s", err)) } if re.NumSubexp() > 0 { - return nil, fmt.Errorf( - "%w: illegal capture group '%s': use (?:pattern) instead", ErrInvalidRoute, rawRegex, - ) + return nil, newPatternError("regexp", 0, len(rawRegex), "capture group, use (?:...) instead") } return re, nil @@ -433,7 +503,18 @@ func (fox *Router) parseRoute(url string) (parsedRoute, error) { // Once callers migrate to parsePattern (which returns the cleaned canonical form), // this check can be removed. if p.str != url { - return parsedRoute{}, fmt.Errorf("%w: path is not clean, use CleanPath", ErrInvalidRoute) + endHost := strings.IndexByte(url, '/') + if endHost == -1 { + endHost = 0 + } + return parsedRoute{}, &PatternError{ + Pattern: url, + Type: "path", + Reason: "syntax", + Start: endHost, + End: len(url), + Hint: "not clean, use CleanPath", + } } paramCnt := 0 diff --git a/parser_test.go b/parser_test.go index 2510122c..31654815 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,8 @@ package fox import ( + "errors" + "fmt" "regexp" "slices" "strings" @@ -45,11 +47,11 @@ func TestParsePattern(t *testing.T) { } cases := []struct { - wantErr error name string path string wantStr string // Expected parsed.str (defaults to path if empty). wantTokens []token + wantErr bool wantOptionalCatchAll bool }{ { @@ -219,127 +221,127 @@ func TestParsePattern(t *testing.T) { { name: "missing a least one slash", path: "foo.com", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "empty parameter", path: "/foo/bar{}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "missing arguments name after catch all", path: "/foo/bar/*", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "missing arguments name after param", path: "/foo/bar/{", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "catch all in the middle of the route", path: "/foo/bar/*/baz", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "empty infix catch all", path: "/foo/bar/+{}/baz", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "empty ending catch all", path: "/foo/bar/baz/+{}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in param", path: "/foo/{{bar}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in param", path: "/foo/{*bar}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in catch-all", path: "/foo/+{/bar}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "catch all not supported in hostname", path: "a.b.c*/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal character in params hostname", path: "a.b.c{/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal character in hostname label", path: "a.b.c}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in param hostname", path: "a.{.bar}.c/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in wildcard hostname", path: "a.+{.bar}.c/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in param hostname", path: "a.{/bar}.c/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected character in wildcard hostname", path: "a.+{/bar}.c/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "in flight catch-all after param in one route segment", path: "/foo/{bar}+{baz}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "multiple param in one route segment", path: "/foo/{bar}{baz}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "in flight param after catch all", path: "/foo/+{args}{param}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive catch all with no slash", path: "/foo/+{args}+{param}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive catch all", path: "/foo/+{args}/+{param}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive catch all with inflight", path: "/foo/ab+{args}/+{param}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected char after inflight catch all", path: "/foo/ab+{args}a", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unexpected char after catch all", path: "/foo/+{args}a", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "prefix catch-all in hostname", @@ -381,97 +383,97 @@ func TestParsePattern(t *testing.T) { { name: "illegal control character in path", path: "example.com/foo\x00", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal leading hyphen in hostname", path: "-a.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal leading dot in hostname", path: ".a.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal trailing hyphen in hostname", path: "a.com-/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal trailing dot in hostname", path: "a.com./", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal trailing dot in hostname after param", path: "{tld}./foo/bar", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal single dot in hostname", path: "./", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal hyphen before dot", path: "a-.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal hyphen after dot", path: "a.-com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal double dot", path: "a..com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal double dot with param state", path: "{b}..com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal double dot with inflight param state", path: "a{b}..com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "param not finishing with delimiter in hostname", path: "{a}b{b}.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive parameter in hostname", path: "{a}{b}.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "leading hostname label exceed 63 characters", path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "middle hostname label exceed 63 characters", path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "trailing hostname label exceed 63 characters", path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "illegal character in domain", path: "a.b!.com/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "invalid all-numeric label", path: "123/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "all-numeric label with param", @@ -496,22 +498,22 @@ func TestParsePattern(t *testing.T) { { name: "all-numeric label with path wildcard", path: "123.456/{abc}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname exceed 255 character", path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "invalid all-numeric label", path: "11.22.33/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "invalid uppercase label", path: "ABC/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "2 regular params in domain", @@ -922,186 +924,195 @@ func TestParsePattern(t *testing.T) { { name: "consecutive infix wildcard at start with regexp not allowed", path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive wildcard with catch all empty not allowed", path: "/+{foo}/*{baz}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname consecutive infix wildcard at start with regexp not allowed", path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive infix wildcard at start with and without regexp not allowed", path: "/+{foo:[A-z]+}/+{baz}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname consecutive infix wildcard at start with and without regexp not allowed", path: "+{foo:[A-z]+}.+{baz}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive infix wildcard at start with regexp not allowed", path: "/+{foo}/+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname consecutive infix wildcard at start with regexp not allowed", path: "+{foo}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive infix wildcard with regexp not allowed", path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname consecutive infix wildcard with regexp not allowed", path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive infix wildcard with first regexp not allowed", path: "/foo/+{bar:[A-z]+}/+{baz}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname consecutive infix wildcard with first regexp not allowed", path: "foo.+{bar:[A-z]+}.+{baz}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "consecutive infix wildcard with second regexp not allowed", path: "/foo/+{bar}/+{baz:[A-z]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "hostname consecutive infix wildcard with second regexp not allowed", path: "foo.+{bar}.+{baz:[A-z]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "non slash char after regexp param not allowed", path: "/foo/{bar:[A-z]+}a/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "non slash char after regexp wildcard not allowed", path: "/foo/+{bar:[A-z]+}a/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "regexp wildcard not allowed in hostname", path: "+{a.{b:[A-z]+}}.c/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "regexp wildcard not allowed in hostname", path: "+{a.b.{c:[A-z]+}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "missing param name with regexp", path: "/foo/{:[A-z]+}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "missing wildcard name with regexp", path: "/foo/+{:[A-z]+}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "missing regular expression", path: "/foo/{a:}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "missing regular expression with only ':'", path: "/foo/{:}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unsupported regexp in optional wildcard", path: "/foo/*{any:[A-z]+}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unbalanced braces in param regexp", path: "/foo/{bar:[A-z]+", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unbalanced braces in wildcard regexp", path: "/foo/+{bar:[A-z]+", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "balanced braces in param regexp with invalid char after", path: "/foo/{bar:{}}a", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "balanced braces in wildcard regexp with invalid brace after", path: "/foo/{bar:{}}}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "unbalanced braces in regexp complex", path: "/foo/{bar:{{{{}}}}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "invalid regular expression", path: "/foo/{bar:a{5,2}}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "invalid regular expression", path: "/foo/{bar:\\k}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "capture group in regexp are not allowed", path: "/foo/{bar:(foo|bar)}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "no opening brace after * wildcard", path: "/foo/*:bar}", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "no infix catch all empty", path: "/foo/*{any}/bar", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "no infix inflight catch all empty", path: "/foo/uuid_*{any}/bar", - wantErr: ErrInvalidRoute, + wantErr: true, }, { name: "no suffix catch all empty in hostname", path: "a.b.*{any}/", - wantErr: ErrInvalidRoute, + wantErr: true, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { parsed, _, err := f.parsePattern(tc.path) - require.ErrorIs(t, err, tc.wantErr) - if err != nil { + if tc.wantErr { + require.Error(t, err) + var pe *PatternError + require.True(t, errors.As(err, &pe), "expected *PatternError, got %T", err) + assert.True(t, errors.Is(err, ErrInvalidRoute), "PatternError should unwrap to ErrInvalidRoute") + assert.NotEmpty(t, pe.Pattern, "Pattern must be set") + assert.NotEmpty(t, pe.Reason, "Reason must be set") + assert.True(t, pe.Start >= 0, "Start must be >= 0") + assert.True(t, pe.End >= pe.Start, "End must be >= Start") + assert.True(t, pe.End <= len(pe.Pattern), "End must be <= len(Pattern)") return } + require.NoError(t, err) wantStr := tc.wantStr if wantStr == "" { wantStr = tc.path @@ -1186,3 +1197,275 @@ func TestParsePatternParamsConstraint(t *testing.T) { assert.Error(t, err) }) } + +func TestPatternErrorPosition(t *testing.T) { + cases := []struct { + name string + path string + wantType string + wantReason string + wantStart int + wantEnd int + wantMsg string + }{ + { + name: "uppercase character in hostname", + path: "example.Com/path", + wantType: "hostname", + wantReason: "syntax", + wantStart: 8, + wantEnd: 9, + wantMsg: "uppercase character in label", + }, + { + name: "all numeric hostname", + path: "1234567/path", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 7, + wantMsg: "all numeric", + }, + { + name: "missing trailing slash", + path: "foo.com", + wantReason: "syntax", + wantStart: 0, + wantEnd: 7, + wantMsg: "missing trailing '/'", + }, + { + name: "empty parameter in path", + path: "/foo/bar{}", + wantType: "path", + wantReason: "parameter", + wantStart: 8, + wantEnd: 10, + wantMsg: "missing name", + }, + { + name: "illegal char in param name", + path: "/foo/{*bar}", + wantType: "path", + wantReason: "parameter", + wantStart: 6, + wantEnd: 7, + wantMsg: "illegal character in name", + }, + { + name: "unbalanced braces in path", + path: "/foo/{bar:[A-z]+", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 16, + wantMsg: "unbalanced braces", + }, + { + name: "missing param after + in path", + path: "/foo/bar/+baz", + wantType: "path", + wantReason: "syntax", + wantStart: 9, + wantEnd: 10, + wantMsg: "missing parameter after delimiter", + }, + { + name: "consecutive wildcard in path", + path: "/foo/+{args}/+{param}", + wantType: "path", + wantReason: "syntax", + wantStart: 13, + wantEnd: 14, + wantMsg: "consecutive wildcard", + }, + { + name: "illegal character after param in path", + path: "/foo/{bar}+{baz}", + wantType: "path", + wantReason: "syntax", + wantStart: 10, + wantEnd: 11, + wantMsg: "character after parameter", + }, + { + name: "illegal control character in path", + path: "example.com/foo\x00", + wantType: "path", + wantReason: "syntax", + wantStart: 15, + wantEnd: 16, + wantMsg: "control character", + }, + { + name: "capture group in regexp", + path: "/foo/{bar:(foo|bar)}", + wantType: "path", + wantReason: "regexp", + wantStart: 10, + wantEnd: 19, + wantMsg: "capture group, use (?:...) instead", + }, + { + name: "missing regular expression", + path: "/foo/{a:}", + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 8, + wantMsg: "missing expression", + }, + { + name: "regexp not allowed in optional wildcard", + path: "/foo/*{any:[A-z]+}", + wantType: "path", + wantReason: "regexp", + wantStart: 6, + wantEnd: 18, + wantMsg: "not allowed in optional wildcard", + }, + { + name: "optional wildcard only as suffix", + path: "/foo/*{any}/bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 11, + wantMsg: "optional wildcard allowed only as suffix", + }, + { + name: "trailing dot in hostname", + path: "a.com./", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "trailing '.'", + }, + { + name: "trailing hyphen in hostname", + path: "a.com-/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "illegal trailing '-'", + }, + { + name: "hostname label exceed 63 characters", + path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 4, + wantEnd: 68, + wantMsg: "label exceeds 63 characters", + }, + { + name: "too many params", + path: "/{1}/{2}/{3}/{4}", + wantType: "path", + wantReason: "constraint", + wantStart: 13, + wantEnd: 16, + wantMsg: "too many parameters", + }, + { + name: "param key too large", + path: "/{abcd}", + wantType: "path", + wantReason: "constraint", + wantStart: 2, + wantEnd: 6, + wantMsg: "key too large", + }, + { + name: "hostname illegal character after param", + path: "{a}b{b}.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 3, + wantEnd: 4, + wantMsg: "character after parameter", + }, + { + name: "hostname consecutive dot", + path: "a..com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 2, + wantEnd: 3, + wantMsg: "consecutive '.'", + }, + { + name: "regexp not allowed with disabled regexp", + path: "/{a:a}", + wantType: "path", + wantReason: "regexp", + wantStart: 4, + wantEnd: 5, + wantMsg: "not enabled", + }, + { + name: "hostname missing param after + delimiter", + path: "+baz.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "missing parameter after delimiter", + }, + { + name: "hostname optional wildcard only as suffix", + path: "a.b.*{any}/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 4, + wantEnd: 6, + wantMsg: "optional wildcard allowed only as suffix", + }, + { + name: "missing param name with regexp", + path: "/foo/{:[A-z]+}", + wantType: "path", + wantReason: "parameter", + wantStart: 5, + wantEnd: 14, + wantMsg: "missing name", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var f *Router + if tc.name == "regexp not allowed with disabled regexp" { + f = MustRouter() + } else if tc.name == "too many params" { + var err error + f, err = NewRouter(WithMaxRouteParams(3), AllowRegexpParam(true)) + require.NoError(t, err) + } else if tc.name == "param key too large" { + var err error + f, err = NewRouter(WithMaxRouteParamKeyBytes(3), AllowRegexpParam(true)) + require.NoError(t, err) + } else { + f = MustRouter(AllowRegexpParam(true)) + } + + _, _, err := f.parsePattern(tc.path) + require.Error(t, err) + var pe *PatternError + require.True(t, errors.As(err, &pe), "expected *PatternError, got %T: %v", err, err) + assert.Equal(t, tc.wantType, pe.Type, "type mismatch") + assert.Equal(t, tc.wantReason, pe.Reason, "reason mismatch") + assert.Equal(t, tc.wantStart, pe.Start, "start mismatch") + assert.Equal(t, tc.wantEnd, pe.End, "end mismatch") + assert.Contains(t, pe.Error(), tc.wantMsg, "message mismatch") + fmt.Println(pe) + }) + } +} + +func TestX(t *testing.T) { + f := MustRouter() + f.MustAdd(MethodGet, "/foo/{asfsadf*}/baz", emptyHandler) +} From 5fcdd6e805e3bfff640f0f4aac4568760cab1190 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Tue, 17 Feb 2026 11:56:01 +0100 Subject: [PATCH 03/11] feat(parser): return param count from parsePattern and auto-append trailing slash for hostname-only pattern --- parser.go | 116 ++---- parser_test.go | 1055 +++++++++++++++++++----------------------------- 2 files changed, 462 insertions(+), 709 deletions(-) diff --git a/parser.go b/parser.go index 260558f1..eeb778eb 100644 --- a/parser.go +++ b/parser.go @@ -28,20 +28,22 @@ func (e *PatternError) Error() string { sb.WriteString(e.Reason) sb.WriteString(": ") sb.WriteString(e.Hint) - sb.WriteByte('\n') - sb.WriteString(" ") - sb.WriteString(e.Pattern) - sb.WriteByte('\n') - sb.WriteString(" ") - for i := 0; i < e.Start; i++ { - sb.WriteByte(' ') - } - n := e.End - e.Start - if n <= 0 { - n = 1 - } - for i := 0; i < n; i++ { - sb.WriteByte('^') + if e.Pattern != "" { + sb.WriteByte('\n') + sb.WriteString(" ") + sb.WriteString(e.Pattern) + sb.WriteByte('\n') + sb.WriteString(" ") + for i := 0; i < e.Start; i++ { + sb.WriteByte(' ') + } + n := e.End - e.Start + if n <= 0 { + n = 1 + } + for i := 0; i < n; i++ { + sb.WriteByte('^') + } } return sb.String() } @@ -56,28 +58,26 @@ func newPatternError(reason string, start, end int, msg string) *PatternError { } type pattern struct { - // Canonical cleaned pattern: hostname + CleanPath(path). - str string + str string // canonical cleaned pattern tokens []token optionalCatchAll bool endHost int } -// parsePattern parses and validates a route pattern by splitting it into hostname and path, -// cleaning the path, and delegating to focused sub-parsers. func (fox *Router) parsePattern(raw string) (*pattern, int, error) { endHost := strings.IndexByte(raw, '/') if endHost == -1 { - return nil, 0, &PatternError{ - Pattern: raw, - Reason: "syntax", - Start: 0, - End: len(raw), - Hint: "missing trailing '/'", + if len(raw) == 0 { + return nil, 0, &PatternError{ + Pattern: raw, + Reason: "syntax", + Hint: "empty pattern", + } } + raw += "/" + endHost = len(raw) - 1 } - // Build canonical pattern early so sub-parser errors can reference it. cleanedPath := CleanPath(raw[endHost:]) canonicalPattern := raw[:endHost] + cleanedPath @@ -92,7 +92,6 @@ func (fox *Router) parsePattern(raw string) (*pattern, int, error) { if pe != nil { pe.Pattern = canonicalPattern pe.Type = "hostname" - // hostname offset is 0, no adjustment needed. return nil, 0, pe } } @@ -118,8 +117,6 @@ func (fox *Router) parsePattern(raw string) (*pattern, int, error) { }, paramCount, nil } -// hostnameValidator tracks RFC 5890 hostname label state during parsing. -// It validates static characters one at a time, while the caller handles parameter tokenization. type hostnameValidator struct { partLen int // Current label length in bytes. totalLen int // Total hostname length in bytes. @@ -127,9 +124,6 @@ type hostnameValidator struct { nonNumeric bool // True once we've seen a letter, hyphen, or parameter. } -// checkByte validates a single static hostname character against RFC 5890 rules -// (dot/dash adjacency, label length, uppercase, illegal characters) and updates tracking state. -// pos is the byte offset of c in the hostname string. func (v *hostnameValidator) checkByte(c byte, pos int) *PatternError { switch { case 'a' <= c && c <= 'z' || c == '_': @@ -164,14 +158,11 @@ func (v *hostnameValidator) checkByte(c byte, pos int) *PatternError { return nil } -// skipParam resets adjacency state after a {param} and marks the hostname as non-numeric. func (v *hostnameValidator) skipParam() { v.last = 0 v.nonNumeric = true } -// postCheck runs final hostname validation: trailing dash/dot, all-numeric, label and total length. -// hostnameLen is len(hostname), used to compute error positions. func (v *hostnameValidator) postCheck(hostnameLen int) *PatternError { v.totalLen += v.partLen if v.last == '-' { @@ -192,14 +183,10 @@ func (v *hostnameValidator) postCheck(hostnameLen int) *PatternError { return nil } -// parseHostname validates and tokenizes the hostname portion of a route pattern. -// It enforces RFC 5890 rules for labels and returns the number of parameters found. -// Positions in the returned PatternError are relative to hostname. func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) { var sb strings.Builder sb.Grow(len(hostname)) - tokens := make([]token, 0, 1) // At least one token. - // Initialize last to dotDelim so that a leading '-' is caught by the "dash after dot" rule. + tokens := make([]token, 0, 5) validator := hostnameValidator{last: dotDelim} var ( paramCount int @@ -250,13 +237,11 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) tokens = append(tokens, token{typ: kind, value: name, regexp: re}) i += n validator.skipParam() - // After closing brace, next char must be '.' (hostname delimiter) or end. if i < len(hostname) && hostname[i] != '.' { return nil, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") } case '*': - // Optional wildcard *{param} is suffix-only; hostname always has a path after it. i++ if i < len(hostname) && hostname[i] == '{' { return nil, 0, newPatternError("syntax", i-1, i+1, "optional wildcard allowed only as suffix") @@ -279,21 +264,14 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) if sb.Len() > 0 { tokens = append(tokens, token{typ: nodeStatic, value: sb.String(), hsplit: true}) - sb.Reset() } - return tokens, paramCount, nil } -// parsePath validates and tokenizes the path portion of a route pattern. -// The path must already be cleaned via CleanPath. paramCount is the number of parameters -// already parsed (e.g. from the hostname). Returns tokens, whether the path ends with an -// optional catch-all *{param}, and the updated total parameter count. -// Positions in the returned PatternError are relative to path. func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, *PatternError) { var sb strings.Builder sb.Grow(len(path)) - tokens := make([]token, 0, 1) // At least one token. + tokens := make([]token, 0, 5) var ( prevWild bool staticSinceWild int @@ -343,20 +321,17 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, * } tokens = append(tokens, token{typ: kind, value: name, regexp: re}) i += n - // Optional wildcard *{param} is only allowed as suffix (last thing in path). if isOpt { if i < len(path) { return nil, false, 0, newPatternError("syntax", paramStart, i, "optional wildcard allowed only as suffix") } optCatchAll = true } - // After closing brace, next char must be '/' or end of path. if i < len(path) && path[i] != '/' { return nil, false, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") } default: - // Reject ASCII control characters. if c < ' ' || c == 0x7f { return nil, false, 0, newPatternError("syntax", i, i+1, "illegal control character") } @@ -368,16 +343,10 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, * if sb.Len() > 0 { tokens = append(tokens, token{typ: nodeStatic, value: sb.String()}) - sb.Reset() } return tokens, optCatchAll, paramCount, nil } -// parseBrace parses a parameter starting at '{' in s. It returns the parameter name, -// compiled regexp (nil if none), and total bytes consumed (including '{' and '}'). -// delim is the segment delimiter ('/' for path, '.' for hostname). -// isOptional indicates *{} (optional catch-all, which disallows regexp constraints). -// Positions in the returned PatternError are relative to s. func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *regexp.Regexp, int, *PatternError) { // Skip s[0] (the opening '{') and start at nesting level 1 to account for it. idx := braceIndex(s[1:], 1) @@ -388,7 +357,6 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r content := s[1 : 1+idx] // Everything between { and }. consumed := 1 + idx + 1 // { + content + } - // Split into name and optional regex on first ':'. name := content var rawRegex string hasRegex := false @@ -400,7 +368,6 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r hasRegex = true } - // Validate name length before possibly expensive regexp compilation. if len(name) > fox.maxParamKeyBytes { return "", nil, 0, newPatternError("constraint", 1, 1+len(name), "key too large") } @@ -409,7 +376,6 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r return "", nil, 0, newPatternError("parameter", 0, consumed, "missing name") } - // Validate name characters: no delimiters, no special chars. for j := 0; j < len(name); j++ { switch name[j] { // TODO: just put . and /, add also } @@ -422,15 +388,12 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r return name, nil, consumed, nil } - // Optional wildcards (*{param}) cannot have regexps because they match empty strings, - // making it impossible to disambiguate routes with different regexps. if isOptional { return "", nil, 0, newPatternError("regexp", 0, consumed, "not allowed in optional wildcard") } re, pe := fox.compileParamRegexp(rawRegex) if pe != nil { - // Adjust positions: rawRegex starts at 1 ('{') + colonIdx + 1 (':') within s. regexOffset := 1 + colonIdx + 1 pe.Start += regexOffset pe.End += regexOffset @@ -443,7 +406,7 @@ func (fox *Router) parseBrace(s string, delim byte, isOptional bool) (string, *r // Positions in the returned PatternError are relative to rawRegex. func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *PatternError) { if !fox.allowRegexp { - return nil, newPatternError("regexp", 0, len(rawRegex), "not enabled") + return nil, newPatternError("regexp", 0, len(rawRegex), "feature not enabled") } if rawRegex == "" { return nil, newPatternError("regexp", 0, 0, "missing expression") @@ -462,9 +425,6 @@ func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *Pattern // braceIndex returns the index of the closing brace that balances an opening // brace. It starts at startLevel opened brace. -// -// Example: For pattern "{id:[0-9]{1,3}}", the caller would pass "[0-9]{1,3}}" and 1 -// (everything after the initial '{'), and this returns 10 (index of the final '}'). func braceIndex(s string, startLevel int) int { level := startLevel @@ -493,7 +453,7 @@ type parsedRoute struct { // parseRoute wraps parsePattern to provide the old parsedRoute return type. // Callers should migrate to parsePattern directly. func (fox *Router) parseRoute(url string) (parsedRoute, error) { - p, _, err := fox.parsePattern(url) + p, paramCnt, err := fox.parsePattern(url) if err != nil { return parsedRoute{}, err } @@ -517,13 +477,6 @@ func (fox *Router) parseRoute(url string) (parsedRoute, error) { } } - paramCnt := 0 - for _, tk := range p.tokens { - if tk.typ != nodeStatic { - paramCnt++ - } - } - startCatchAll := 0 if p.optionalCatchAll { // Reconstruct the startCatchAll index for backwards compatibility. @@ -539,3 +492,14 @@ func (fox *Router) parseRoute(url string) (parsedRoute, error) { startCatchAll: startCatchAll, }, nil } + +func cleanPattern(pattern string) string { + idx := strings.IndexByte(pattern, '/') + if idx == -1 { + if len(pattern) == 0 { + return pattern + } + return pattern + "/" + } + return pattern[:idx] + CleanPath(pattern[idx+1:]) +} diff --git a/parser_test.go b/parser_test.go index 31654815..c649cb14 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,8 +1,6 @@ package fox import ( - "errors" - "fmt" "regexp" "slices" "strings" @@ -47,12 +45,11 @@ func TestParsePattern(t *testing.T) { } cases := []struct { - name string - path string - wantStr string // Expected parsed.str (defaults to path if empty). - wantTokens []token - wantErr bool - wantOptionalCatchAll bool + name string + path string + wantN int + wantTokens []token + optionalCatchAll bool }{ { name: "valid static route", @@ -60,40 +57,45 @@ func TestParsePattern(t *testing.T) { wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), }, { - name: "top level domain param", - path: "{tld}/foo/bar", + name: "top level domain param", + path: "{tld}/foo/bar", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( paramToken("tld", ""), staticToken("/foo/bar", false), )), }, { - name: "top level domain wildcard", - path: "+{tld}/foo/bar", + name: "top level domain wildcard", + path: "+{tld}/foo/bar", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( wildcardToken("tld", ""), staticToken("/foo/bar", false), )), }, { - name: "valid catch all route", - path: "/foo/bar/+{arg}", + name: "valid catch all route", + path: "/foo/bar/+{arg}", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/bar/", false), wildcardToken("arg", ""), )), }, { - name: "valid param route", - path: "/foo/bar/{baz}", + name: "valid param route", + path: "/foo/bar/{baz}", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/bar/", false), paramToken("baz", ""), )), }, { - name: "valid multi params route", - path: "/foo/{bar}/{baz}", + name: "valid multi params route", + path: "/foo/{bar}/{baz}", + wantN: 2, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/", false), paramToken("bar", ""), @@ -102,8 +104,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "valid same params route", - path: "/foo/{bar}/{bar}", + name: "valid same params route", + path: "/foo/{bar}/{bar}", + wantN: 2, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/", false), paramToken("bar", ""), @@ -112,8 +115,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "valid multi params and catch all route", - path: "/foo/{bar}/{baz}/+{arg}", + name: "valid multi params and catch all route", + path: "/foo/{bar}/{baz}/+{arg}", + wantN: 3, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/", false), paramToken("bar", ""), @@ -124,24 +128,27 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "valid inflight param", - path: "/foo/xyz:{bar}", + name: "valid inflight param", + path: "/foo/xyz:{bar}", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/xyz:", false), paramToken("bar", ""), )), }, { - name: "valid inflight catchall", - path: "/foo/xyz:+{bar}", + name: "valid inflight catchall", + path: "/foo/xyz:+{bar}", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/xyz:", false), wildcardToken("bar", ""), )), }, { - name: "valid multi inflight param and catch all", - path: "/foo/xyz:{bar}/abc:{bar}/+{arg}", + name: "valid multi inflight param and catch all", + path: "/foo/xyz:{bar}/abc:{bar}/+{arg}", + wantN: 3, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/xyz:", false), paramToken("bar", ""), @@ -152,8 +159,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "catch all with arg in the middle of the route", - path: "/foo/bar/+{bar}/baz", + name: "catch all with arg in the middle of the route", + path: "/foo/bar/+{bar}/baz", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/bar/", false), wildcardToken("bar", ""), @@ -161,8 +169,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "multiple catch all suffix and inflight with arg in the middle of the route", - path: "/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + name: "multiple catch all suffix and inflight with arg in the middle of the route", + path: "/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantN: 4, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/bar/", false), wildcardToken("bar", ""), @@ -175,8 +184,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "inflight catch all with arg in the middle of the route", - path: "/foo/bar/damn+{bar}/baz", + name: "inflight catch all with arg in the middle of the route", + path: "/foo/bar/damn+{bar}/baz", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/bar/damn", false), wildcardToken("bar", ""), @@ -184,8 +194,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "catch all with arg in the middle of the route and param after", - path: "/foo/bar/+{bar}/{baz}", + name: "catch all with arg in the middle of the route and param after", + path: "/foo/bar/+{bar}/{baz}", + wantN: 2, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/bar/", false), wildcardToken("bar", ""), @@ -194,24 +205,27 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "simple domain and path", - path: "foo/bar", + name: "simple domain and path", + path: "foo/bar", + wantN: 0, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("foo", true), staticToken("/bar", false), )), }, { - name: "simple domain with trailing slash", - path: "foo/", + name: "simple domain with trailing slash", + path: "foo/", + wantN: 0, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("foo", true), staticToken("/", false), )), }, { - name: "period in param path allowed", - path: "foo/{.bar}", + name: "period in param path allowed", + path: "foo/{.bar}", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("foo", true), staticToken("/", false), @@ -219,133 +233,138 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "missing a least one slash", - path: "foo.com", - wantErr: true, + name: "missing a least one slash", + path: "foo.com", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("foo.com", true), + staticToken("/", false), + )), }, { - name: "empty parameter", - path: "/foo/bar{}", - wantErr: true, + name: "empty parameter", + path: "/foo/bar{}", + wantN: 0, }, { - name: "missing arguments name after catch all", - path: "/foo/bar/*", - wantErr: true, + name: "missing arguments name after catch all", + path: "/foo/bar/*", + wantN: 0, }, { - name: "missing arguments name after param", - path: "/foo/bar/{", - wantErr: true, + name: "missing arguments name after param", + path: "/foo/bar/{", + wantN: 0, }, { - name: "catch all in the middle of the route", - path: "/foo/bar/*/baz", - wantErr: true, + name: "catch all in the middle of the route", + path: "/foo/bar/*/baz", + wantN: 0, }, { - name: "empty infix catch all", - path: "/foo/bar/+{}/baz", - wantErr: true, + name: "empty infix catch all", + path: "/foo/bar/+{}/baz", + wantN: 0, }, { - name: "empty ending catch all", - path: "/foo/bar/baz/+{}", - wantErr: true, + name: "empty ending catch all", + path: "/foo/bar/baz/+{}", + wantN: 0, }, { - name: "unexpected character in param", - path: "/foo/{{bar}", - wantErr: true, + name: "unexpected character in param", + path: "/foo/{{bar}", + wantN: 0, }, { - name: "unexpected character in param", - path: "/foo/{*bar}", - wantErr: true, + name: "unexpected character in param", + path: "/foo/{*bar}", + wantN: 0, }, { - name: "unexpected character in catch-all", - path: "/foo/+{/bar}", - wantErr: true, + name: "unexpected character in catch-all", + path: "/foo/+{/bar}", + wantN: 0, }, { - name: "catch all not supported in hostname", - path: "a.b.c*/", - wantErr: true, + name: "catch all not supported in hostname", + path: "a.b.c*/", + wantN: 0, }, { - name: "illegal character in params hostname", - path: "a.b.c{/", - wantErr: true, + name: "illegal character in params hostname", + path: "a.b.c{/", + wantN: 0, }, { - name: "illegal character in hostname label", - path: "a.b.c}/", - wantErr: true, + name: "illegal character in hostname label", + path: "a.b.c}/", + wantN: 0, }, { - name: "unexpected character in param hostname", - path: "a.{.bar}.c/", - wantErr: true, + name: "unexpected character in param hostname", + path: "a.{.bar}.c/", + wantN: 0, }, { - name: "unexpected character in wildcard hostname", - path: "a.+{.bar}.c/", - wantErr: true, + name: "unexpected character in wildcard hostname", + path: "a.+{.bar}.c/", + wantN: 0, }, { - name: "unexpected character in param hostname", - path: "a.{/bar}.c/", - wantErr: true, + name: "unexpected character in param hostname", + path: "a.{/bar}.c/", + wantN: 0, }, { - name: "unexpected character in wildcard hostname", - path: "a.+{/bar}.c/", - wantErr: true, + name: "unexpected character in wildcard hostname", + path: "a.+{/bar}.c/", + wantN: 0, }, { - name: "in flight catch-all after param in one route segment", - path: "/foo/{bar}+{baz}", - wantErr: true, + name: "in flight catch-all after param in one route segment", + path: "/foo/{bar}+{baz}", + wantN: 0, }, { - name: "multiple param in one route segment", - path: "/foo/{bar}{baz}", - wantErr: true, + name: "multiple param in one route segment", + path: "/foo/{bar}{baz}", + wantN: 0, }, { - name: "in flight param after catch all", - path: "/foo/+{args}{param}", - wantErr: true, + name: "in flight param after catch all", + path: "/foo/+{args}{param}", + wantN: 0, }, { - name: "consecutive catch all with no slash", - path: "/foo/+{args}+{param}", - wantErr: true, + name: "consecutive catch all with no slash", + path: "/foo/+{args}+{param}", + wantN: 0, }, { - name: "consecutive catch all", - path: "/foo/+{args}/+{param}", - wantErr: true, + name: "consecutive catch all", + path: "/foo/+{args}/+{param}", + wantN: 0, }, { - name: "consecutive catch all with inflight", - path: "/foo/ab+{args}/+{param}", - wantErr: true, + name: "consecutive catch all with inflight", + path: "/foo/ab+{args}/+{param}", + wantN: 0, }, { - name: "unexpected char after inflight catch all", - path: "/foo/ab+{args}a", - wantErr: true, + name: "unexpected char after inflight catch all", + path: "/foo/ab+{args}a", + wantN: 0, }, { - name: "unexpected char after catch all", - path: "/foo/+{args}a", - wantErr: true, + name: "unexpected char after catch all", + path: "/foo/+{args}a", + wantN: 0, }, { - name: "prefix catch-all in hostname", - path: "+{any}.com/foo", + name: "prefix catch-all in hostname", + path: "+{any}.com/foo", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( wildcardToken("any", ""), staticToken(".com", true), @@ -353,8 +372,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "infix catch-all in hostname", - path: "a.+{any}.com/foo", + name: "infix catch-all in hostname", + path: "a.+{any}.com/foo", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("a.", true), wildcardToken("any", ""), @@ -363,8 +383,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "illegal catch-all in hostname", - path: "a.b.+{any}/foo", + name: "illegal catch-all in hostname", + path: "a.b.+{any}/foo", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("a.b.", true), wildcardToken("any", ""), @@ -372,8 +393,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "static hostname with catch-all path", - path: "a.b.com/+{any}", + name: "static hostname with catch-all path", + path: "a.b.com/+{any}", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("a.b.com", true), staticToken("/", false), @@ -381,103 +403,104 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "illegal control character in path", - path: "example.com/foo\x00", - wantErr: true, + name: "illegal control character in path", + path: "example.com/foo\x00", + wantN: 0, }, { - name: "illegal leading hyphen in hostname", - path: "-a.com/", - wantErr: true, + name: "illegal leading hyphen in hostname", + path: "-a.com/", + wantN: 0, }, { - name: "illegal leading dot in hostname", - path: ".a.com/", - wantErr: true, + name: "illegal leading dot in hostname", + path: ".a.com/", + wantN: 0, }, { - name: "illegal trailing hyphen in hostname", - path: "a.com-/", - wantErr: true, + name: "illegal trailing hyphen in hostname", + path: "a.com-/", + wantN: 0, }, { - name: "illegal trailing dot in hostname", - path: "a.com./", - wantErr: true, + name: "illegal trailing dot in hostname", + path: "a.com./", + wantN: 0, }, { - name: "illegal trailing dot in hostname after param", - path: "{tld}./foo/bar", - wantErr: true, + name: "illegal trailing dot in hostname after param", + path: "{tld}./foo/bar", + wantN: 0, }, { - name: "illegal single dot in hostname", - path: "./", - wantErr: true, + name: "illegal single dot in hostname", + path: "./", + wantN: 0, }, { - name: "illegal hyphen before dot", - path: "a-.com/", - wantErr: true, + name: "illegal hyphen before dot", + path: "a-.com/", + wantN: 0, }, { - name: "illegal hyphen after dot", - path: "a.-com/", - wantErr: true, + name: "illegal hyphen after dot", + path: "a.-com/", + wantN: 0, }, { - name: "illegal double dot", - path: "a..com/", - wantErr: true, + name: "illegal double dot", + path: "a..com/", + wantN: 0, }, { - name: "illegal double dot with param state", - path: "{b}..com/", - wantErr: true, + name: "illegal double dot with param state", + path: "{b}..com/", + wantN: 0, }, { - name: "illegal double dot with inflight param state", - path: "a{b}..com/", - wantErr: true, + name: "illegal double dot with inflight param state", + path: "a{b}..com/", + wantN: 0, }, { - name: "param not finishing with delimiter in hostname", - path: "{a}b{b}.com/", - wantErr: true, + name: "param not finishing with delimiter in hostname", + path: "{a}b{b}.com/", + wantN: 0, }, { - name: "consecutive parameter in hostname", - path: "{a}{b}.com/", - wantErr: true, + name: "consecutive parameter in hostname", + path: "{a}{b}.com/", + wantN: 0, }, { - name: "leading hostname label exceed 63 characters", - path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", - wantErr: true, + name: "leading hostname label exceed 63 characters", + path: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.b.com/", + wantN: 0, }, { - name: "middle hostname label exceed 63 characters", - path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", - wantErr: true, + name: "middle hostname label exceed 63 characters", + path: "a.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", + wantN: 0, }, { - name: "trailing hostname label exceed 63 characters", - path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", - wantErr: true, + name: "trailing hostname label exceed 63 characters", + path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", + wantN: 0, }, { - name: "illegal character in domain", - path: "a.b!.com/", - wantErr: true, + name: "illegal character in domain", + path: "a.b!.com/", + wantN: 0, }, { - name: "invalid all-numeric label", - path: "123/", - wantErr: true, + name: "invalid all-numeric label", + path: "123/", + wantN: 0, }, { - name: "all-numeric label with param", - path: "123.{a}.456/", + name: "all-numeric label with param", + path: "123.{a}.456/", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("123.", true), paramToken("a", ""), @@ -486,8 +509,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "all-numeric label with wildcard", - path: "123.+{a}.456/", + name: "all-numeric label with wildcard", + path: "123.+{a}.456/", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("123.", true), wildcardToken("a", ""), @@ -496,28 +520,29 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "all-numeric label with path wildcard", - path: "123.456/{abc}", - wantErr: true, + name: "all-numeric label with path wildcard", + path: "123.456/{abc}", + wantN: 0, }, { - name: "hostname exceed 255 character", - path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", - wantErr: true, + name: "hostname exceed 255 character", + path: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", + wantN: 0, }, { - name: "invalid all-numeric label", - path: "11.22.33/", - wantErr: true, + name: "invalid all-numeric label", + path: "11.22.33/", + wantN: 0, }, { - name: "invalid uppercase label", - path: "ABC/", - wantErr: true, + name: "invalid uppercase label", + path: "ABC/", + wantN: 0, }, { - name: "2 regular params in domain", - path: "{a}.{b}.com/", + name: "2 regular params in domain", + path: "{a}.{b}.com/", + wantN: 2, wantTokens: slices.Collect(iterutil.SeqOf( paramToken("a", ""), staticToken(".", true), @@ -527,16 +552,18 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "253 character with .", - path: "78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + name: "253 character with .", + path: "78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + wantN: 0, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), staticToken("/", false), )), }, { - name: "param does not count at character", - path: "{a}.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + name: "param does not count at character", + path: "{a}.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj/", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( paramToken("a", ""), staticToken(".78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzj", true), @@ -544,8 +571,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "hostname variant with multiple catch all suffix and inflight with arg in the middle of the route", - path: "example.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + name: "hostname variant with multiple catch all suffix and inflight with arg in the middle of the route", + path: "example.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantN: 4, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("example.com", true), staticToken("/foo/bar/", false), @@ -559,8 +587,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "hostname variant with inflight catch all with arg in the middle of the route", - path: "example.com/foo/bar/damn+{bar}/baz", + name: "hostname variant with inflight catch all with arg in the middle of the route", + path: "example.com/foo/bar/damn+{bar}/baz", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("example.com", true), staticToken("/foo/bar/damn", false), @@ -569,8 +598,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "hostname variant catch all with arg in the middle of the route and param after", - path: "example.com/foo/bar/+{bar}/{baz}", + name: "hostname variant catch all with arg in the middle of the route and param after", + path: "example.com/foo/bar/+{bar}/{baz}", + wantN: 2, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("example.com", true), staticToken("/foo/bar/", false), @@ -580,8 +610,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "complex domain and path", - path: "{ab}.{c}.de{f}.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + name: "complex domain and path", + path: "{ab}.{c}.de{f}.com/foo/bar/+{bar}/x+{args}/y/+{z}/{b}", + wantN: 7, wantTokens: slices.Collect(iterutil.SeqOf( paramToken("ab", ""), staticToken(".", true), @@ -599,68 +630,87 @@ func TestParsePattern(t *testing.T) { paramToken("b", ""), )), }, - // CleanPath normalizes traversal patterns instead of rejecting them. { - name: "double slash cleaned", - path: "/foo//bar", - wantStr: "/foo/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + name: "path with double slash", + path: "/foo//bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar", false), + )), }, { - name: "triple slash cleaned", - path: "/foo///bar", - wantStr: "/foo/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + name: "path with > double slash", + path: "/foo///bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar", false), + )), }, { - name: "slash dot slash cleaned", - path: "/foo/./bar", - wantStr: "/foo/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + name: "path with slash dot slash", + path: "/foo/./bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar", false), + )), }, { - name: "slash dot slash dot slash cleaned", - path: "/foo/././bar", - wantStr: "/foo/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/bar", false))), + name: "path with slash dot slash", + path: "/foo/././bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/bar", false), + )), }, { - name: "double dot parent reference cleaned", - path: "/foo/../bar", - wantStr: "/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/bar", false))), + name: "path with double dot parent reference", + path: "/foo/../bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/bar", false), + )), }, { - name: "double parent reference cleaned", - path: "/foo/../../bar", - wantStr: "/bar", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/bar", false))), + name: "path with double dot parent reference", + path: "/foo/../../bar", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/bar", false), + )), }, { - name: "trailing slash dot cleaned", - path: "/foo/.", - wantStr: "/foo/", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/foo/", false))), + name: "path ending with slash dot", + path: "/foo/.", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/foo/", false), + )), }, { - name: "trailing slash double dot cleaned", - path: "/foo/..", - wantStr: "/", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/", false))), + name: "path ending with slash double dot", + path: "/foo/..", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/", false), + )), }, { - name: "root slash dot cleaned", - path: "/.", - wantStr: "/", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/", false))), + name: "path ending with slash dot", + path: "/.", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/", false), + )), }, { - name: "root slash double dot cleaned", - path: "/..", - wantStr: "/", - wantTokens: slices.Collect(iterutil.SeqOf(staticToken("/", false))), + name: "path ending with slash double dot", + path: "/..", + wantN: 0, + wantTokens: slices.Collect(iterutil.SeqOf( + staticToken("/", false), + )), }, - // Allowed dot and slash combination + // Allowed dot and slash combinaison { name: "last path segment starting with slash dot and text", path: "/foo/.bar", @@ -683,8 +733,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "path segment starting with slash dot and param", - path: "/foo/.{foo}/baz", + name: "path segment starting with slash dot and param", + path: "/foo/.{foo}/baz", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/.", false), paramToken("foo", ""), @@ -699,8 +750,9 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "path segment starting with slash dot and param", - path: "/foo/..{foo}/baz", + name: "path segment starting with slash dot and param", + path: "/foo/..{foo}/baz", + wantN: 1, wantTokens: slices.Collect(iterutil.SeqOf( staticToken("/foo/..", false), paramToken("foo", ""), @@ -771,6 +823,7 @@ func TestParsePattern(t *testing.T) { staticToken("/foo/", false), paramToken("bar", "[A-z]+"), )), + wantN: 1, }, { name: "simple ending param with regexp", @@ -779,6 +832,7 @@ func TestParsePattern(t *testing.T) { staticToken("/foo/", false), wildcardToken("bar", "[A-z]+"), )), + wantN: 1, }, { name: "simple infix param with regexp", @@ -788,6 +842,7 @@ func TestParsePattern(t *testing.T) { paramToken("bar", "[A-z]+"), staticToken("/baz", false), )), + wantN: 1, }, { name: "multi infix and ending param with regexp", @@ -798,6 +853,7 @@ func TestParsePattern(t *testing.T) { staticToken("/", false), paramToken("baz", "[0-9]+"), )), + wantN: 2, }, { name: "multi infix and ending wildcard with regexp", @@ -808,6 +864,7 @@ func TestParsePattern(t *testing.T) { staticToken("/a", false), wildcardToken("baz", "[0-9]+"), )), + wantN: 2, }, { name: "consecutive infix regexp wildcard and regexp param allowed", @@ -818,6 +875,7 @@ func TestParsePattern(t *testing.T) { staticToken("/", false), paramToken("baz", "[0-9]+"), )), + wantN: 2, }, { name: "hostname starting with regexp", @@ -827,6 +885,7 @@ func TestParsePattern(t *testing.T) { staticToken(".b.c", true), staticToken("/foo", false), )), + wantN: 1, }, { name: "hostname with middle param regexp", @@ -837,6 +896,7 @@ func TestParsePattern(t *testing.T) { staticToken(".c", true), staticToken("/foo", false), )), + wantN: 1, }, { name: "hostname ending with param regexp", @@ -846,6 +906,7 @@ func TestParsePattern(t *testing.T) { paramToken("c", "[A-z]+"), staticToken("/foo", false), )), + wantN: 1, }, { name: "non capturing group allowed in regexp", @@ -854,6 +915,7 @@ func TestParsePattern(t *testing.T) { staticToken("/foo/", false), paramToken("bar", "(?:foo|bar)"), )), + wantN: 1, }, { name: "regexp wildcard at the beginning of the path", @@ -863,6 +925,7 @@ func TestParsePattern(t *testing.T) { wildcardToken("foo", "[A-z]+"), staticToken("/bar", false), )), + wantN: 1, }, { name: "regexp wildcard at the beginning of the host", @@ -872,6 +935,7 @@ func TestParsePattern(t *testing.T) { staticToken(".b.c", true), staticToken("/", false), )), + wantN: 1, }, { name: "consecutive wildcard from hostname to path", @@ -881,6 +945,7 @@ func TestParsePattern(t *testing.T) { staticToken("/", false), wildcardToken("bar", ""), )), + wantN: 2, }, { name: "consecutive wildcard with empty catch all from hostname to path", @@ -890,7 +955,8 @@ func TestParsePattern(t *testing.T) { staticToken("/", false), wildcardToken("bar", ""), )), - wantOptionalCatchAll: true, + wantN: 2, + optionalCatchAll: true, }, { name: "param then wildcard regexp", @@ -901,6 +967,7 @@ func TestParsePattern(t *testing.T) { wildcardToken("b", "b"), staticToken("/", false), )), + wantN: 2, }, { name: "param regexp then wildcard regexp", @@ -911,6 +978,7 @@ func TestParsePattern(t *testing.T) { wildcardToken("b", "b"), staticToken("/", false), )), + wantN: 2, }, { name: "catch all empty as suffix", @@ -919,208 +987,164 @@ func TestParsePattern(t *testing.T) { staticToken("/foo/", false), wildcardToken("any", ""), )), - wantOptionalCatchAll: true, + wantN: 1, + optionalCatchAll: true, }, { - name: "consecutive infix wildcard at start with regexp not allowed", - path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", - wantErr: true, + name: "consecutive infix wildcard at start with regexp not allowed", + path: "/+{foo:[A-z]+}/+{baz:[0-9]+}", }, { - name: "consecutive wildcard with catch all empty not allowed", - path: "/+{foo}/*{baz}", - wantErr: true, + name: "consecutive wildcard with catch all empty not allowed", + path: "/+{foo}/*{baz}", }, { - name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", - path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", - wantErr: true, + name: "consecutive infix wildcard with catch all empty at start with regexp not allowed", + path: "/+{foo:[A-z]+}/*{baz:[0-9]+}", }, { - name: "hostname consecutive infix wildcard at start with regexp not allowed", - path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", - wantErr: true, + name: "hostname consecutive infix wildcard at start with regexp not allowed", + path: "/{foo:[A-z]+}.+{baz:[0-9]+}/", }, { - name: "consecutive infix wildcard at start with and without regexp not allowed", - path: "/+{foo:[A-z]+}/+{baz}", - wantErr: true, + name: "consecutive infix wildcard at start with and without regexp not allowed", + path: "/+{foo:[A-z]+}/+{baz}", }, { - name: "hostname consecutive infix wildcard at start with and without regexp not allowed", - path: "+{foo:[A-z]+}.+{baz}/", - wantErr: true, + name: "hostname consecutive infix wildcard at start with and without regexp not allowed", + path: "+{foo:[A-z]+}.+{baz}/", }, { - name: "consecutive infix wildcard at start with regexp not allowed", - path: "/+{foo}/+{baz:[0-9]+}/", - wantErr: true, + name: "consecutive infix wildcard at start with regexp not allowed", + path: "/+{foo}/+{baz:[0-9]+}/", }, { - name: "hostname consecutive infix wildcard at start with regexp not allowed", - path: "+{foo}.+{baz:[0-9]+}/", - wantErr: true, + name: "hostname consecutive infix wildcard at start with regexp not allowed", + path: "+{foo}.+{baz:[0-9]+}/", }, { - name: "consecutive infix wildcard with regexp not allowed", - path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", - wantErr: true, + name: "consecutive infix wildcard with regexp not allowed", + path: "/foo/+{bar:[A-z]+}/+{baz:[0-9]+}", }, { - name: "hostname consecutive infix wildcard with regexp not allowed", - path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", - wantErr: true, + name: "hostname consecutive infix wildcard with regexp not allowed", + path: "foo.+{bar:[A-z]+}.+{baz:[0-9]+}/", }, { - name: "consecutive infix wildcard with first regexp not allowed", - path: "/foo/+{bar:[A-z]+}/+{baz}", - wantErr: true, + name: "consecutive infix wildcard with first regexp not allowed", + path: "/foo/+{bar:[A-z]+}/+{baz}", }, { - name: "hostname consecutive infix wildcard with first regexp not allowed", - path: "foo.+{bar:[A-z]+}.+{baz}/", - wantErr: true, + name: "hostname consecutive infix wildcard with first regexp not allowed", + path: "foo.+{bar:[A-z]+}.+{baz}/", }, { - name: "consecutive infix wildcard with second regexp not allowed", - path: "/foo/+{bar}/+{baz:[A-z]+}/", - wantErr: true, + name: "consecutive infix wildcard with second regexp not allowed", + path: "/foo/+{bar}/+{baz:[A-z]+}/", }, { - name: "hostname consecutive infix wildcard with second regexp not allowed", - path: "foo.+{bar}.+{baz:[A-z]+}/", - wantErr: true, + name: "hostname consecutive infix wildcard with second regexp not allowed", + path: "foo.+{bar}.+{baz:[A-z]+}/", }, { - name: "non slash char after regexp param not allowed", - path: "/foo/{bar:[A-z]+}a/", - wantErr: true, + name: "non slash char after regexp param not allowed", + path: "/foo/{bar:[A-z]+}a/", }, { - name: "non slash char after regexp wildcard not allowed", - path: "/foo/+{bar:[A-z]+}a/", - wantErr: true, + name: "non slash char after regexp wildcard not allowed", + path: "/foo/+{bar:[A-z]+}a/", }, { - name: "regexp wildcard not allowed in hostname", - path: "+{a.{b:[A-z]+}}.c/", - wantErr: true, + name: "regexp wildcard not allowed in hostname", + path: "+{a.{b:[A-z]+}}.c/", }, { - name: "regexp wildcard not allowed in hostname", - path: "+{a.b.{c:[A-z]+}/", - wantErr: true, + name: "regexp wildcard not allowed in hostname", + path: "+{a.b.{c:[A-z]+}/", }, { - name: "missing param name with regexp", - path: "/foo/{:[A-z]+}", - wantErr: true, + name: "missing param name with regexp", + path: "/foo/{:[A-z]+}", }, { - name: "missing wildcard name with regexp", - path: "/foo/+{:[A-z]+}", - wantErr: true, + name: "missing wildcard name with regexp", + path: "/foo/+{:[A-z]+}", }, { - name: "missing regular expression", - path: "/foo/{a:}", - wantErr: true, + name: "missing regular expression", + path: "/foo/{a:}", }, { - name: "missing regular expression with only ':'", - path: "/foo/{:}", - wantErr: true, + name: "missing regular expression with only ':'", + path: "/foo/{:}", }, { - name: "unsupported regexp in optional wildcard", - path: "/foo/*{any:[A-z]+}", - wantErr: true, + name: "unsupported regexp in optional wildcard", + path: "/foo/*{any:[A-z]+}", }, { - name: "unbalanced braces in param regexp", - path: "/foo/{bar:[A-z]+", - wantErr: true, + name: "unbalanced braces in param regexp", + path: "/foo/{bar:[A-z]+", }, { - name: "unbalanced braces in wildcard regexp", - path: "/foo/+{bar:[A-z]+", - wantErr: true, + name: "unbalanced braces in wildcard regexp", + path: "/foo/+{bar:[A-z]+", }, { - name: "balanced braces in param regexp with invalid char after", - path: "/foo/{bar:{}}a", - wantErr: true, + name: "balanced braces in param regexp with invalid char after", + path: "/foo/{bar:{}}a", }, { - name: "balanced braces in wildcard regexp with invalid brace after", - path: "/foo/{bar:{}}}", - wantErr: true, + name: "balanced braces in wildcard regexp with invalid brace after", + path: "/foo/{bar:{}}}", }, { - name: "unbalanced braces in regexp complex", - path: "/foo/{bar:{{{{}}}}", - wantErr: true, + name: "unbalanced braces in regexp complex", + path: "/foo/{bar:{{{{}}}}", }, { - name: "invalid regular expression", - path: "/foo/{bar:a{5,2}}", - wantErr: true, + name: "invalid regular expression", + path: "/foo/{bar:a{5,2}}", }, { - name: "invalid regular expression", - path: "/foo/{bar:\\k}", - wantErr: true, + name: "invalid regular expression", + path: "/foo/{bar:\\k}", }, { - name: "capture group in regexp are not allowed", - path: "/foo/{bar:(foo|bar)}", - wantErr: true, + name: "capture group in regexp are not allowed", + path: "/foo/{bar:(foo|bar)}", }, { - name: "no opening brace after * wildcard", - path: "/foo/*:bar}", - wantErr: true, + name: "no opening brace after * wildcard", + path: "/foo/*:bar}", }, { - name: "no infix catch all empty", - path: "/foo/*{any}/bar", - wantErr: true, + name: "no infix catch all empty", + path: "/foo/*{any}/bar", }, { - name: "no infix inflight catch all empty", - path: "/foo/uuid_*{any}/bar", - wantErr: true, + name: "no infix inflight catch all empty", + path: "/foo/uuid_*{any}/bar", }, { - name: "no suffix catch all empty in hostname", - path: "a.b.*{any}/", - wantErr: true, + name: "no suffix catch all empty in hostname", + path: "a.b.*{any}/", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - parsed, _, err := f.parsePattern(tc.path) - if tc.wantErr { - require.Error(t, err) - var pe *PatternError - require.True(t, errors.As(err, &pe), "expected *PatternError, got %T", err) - assert.True(t, errors.Is(err, ErrInvalidRoute), "PatternError should unwrap to ErrInvalidRoute") - assert.NotEmpty(t, pe.Pattern, "Pattern must be set") - assert.NotEmpty(t, pe.Reason, "Reason must be set") - assert.True(t, pe.Start >= 0, "Start must be >= 0") - assert.True(t, pe.End >= pe.Start, "End must be >= Start") - assert.True(t, pe.End <= len(pe.Pattern), "End must be <= len(Pattern)") + pat, paramCnt, err := f.parsePattern(tc.path) + if err != nil { + var patErr *PatternError + require.ErrorAs(t, err, &patErr) return } - require.NoError(t, err) - wantStr := tc.wantStr - if wantStr == "" { - wantStr = tc.path + assert.Equal(t, tc.wantN, paramCnt) + assert.Equal(t, tc.wantTokens, pat.tokens) + assert.Equal(t, tc.optionalCatchAll, pat.optionalCatchAll) + if err == nil { + assert.Equal(t, strings.IndexByte(cleanPattern(tc.path), '/'), pat.endHost) } - assert.Equal(t, wantStr, parsed.str) - assert.Equal(t, tc.wantTokens, parsed.tokens) - assert.Equal(t, tc.wantOptionalCatchAll, parsed.optionalCatchAll) - assert.Equal(t, strings.IndexByte(tc.path, '/'), parsed.endHost) }) } } @@ -1201,7 +1225,8 @@ func TestParsePatternParamsConstraint(t *testing.T) { func TestPatternErrorPosition(t *testing.T) { cases := []struct { name string - path string + pattern string + options []GlobalOption wantType string wantReason string wantStart int @@ -1209,263 +1234,27 @@ func TestPatternErrorPosition(t *testing.T) { wantMsg string }{ { - name: "uppercase character in hostname", - path: "example.Com/path", - wantType: "hostname", - wantReason: "syntax", - wantStart: 8, - wantEnd: 9, - wantMsg: "uppercase character in label", - }, - { - name: "all numeric hostname", - path: "1234567/path", - wantType: "hostname", + name: "empty raw pattern", + pattern: "", wantReason: "syntax", wantStart: 0, - wantEnd: 7, - wantMsg: "all numeric", - }, - { - name: "missing trailing slash", - path: "foo.com", - wantReason: "syntax", - wantStart: 0, - wantEnd: 7, - wantMsg: "missing trailing '/'", - }, - { - name: "empty parameter in path", - path: "/foo/bar{}", - wantType: "path", - wantReason: "parameter", - wantStart: 8, - wantEnd: 10, - wantMsg: "missing name", - }, - { - name: "illegal char in param name", - path: "/foo/{*bar}", - wantType: "path", - wantReason: "parameter", - wantStart: 6, - wantEnd: 7, - wantMsg: "illegal character in name", - }, - { - name: "unbalanced braces in path", - path: "/foo/{bar:[A-z]+", - wantType: "path", - wantReason: "syntax", - wantStart: 5, - wantEnd: 16, - wantMsg: "unbalanced braces", - }, - { - name: "missing param after + in path", - path: "/foo/bar/+baz", - wantType: "path", - wantReason: "syntax", - wantStart: 9, - wantEnd: 10, - wantMsg: "missing parameter after delimiter", - }, - { - name: "consecutive wildcard in path", - path: "/foo/+{args}/+{param}", - wantType: "path", - wantReason: "syntax", - wantStart: 13, - wantEnd: 14, - wantMsg: "consecutive wildcard", - }, - { - name: "illegal character after param in path", - path: "/foo/{bar}+{baz}", - wantType: "path", - wantReason: "syntax", - wantStart: 10, - wantEnd: 11, - wantMsg: "character after parameter", - }, - { - name: "illegal control character in path", - path: "example.com/foo\x00", - wantType: "path", - wantReason: "syntax", - wantStart: 15, - wantEnd: 16, - wantMsg: "control character", - }, - { - name: "capture group in regexp", - path: "/foo/{bar:(foo|bar)}", - wantType: "path", - wantReason: "regexp", - wantStart: 10, - wantEnd: 19, - wantMsg: "capture group, use (?:...) instead", - }, - { - name: "missing regular expression", - path: "/foo/{a:}", - wantType: "path", - wantReason: "regexp", - wantStart: 8, - wantEnd: 8, - wantMsg: "missing expression", - }, - { - name: "regexp not allowed in optional wildcard", - path: "/foo/*{any:[A-z]+}", - wantType: "path", - wantReason: "regexp", - wantStart: 6, - wantEnd: 18, - wantMsg: "not allowed in optional wildcard", - }, - { - name: "optional wildcard only as suffix", - path: "/foo/*{any}/bar", - wantType: "path", - wantReason: "syntax", - wantStart: 5, - wantEnd: 11, - wantMsg: "optional wildcard allowed only as suffix", - }, - { - name: "trailing dot in hostname", - path: "a.com./", - wantType: "hostname", - wantReason: "syntax", - wantStart: 5, - wantEnd: 6, - wantMsg: "trailing '.'", - }, - { - name: "trailing hyphen in hostname", - path: "a.com-/", - wantType: "hostname", - wantReason: "syntax", - wantStart: 5, - wantEnd: 6, - wantMsg: "illegal trailing '-'", - }, - { - name: "hostname label exceed 63 characters", - path: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", - wantType: "hostname", - wantReason: "constraint", - wantStart: 4, - wantEnd: 68, - wantMsg: "label exceeds 63 characters", - }, - { - name: "too many params", - path: "/{1}/{2}/{3}/{4}", - wantType: "path", - wantReason: "constraint", - wantStart: 13, - wantEnd: 16, - wantMsg: "too many parameters", - }, - { - name: "param key too large", - path: "/{abcd}", - wantType: "path", - wantReason: "constraint", - wantStart: 2, - wantEnd: 6, - wantMsg: "key too large", - }, - { - name: "hostname illegal character after param", - path: "{a}b{b}.com/", - wantType: "hostname", - wantReason: "syntax", - wantStart: 3, - wantEnd: 4, - wantMsg: "character after parameter", - }, - { - name: "hostname consecutive dot", - path: "a..com/", - wantType: "hostname", - wantReason: "syntax", - wantStart: 2, - wantEnd: 3, - wantMsg: "consecutive '.'", - }, - { - name: "regexp not allowed with disabled regexp", - path: "/{a:a}", - wantType: "path", - wantReason: "regexp", - wantStart: 4, - wantEnd: 5, - wantMsg: "not enabled", - }, - { - name: "hostname missing param after + delimiter", - path: "+baz.com/", - wantType: "hostname", - wantReason: "syntax", - wantStart: 0, - wantEnd: 1, - wantMsg: "missing parameter after delimiter", - }, - { - name: "hostname optional wildcard only as suffix", - path: "a.b.*{any}/", - wantType: "hostname", - wantReason: "syntax", - wantStart: 4, - wantEnd: 6, - wantMsg: "optional wildcard allowed only as suffix", - }, - { - name: "missing param name with regexp", - path: "/foo/{:[A-z]+}", - wantType: "path", - wantReason: "parameter", - wantStart: 5, - wantEnd: 14, - wantMsg: "missing name", + wantEnd: 0, + wantMsg: "empty pattern", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - var f *Router - if tc.name == "regexp not allowed with disabled regexp" { - f = MustRouter() - } else if tc.name == "too many params" { - var err error - f, err = NewRouter(WithMaxRouteParams(3), AllowRegexpParam(true)) - require.NoError(t, err) - } else if tc.name == "param key too large" { - var err error - f, err = NewRouter(WithMaxRouteParamKeyBytes(3), AllowRegexpParam(true)) - require.NoError(t, err) - } else { - f = MustRouter(AllowRegexpParam(true)) - } - - _, _, err := f.parsePattern(tc.path) + f := MustRouter(tc.options...) + _, _, err := f.parsePattern(tc.pattern) require.Error(t, err) var pe *PatternError - require.True(t, errors.As(err, &pe), "expected *PatternError, got %T: %v", err, err) - assert.Equal(t, tc.wantType, pe.Type, "type mismatch") - assert.Equal(t, tc.wantReason, pe.Reason, "reason mismatch") - assert.Equal(t, tc.wantStart, pe.Start, "start mismatch") - assert.Equal(t, tc.wantEnd, pe.End, "end mismatch") - assert.Contains(t, pe.Error(), tc.wantMsg, "message mismatch") - fmt.Println(pe) + require.ErrorAs(t, err, &pe) + assert.Equal(t, tc.wantType, pe.Type) + assert.Equal(t, tc.wantReason, pe.Reason) + assert.Equal(t, tc.wantStart, pe.Start) + assert.Equal(t, tc.wantEnd, pe.End) + assert.Contains(t, pe.Error(), tc.wantMsg) }) } } - -func TestX(t *testing.T) { - f := MustRouter() - f.MustAdd(MethodGet, "/foo/{asfsadf*}/baz", emptyHandler) -} From 209653a09a977f72923c4844fb25d8285883da59 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:17:18 +0200 Subject: [PATCH 04/11] feat(parser): reject unclean paths with structured errors instead of silent cleanup --- .github/workflows/tests.yaml | 7 + error.go | 51 +++- fox.go | 29 +- fox_test.go | 11 +- iter.go | 4 +- node.go | 6 +- node_test.go | 12 +- parser.go | 163 +++--------- parser_test.go | 495 ++++++++++++++++++++++++++++++----- route.go | 13 +- tree.go | 14 +- tree_test.go | 6 +- txn.go | 10 +- txn_test.go | 8 +- 14 files changed, 585 insertions(+), 244 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 202f00af..4466b5a9 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -3,6 +3,9 @@ on: push: workflow_dispatch: +permissions: + contents: read + jobs: test: name: Test Fox @@ -19,6 +22,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 + with: + persist-credentials: false - name: Run tests run: go test -v -coverprofile=coverage.txt -covermode=atomic ./... @@ -47,6 +52,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 + with: + persist-credentials: false - name: Run linter uses: golangci/golangci-lint-action@v9 diff --git a/error.go b/error.go index 21650fa2..1c6bdd9e 100644 --- a/error.go +++ b/error.go @@ -44,7 +44,7 @@ func (e *RouteConflictError) Error() string { routef(sb, e.New, 4, true) if e.isShadowed { - if e.New.catchEmpty { + if e.New.pattern.optionalCatchAll { sb.WriteString("\nis shadowed by") } else { sb.WriteString("\nwould shadow") @@ -96,3 +96,52 @@ func newRouteNotFoundError(route *Route) error { sb.WriteString("\nis not registered") return fmt.Errorf("%w: %s", ErrRouteNotFound, sb.String()) } + +type PatternError struct { + Pattern string // canonical form of the route pattern + Type string // hostname | path + Reason string // syntax | parameter | regexp | constraint + Hint string // hint + Start int // start offset of the offending segment + End int // end offset of the offending segment +} + +// Error returns a human-readable error message with a visual pointer to the offending segment. +func (e *PatternError) Error() string { + var sb strings.Builder + sb.WriteString("pattern: ") + if e.Type != "" { + sb.WriteString(e.Type) + sb.WriteString(": ") + } + sb.WriteString(e.Reason) + sb.WriteString(": ") + sb.WriteString(e.Hint) + if e.Pattern != "" { + sb.WriteByte('\n') + sb.WriteString(" ") + sb.WriteString(e.Pattern) + sb.WriteByte('\n') + sb.WriteString(" ") + for i := 0; i < e.Start; i++ { + sb.WriteByte(' ') + } + n := e.End - e.Start + if n <= 0 { + n = 1 + } + for i := 0; i < n; i++ { + sb.WriteByte('^') + } + } + return sb.String() +} + +func newPatternError(reason string, start, end int, msg string) *PatternError { + return &PatternError{ + Reason: reason, + Start: start, + End: end, + Hint: msg, + } +} diff --git a/fox.go b/fox.go index 5f949f3f..ce437368 100644 --- a/fox.go +++ b/fox.go @@ -325,7 +325,7 @@ func (fox *Router) Route(methods []string, pattern string, matchers ...Matcher) return nil } idx := slices.IndexFunc(matched.routes, func(r *Route) bool { - return r.pattern == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) + return r.pattern.str == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) }) if idx < 0 { return nil @@ -386,7 +386,7 @@ func (fox *Router) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc * idx, n, tsr := tree.lookup(r.Method, r.Host, path, c, false) if n != nil { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params return c.route, c, tsr } @@ -411,7 +411,7 @@ func (fox *Router) NewRoute(methods []string, pattern string, handler HandlerFun } } - parsed, err := fox.parseRoute(pattern) + pat, paramsCnt, err := fox.parsePattern(pattern) if err != nil { return nil, err } @@ -419,15 +419,12 @@ func (fox *Router) NewRoute(methods []string, pattern string, handler HandlerFun rte := &Route{ clientip: fox.clientip, hbase: handler, - pattern: pattern, + pattern: pat, handleSlash: fox.handleSlash, - hostEnd: parsed.endHost, - tokens: parsed.token, - catchEmpty: parsed.startCatchAll > 0 && pattern[parsed.startCatchAll] == starDelim, } - rte.params = make([]string, 0, parsed.paramCnt) - for _, tk := range parsed.token { + rte.params = make([]string, 0, paramsCnt) + for _, tk := range pat.tokens { if tk.typ != nodeStatic { rte.params = append(rte.params, tk.value) } @@ -596,7 +593,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { idx, n, tsr := tree.lookup(r.Method, r.Host, path, c, false) if !tsr && n != nil { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params c.route.hall(c) tree.pool.Put(c) @@ -608,7 +605,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { route := n.routes[idx] if route.handleSlash == RelaxedSlash { c.route = route - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params route.hall(c) tree.pool.Put(c) @@ -631,7 +628,7 @@ func (fox *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { *c.params = (*c.params)[:0] if idx, n, tsr := tree.lookup(r.Method, r.Host, CleanPath(path), c, false); n != nil && (!tsr || n.routes[idx].handleSlash == RelaxedSlash) { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params c.route.hall(c) tree.pool.Put(c) @@ -797,7 +794,7 @@ func (fox *Router) serveSubRouter(c *Context, path string) { if !tsr && n != nil { c.route = n.routes[idx] *c.paramsKeys = append(*c.paramsKeys, c.route.params...) - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str c.route.hall(c) return } @@ -808,7 +805,7 @@ func (fox *Router) serveSubRouter(c *Context, path string) { if route.handleSlash == RelaxedSlash { c.route = route *c.paramsKeys = append(*c.paramsKeys, route.params...) - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str route.hall(c) return } @@ -829,7 +826,7 @@ func (fox *Router) serveSubRouter(c *Context, path string) { if idx, n, tsr := tree.lookupByPath(r.Method, CleanPath(path), c, false); n != nil && (!tsr || n.routes[idx].handleSlash == RelaxedSlash) { c.route = n.routes[idx] *c.paramsKeys = append(*c.paramsKeys, c.route.params...) - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str c.route.hall(c) return } @@ -972,7 +969,7 @@ func Sub(router *Router) HandlerFunc { *subCtx.subPatterns = append(*subCtx.subPatterns, *c.subPatterns...) - lastTkType := route.tokens[len(route.tokens)-1].typ + lastTkType := route.pattern.tokens[len(route.pattern.tokens)-1].typ var p string switch lastTkType { case nodeWildcard: diff --git a/fox_test.go b/fox_test.go index 7c9e1d6b..a40004e0 100644 --- a/fox_test.go +++ b/fox_test.go @@ -2049,7 +2049,7 @@ func TestInsertConflict(t *testing.T) { var conflict *RouteConflictError require.ErrorAs(t, got, &conflict) patterns := iterutil.Map(slices.Values(conflict.Conflicts), func(a *Route) string { - return a.pattern + return a.pattern.str }) assert.Equal(t, tc.wantMatch, slices.Collect(patterns)) }) @@ -2119,16 +2119,18 @@ func TestUpdateConflict(t *testing.T) { func TestInvalidRoute(t *testing.T) { f := MustRouter() + var pe *PatternError + // Invalid route on insert assert.ErrorIs(t, onlyError(f.Add([]string{"G\x00ET"}, "/foo", emptyHandler)), ErrInvalidRoute) assert.ErrorIs(t, onlyError(f.Add([]string{""}, "/foo", emptyHandler)), ErrInvalidRoute) assert.ErrorIs(t, onlyError(f.Add(MethodGet, "/foo", nil)), ErrInvalidRoute) - assert.ErrorIs(t, onlyError(f.Add(MethodGet, "/foo\x00", emptyHandler)), ErrInvalidRoute) + assert.ErrorAs(t, onlyError(f.Add(MethodGet, "/foo\x00", emptyHandler)), &pe) // Invalid route on update assert.ErrorIs(t, onlyError(f.Update([]string{""}, "/foo", emptyHandler)), ErrInvalidRoute) assert.ErrorIs(t, onlyError(f.Update(MethodGet, "/foo", nil)), ErrInvalidRoute) - assert.ErrorIs(t, onlyError(f.Update(MethodGet, "/foo\x00", emptyHandler)), ErrInvalidRoute) + assert.ErrorAs(t, onlyError(f.Update(MethodGet, "/foo\x00", emptyHandler)), &pe) } func TestUpdateRoute(t *testing.T) { @@ -3248,7 +3250,8 @@ func TestRouter_DeleteError(t *testing.T) { }) t.Run("delete invalid route", func(t *testing.T) { r, err := f.Delete(MethodGet, "/{") - assert.ErrorIs(t, err, ErrInvalidRoute) + var pe *PatternError + assert.ErrorAs(t, err, &pe) assert.Nil(t, r) }) t.Run("route does not exist", func(t *testing.T) { diff --git a/iter.go b/iter.go index 6ba01935..e1c0cf4d 100644 --- a/iter.go +++ b/iter.go @@ -57,7 +57,7 @@ func (it Iter) Routes(pattern string) iter.Seq[*Route] { } for _, route := range matched.routes { - if route.pattern == pattern { + if route.pattern.str == pattern { if !yield(route) { return } @@ -156,7 +156,7 @@ func (it Iter) PatternPrefix(prefix string) iter.Seq[*Route] { if elem.isLeaf() { for _, route := range elem.routes { - if len(route.params) > 0 && !strings.HasPrefix(route.pattern, prefix) { + if len(route.params) > 0 && !strings.HasPrefix(route.pattern.str, prefix) { continue } diff --git a/node.go b/node.go index d017a34b..942cce14 100644 --- a/node.go +++ b/node.go @@ -363,7 +363,7 @@ Walk: // to search for match empty catch-all. for _, wildcardNode := range child.wildcards { for i, route := range wildcardNode.routes { - if route.handleSlash != StrictSlash && route.catchEmpty && route.match(method, c) { + if route.handleSlash != StrictSlash && route.pattern.optionalCatchAll && route.match(method, c) { if !lazy { // record empty match *c.params = append(*c.params, "") @@ -571,7 +571,7 @@ Walk: // Try to catch empty for wildcard supporting it. for _, wildcardNode := range matched.wildcards { for i, route := range wildcardNode.routes { - if route.catchEmpty && route.match(method, c) { + if route.pattern.optionalCatchAll && route.match(method, c) { if !lazy { *c.params = append(*c.params, "") } @@ -989,7 +989,7 @@ func (n *node) string(space int) string { sb.WriteByte('\n') sb.WriteString(strings.Repeat(" ", space+8)) sb.WriteString("=> ") - sb.WriteString(route.pattern) + sb.WriteString(route.pattern.str) if len(route.methods) > 0 { sb.WriteString(" [methods: ") diff --git a/node_test.go b/node_test.go index ae769f2c..f9dcd11a 100644 --- a/node_test.go +++ b/node_test.go @@ -440,7 +440,7 @@ func TestRouteWithParams(t *testing.T) { require.NotNilf(t, n, "route: %s", rte) require.NotNilf(t, n.routes[idx], "route: %s", rte) assert.False(t, tsr) - assert.Equal(t, rte, n.routes[idx].pattern) + assert.Equal(t, rte, n.routes[idx].pattern.str) } } @@ -1515,7 +1515,7 @@ func TestOverlappingRoute(t *testing.T) { require.NotNil(t, n) require.NotNil(t, n.routes[idx]) assert.False(t, tsr) - assert.Equal(t, tc.wantMatch, n.routes[idx].pattern) + assert.Equal(t, tc.wantMatch, n.routes[idx].pattern.str) c.route = n.routes[idx] *c.paramsKeys = c.route.params if len(tc.wantParams) == 0 { @@ -1533,7 +1533,7 @@ func TestOverlappingRoute(t *testing.T) { assert.False(t, tsr) c.route = n.routes[idx] assert.Empty(t, slices.Collect(c.Params())) - assert.Equal(t, tc.wantMatch, n.routes[idx].pattern) + assert.Equal(t, tc.wantMatch, n.routes[idx].pattern.str) }) } } @@ -2667,7 +2667,7 @@ func TestInfixWildcard(t *testing.T) { c := newTestContext(f) idx, n, tsr := lookupByPath(tree.patterns, http.MethodGet, tc.path, c, false, 0) require.NotNil(t, n) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -3038,7 +3038,7 @@ func TestInfixWildcardTsr(t *testing.T) { c := newTestContext(f) idx, n, tsr := lookupByPath(tree.patterns, http.MethodGet, tc.path, c, false, 0) require.NotNil(t, n) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -3133,7 +3133,7 @@ func TestTree_LookupTsr(t *testing.T) { if tc.want { require.NotNil(t, n) require.NotNil(t, n.routes[idx]) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) } }) } diff --git a/parser.go b/parser.go index eeb778eb..5d85d988 100644 --- a/parser.go +++ b/parser.go @@ -6,69 +6,18 @@ import ( "strings" ) -// PatternError is a structured error for invalid route patterns. It carries the reason, -// the offending position, and the pattern itself, enabling programmatic diagnostics. -type PatternError struct { - Pattern string // canonical form of the route pattern - Type string // hostname | path - Reason string // syntax | parameter | regexp | constraint - Hint string // hint - Start int // start offset of the offending segment - End int // end offset of the offending segment -} - -// Error returns a human-readable error message with a visual pointer to the offending segment. -func (e *PatternError) Error() string { - var sb strings.Builder - sb.WriteString("pattern: ") - if e.Type != "" { - sb.WriteString(e.Type) - sb.WriteString(": ") - } - sb.WriteString(e.Reason) - sb.WriteString(": ") - sb.WriteString(e.Hint) - if e.Pattern != "" { - sb.WriteByte('\n') - sb.WriteString(" ") - sb.WriteString(e.Pattern) - sb.WriteByte('\n') - sb.WriteString(" ") - for i := 0; i < e.Start; i++ { - sb.WriteByte(' ') - } - n := e.End - e.Start - if n <= 0 { - n = 1 - } - for i := 0; i < n; i++ { - sb.WriteByte('^') - } - } - return sb.String() -} - -func newPatternError(reason string, start, end int, msg string) *PatternError { - return &PatternError{ - Reason: reason, - Start: start, - End: end, - Hint: msg, - } -} - type pattern struct { - str string // canonical cleaned pattern + str string // canonical pattern tokens []token optionalCatchAll bool endHost int } -func (fox *Router) parsePattern(raw string) (*pattern, int, error) { +func (fox *Router) parsePattern(raw string) (pattern, int, error) { endHost := strings.IndexByte(raw, '/') if endHost == -1 { if len(raw) == 0 { - return nil, 0, &PatternError{ + return pattern{}, 0, &PatternError{ Pattern: raw, Reason: "syntax", Hint: "empty pattern", @@ -78,8 +27,7 @@ func (fox *Router) parsePattern(raw string) (*pattern, int, error) { endHost = len(raw) - 1 } - cleanedPath := CleanPath(raw[endHost:]) - canonicalPattern := raw[:endHost] + cleanedPath + path := raw[endHost:] var ( paramCount int @@ -90,27 +38,27 @@ func (fox *Router) parsePattern(raw string) (*pattern, int, error) { var pe *PatternError hostTokens, paramCount, pe = fox.parseHostname(raw[:endHost]) if pe != nil { - pe.Pattern = canonicalPattern + pe.Pattern = raw pe.Type = "hostname" - return nil, 0, pe + return pattern{}, 0, pe } } - pathTokens, optCatchAll, paramCount, pe := fox.parsePath(cleanedPath, paramCount) + pathTokens, optCatchAll, paramCount, pe := fox.parsePath(path, paramCount) if pe != nil { - pe.Pattern = canonicalPattern + pe.Pattern = raw pe.Type = "path" pe.Start += endHost pe.End += endHost - return nil, 0, pe + return pattern{}, 0, pe } tokens := make([]token, 0, len(hostTokens)+len(pathTokens)) tokens = append(tokens, hostTokens...) tokens = append(tokens, pathTokens...) - return &pattern{ - str: canonicalPattern, + return pattern{ + str: raw, tokens: tokens, endHost: endHost, optionalCatchAll: optCatchAll, @@ -335,6 +283,31 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, * if c < ' ' || c == 0x7f { return nil, false, 0, newPatternError("syntax", i, i+1, "illegal control character") } + if c == '/' && i > 0 && path[i-1] == '/' { + return nil, false, 0, newPatternError("syntax", i-1, i+1, "consecutive '/'") + } + if c == '.' && i > 0 && path[i-1] == '/' { + next := i + 1 + if next >= len(path) || path[next] == '/' { + // "/." at end or "/./" + end := next + if next < len(path) { + end = next + 1 + } + return nil, false, 0, newPatternError("syntax", i-1, end, "dot segment") + } + if path[next] == '.' { + afterDots := next + 1 + if afterDots >= len(path) || path[afterDots] == '/' { + // "/.." at end or "/../" + end := afterDots + if afterDots < len(path) { + end = afterDots + 1 + } + return nil, false, 0, newPatternError("syntax", i-1, end, "dot segment") + } + } + } sb.WriteByte(c) staticSinceWild++ i++ @@ -441,65 +414,9 @@ func braceIndex(s string, startLevel int) int { return -1 } -// parsedRoute is a compatibility bridge for callers that have not yet migrated to parsePattern. -// It translates the new pattern type back into the old field layout. -type parsedRoute struct { - token []token - paramCnt int - endHost int - startCatchAll int -} - -// parseRoute wraps parsePattern to provide the old parsedRoute return type. -// Callers should migrate to parsePattern directly. -func (fox *Router) parseRoute(url string) (parsedRoute, error) { - p, paramCnt, err := fox.parsePattern(url) - if err != nil { - return parsedRoute{}, err - } - - // Backward compatibility: callers store the original url as the route pattern, - // so we must reject paths that CleanPath would normalize (e.g. //, ./, ../). - // Once callers migrate to parsePattern (which returns the cleaned canonical form), - // this check can be removed. - if p.str != url { - endHost := strings.IndexByte(url, '/') - if endHost == -1 { - endHost = 0 - } - return parsedRoute{}, &PatternError{ - Pattern: url, - Type: "path", - Reason: "syntax", - Start: endHost, - End: len(url), - Hint: "not clean, use CleanPath", - } - } - - startCatchAll := 0 - if p.optionalCatchAll { - // Reconstruct the startCatchAll index for backwards compatibility. - // Callers use: startCatchAll > 0 && pattern[startCatchAll] == '*' - // So we need the index of '*' in the original pattern string. - startCatchAll = strings.LastIndexByte(url, '*') - } - - return parsedRoute{ - token: p.tokens, - paramCnt: paramCnt, - endHost: p.endHost, - startCatchAll: startCatchAll, - }, nil -} - -func cleanPattern(pattern string) string { - idx := strings.IndexByte(pattern, '/') - if idx == -1 { - if len(pattern) == 0 { - return pattern - } - return pattern + "/" +func normalizeHost(pattern string) string { + if pattern == "" || strings.IndexByte(pattern, slashDelim) >= 0 { + return pattern } - return pattern[:idx] + CleanPath(pattern[idx+1:]) + return pattern + "/" } diff --git a/parser_test.go b/parser_test.go index c649cb14..f0c5329e 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,6 +1,7 @@ package fox import ( + "fmt" "regexp" "slices" "strings" @@ -631,84 +632,44 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "path with double slash", - path: "/foo//bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar", false), - )), + name: "path with double slash", + path: "/foo//bar", }, { - name: "path with > double slash", - path: "/foo///bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar", false), - )), + name: "path with > double slash", + path: "/foo///bar", }, { - name: "path with slash dot slash", - path: "/foo/./bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar", false), - )), + name: "path with slash dot slash", + path: "/foo/./bar", }, { - name: "path with slash dot slash", - path: "/foo/././bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/bar", false), - )), + name: "path with slash dot slash", + path: "/foo/././bar", }, { - name: "path with double dot parent reference", - path: "/foo/../bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/bar", false), - )), + name: "path with double dot parent reference", + path: "/foo/../bar", }, { - name: "path with double dot parent reference", - path: "/foo/../../bar", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/bar", false), - )), + name: "path with double dot parent reference", + path: "/foo/../../bar", }, { - name: "path ending with slash dot", - path: "/foo/.", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/foo/", false), - )), + name: "path ending with slash dot", + path: "/foo/.", }, { - name: "path ending with slash double dot", - path: "/foo/..", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/", false), - )), + name: "path ending with slash double dot", + path: "/foo/..", }, { - name: "path ending with slash dot", - path: "/.", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/", false), - )), + name: "path ending with slash dot", + path: "/.", }, { - name: "path ending with slash double dot", - path: "/..", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("/", false), - )), + name: "path ending with slash double dot", + path: "/..", }, // Allowed dot and slash combinaison { @@ -1143,7 +1104,7 @@ func TestParsePattern(t *testing.T) { assert.Equal(t, tc.wantTokens, pat.tokens) assert.Equal(t, tc.optionalCatchAll, pat.optionalCatchAll) if err == nil { - assert.Equal(t, strings.IndexByte(cleanPattern(tc.path), '/'), pat.endHost) + assert.Equal(t, strings.IndexByte(normalizeHost(tc.path), '/'), pat.endHost) } }) } @@ -1241,6 +1202,372 @@ func TestPatternErrorPosition(t *testing.T) { wantEnd: 0, wantMsg: "empty pattern", }, + { + name: "hostname dash after dot", + pattern: "-a.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "illegal character after '.'", + }, + { + name: "hostname consecutive dots", + pattern: "a..com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 2, + wantEnd: 3, + wantMsg: "illegal consecutive '.'", + }, + { + name: "hostname label ends with dash", + pattern: "a-.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 1, + wantEnd: 2, + wantMsg: "label ends with '-'", + }, + { + name: "hostname label exceeds 63 chars at dot", + pattern: "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 0, + wantEnd: 64, + wantMsg: "label exceeds 63 characters", + }, + { + name: "hostname uppercase character", + pattern: "A.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "uppercase character in label", + }, + { + name: "hostname illegal character in label", + pattern: "a!.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 1, + wantEnd: 2, + wantMsg: "illegal character in label", + }, + { + name: "hostname trailing dash", + pattern: "a.com-/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "illegal trailing '-'", + }, + { + name: "hostname trailing dot", + pattern: "a.com./", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "illegal trailing '.'", + }, + { + name: "hostname all numeric", + pattern: "123/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 3, + wantMsg: "all numeric", + }, + { + name: "hostname trailing label exceeds 63 chars", + pattern: "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 4, + wantEnd: 68, + wantMsg: "label exceeds 63 characters", + }, + { + name: "hostname exceeds 253 characters", + pattern: "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", + wantType: "hostname", + wantReason: "constraint", + wantStart: 0, + wantEnd: 256, + wantMsg: "exceeds 253 characters", + }, + { + name: "hostname missing parameter after + delimiter", + pattern: "+a.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "missing parameter after delimiter", + }, + { + name: "hostname consecutive wildcard", + pattern: "+{a}.+{b}.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "consecutive wildcard", + }, + { + name: "hostname too many parameters", + pattern: "{a}.{b}.com/", + options: []GlobalOption{WithMaxRouteParams(1)}, + wantType: "hostname", + wantReason: "constraint", + wantStart: 4, + wantEnd: 7, + wantMsg: "too many parameters", + }, + { + name: "hostname illegal character after parameter", + pattern: "{a}b.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 3, + wantEnd: 4, + wantMsg: "illegal character after parameter", + }, + { + name: "hostname optional wildcard not allowed", + pattern: "a.*{any}/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 2, + wantEnd: 4, + wantMsg: "optional wildcard allowed only as suffix", + }, + { + name: "hostname bare star missing parameter", + pattern: "a.b*/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 3, + wantEnd: 4, + wantMsg: "missing parameter after delimiter", + }, + { + name: "path missing parameter after + delimiter", + pattern: "/foo/+bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 6, + wantMsg: "missing parameter after delimiter", + }, + { + name: "path consecutive wildcard", + pattern: "/+{a}/+{b}", + wantType: "path", + wantReason: "syntax", + wantStart: 6, + wantEnd: 7, + wantMsg: "consecutive wildcard", + }, + { + name: "path too many parameters", + pattern: "/foo/{a}/{b}", + options: []GlobalOption{WithMaxRouteParams(1)}, + wantType: "path", + wantReason: "constraint", + wantStart: 9, + wantEnd: 12, + wantMsg: "too many parameters", + }, + { + name: "path optional wildcard not as suffix", + pattern: "/foo/*{any}/bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 11, + wantMsg: "optional wildcard allowed only as suffix", + }, + { + name: "path illegal character after parameter", + pattern: "/foo/{a}b", + wantType: "path", + wantReason: "syntax", + wantStart: 8, + wantEnd: 9, + wantMsg: "illegal character after parameter", + }, + { + name: "path illegal control character", + pattern: "/foo\x01bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 5, + wantMsg: "illegal control character", + }, + { + name: "path consecutive slashes", + pattern: "/foo//bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 6, + wantMsg: "consecutive '/'", + }, + { + name: "path consecutive slashes with hostname", + pattern: "example.com/foo//bar", + wantType: "path", + wantReason: "syntax", + wantStart: 15, + wantEnd: 17, + wantMsg: "consecutive '/'", + }, + { + name: "path dot segment single dot mid", + pattern: "/foo/./bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 7, + wantMsg: "dot segment", + }, + { + name: "path dot segment single dot end", + pattern: "/foo/.", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 6, + wantMsg: "dot segment", + }, + { + name: "path dot segment double dot mid", + pattern: "/foo/../bar", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 8, + wantMsg: "dot segment", + }, + { + name: "path dot segment double dot end", + pattern: "/foo/..", + wantType: "path", + wantReason: "syntax", + wantStart: 4, + wantEnd: 7, + wantMsg: "dot segment", + }, + { + name: "path root single dot", + pattern: "/.", + wantType: "path", + wantReason: "syntax", + wantStart: 0, + wantEnd: 2, + wantMsg: "dot segment", + }, + { + name: "path root double dot", + pattern: "/..", + wantType: "path", + wantReason: "syntax", + wantStart: 0, + wantEnd: 3, + wantMsg: "dot segment", + }, + { + name: "unbalanced braces", + pattern: "/foo/{bar", + wantType: "path", + wantReason: "syntax", + wantStart: 5, + wantEnd: 9, + wantMsg: "unbalanced braces", + }, + { + name: "parameter key too large", + pattern: "/foo/{abcd}", + options: []GlobalOption{WithMaxRouteParamKeyBytes(3)}, + wantType: "path", + wantReason: "constraint", + wantStart: 6, + wantEnd: 10, + wantMsg: "key too large", + }, + { + name: "missing parameter name", + pattern: "/foo/{}", + wantType: "path", + wantReason: "parameter", + wantStart: 5, + wantEnd: 7, + wantMsg: "missing name", + }, + { + name: "illegal character in parameter name", + pattern: "/foo/{*bar}", + wantType: "path", + wantReason: "parameter", + wantStart: 6, + wantEnd: 7, + wantMsg: "illegal character in name", + }, + { + name: "regexp not allowed in optional wildcard", + pattern: "/foo/*{any:[A-z]+}", + wantType: "path", + wantReason: "regexp", + wantStart: 6, + wantEnd: 18, + wantMsg: "not allowed in optional wildcard", + }, + { + name: "regexp feature not enabled", + pattern: "/foo/{a:[A-z]+}", + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 14, + wantMsg: "feature not enabled", + }, + { + name: "regexp missing expression", + pattern: "/foo/{a:}", + options: []GlobalOption{AllowRegexpParam(true)}, + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 8, + wantMsg: "missing expression", + }, + { + name: "regexp compile error", + pattern: "/foo/{a:a{5,2}}", + options: []GlobalOption{AllowRegexpParam(true)}, + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 14, + wantMsg: "compile error", + }, + { + name: "regexp capture group not allowed", + pattern: "/foo/{a:(foo|bar)}", + options: []GlobalOption{AllowRegexpParam(true)}, + wantType: "path", + wantReason: "regexp", + wantStart: 8, + wantEnd: 17, + wantMsg: "capture group", + }, } for _, tc := range cases { @@ -1255,6 +1582,52 @@ func TestPatternErrorPosition(t *testing.T) { assert.Equal(t, tc.wantStart, pe.Start) assert.Equal(t, tc.wantEnd, pe.End) assert.Contains(t, pe.Error(), tc.wantMsg) + fmt.Println(err) + }) + } +} + +func TestNormalizeHost(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "empty string", + in: "", + want: "", + }, + { + name: "path only", + in: "/foo/bar", + want: "/foo/bar", + }, + { + name: "host with trailing slash", + in: "example.com/", + want: "example.com/", + }, + { + name: "host with path", + in: "example.com/foo", + want: "example.com/foo", + }, + { + name: "host only without slash", + in: "example.com", + want: "example.com/", + }, + { + name: "root slash only", + in: "/", + want: "/", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, normalizeHost(tc.in)) }) } } diff --git a/route.go b/route.go index aceabc5e..bfa4973d 100644 --- a/route.go +++ b/route.go @@ -14,17 +14,14 @@ type Route struct { hself HandlerFunc hall HandlerFunc annots map[any]any - pattern string + pattern pattern name string methods []string mws []middleware params []string - tokens []token matchers []Matcher - hostEnd int priority uint handleSlash TrailingSlashOption - catchEmpty bool } // Handle calls the handler with the provided [Context]. See also [Route.HandleMiddleware]. @@ -55,17 +52,17 @@ func (r *Route) Methods() iter.Seq[string] { // Pattern returns the registered route pattern. func (r *Route) Pattern() string { - return r.pattern + return r.pattern.str } // Hostname returns the hostname part of the registered pattern if any. func (r *Route) Hostname() string { - return r.pattern[:r.hostEnd] + return r.pattern.str[:r.pattern.endHost] } // Path returns the path part of the registered pattern. func (r *Route) Path() string { - return r.pattern[r.hostEnd:] + return r.pattern.str[r.pattern.endHost:] } // Name returns the name of this [Route]. @@ -201,7 +198,7 @@ func routef(sb *strings.Builder, route *Route, pad int, showName bool) { } sb.WriteString(" pattern:") - sb.WriteString(route.pattern) + sb.WriteString(route.pattern.str) if route.name != "" && showName { sb.WriteString(" name:") diff --git a/tree.go b/tree.go index b845e178..9cb2068a 100644 --- a/tree.go +++ b/tree.go @@ -258,13 +258,13 @@ func (t *tXn) deleteNameIn(root, n *node, search string) *node { func (t *tXn) insert(route *Route, mode insertMode) error { t.mode = mode - newRoot, err := t.insertTokens(nil, t.patterns, route.tokens, route) + newRoot, err := t.insertTokens(nil, t.patterns, route.pattern.tokens, route) if err != nil { return err } if newRoot != nil { t.patterns = newRoot - t.maxDepth = max(t.maxDepth, t.computePathDepth(newRoot, route.tokens)) + t.maxDepth = max(t.maxDepth, t.computePathDepth(newRoot, route.pattern.tokens)) t.maxParams = max(t.maxParams, len(route.params)) t.size++ if len(route.methods) > 0 && t.mode == modeInsert { @@ -308,7 +308,7 @@ func (t *tXn) insertTokens(p, n *node, tokens []token, route *Route) (*node, err // Since pattern matching precedes matcher evaluation, a conflict occurs when the exact path has no matchers (shadows all requests // to that pattern) or matchers equal to the catch-empty's (both match the same request). var conflicts []*Route - if route.catchEmpty && p != nil { + if route.pattern.optionalCatchAll && p != nil { for _, r := range p.routes { if (len(r.matchers) == 0 || r.matchersEqual(route.matchers)) && slicesutil.Overlap(r.methods, route.methods) { conflicts = append(conflicts, r) @@ -321,7 +321,7 @@ func (t *tXn) insertTokens(p, n *node, tokens []token, route *Route) (*node, err for _, wildcard := range n.wildcards { for _, r := range wildcard.routes { - if r.catchEmpty && (len(route.matchers) == 0 || route.matchersEqual(r.matchers)) && slicesutil.Overlap(route.methods, r.methods) { + if r.pattern.optionalCatchAll && (len(route.matchers) == 0 || route.matchersEqual(r.matchers)) && slicesutil.Overlap(route.methods, r.methods) { conflicts = append(conflicts, r) } } @@ -341,7 +341,7 @@ func (t *tXn) insertTokens(p, n *node, tokens []token, route *Route) (*node, err return nc, nil case modeUpdate: idx := slices.IndexFunc(n.routes, func(r *Route) bool { - return r.pattern == route.pattern && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) + return r.pattern.str == route.pattern.str && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) }) if idx == -1 { return nil, newRouteNotFoundError(route) @@ -606,7 +606,7 @@ func (t *tXn) insertWildcard(n *node, tk token, remaining []token, route *Route) // delete performs a recursive copy-on-write deletion. func (t *tXn) delete(route *Route) (*Route, bool) { - newRoot, oldRoute := t.deleteTokens(t.patterns, t.patterns, route.tokens, route) + newRoot, oldRoute := t.deleteTokens(t.patterns, t.patterns, route.pattern.tokens, route) if newRoot != nil { t.patterns = newRoot if !t.forked && len(route.methods) > 0 { @@ -636,7 +636,7 @@ func (t *tXn) deleteTokens(root, n *node, tokens []token, route *Route) (*node, } idx := slices.IndexFunc(n.routes, func(r *Route) bool { - return r.pattern == route.pattern && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) + return r.pattern.str == route.pattern.str && slices.Equal(r.methods, route.methods) && r.matchersEqual(route.matchers) }) if idx == -1 { return nil, nil diff --git a/tree_test.go b/tree_test.go index 0571129b..6df07d6b 100644 --- a/tree_test.go +++ b/tree_test.go @@ -1228,7 +1228,7 @@ func TestDomainLookup(t *testing.T) { c := newTestContext(f) idx, n, tsr := tree.lookup(http.MethodGet, tc.host, tc.path, c, false) require.NotNil(t, n) - assert.Equal(t, tc.wantPath, n.routes[idx].pattern) + assert.Equal(t, tc.wantPath, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -1499,7 +1499,7 @@ func TestMatchersLookup(t *testing.T) { c.req = req idx, n, tsr := tree.lookup(http.MethodGet, tc.host, c.Path(), c, false) require.NotNil(t, n) - assert.Equal(t, tc.wantPattern, n.routes[idx].pattern) + assert.Equal(t, tc.wantPattern, n.routes[idx].pattern.str) assert.Equal(t, tc.wantTsr, tsr) c.route = n.routes[idx] *c.paramsKeys = c.route.params @@ -1645,7 +1645,7 @@ func TestMatchersLookupWithPriority(t *testing.T) { c.req = req idx, n, _ := tree.lookup(http.MethodGet, "", c.Path(), c, false) require.NotNil(t, n) - assert.Equal(t, tc.wantPattern, n.routes[idx].pattern) + assert.Equal(t, tc.wantPattern, n.routes[idx].pattern.str) assert.Equal(t, tc.wantMatcher, n.routes[idx].matchers) c.route = n.routes[idx] *c.paramsKeys = c.route.params diff --git a/txn.go b/txn.go index 9a10e5a2..f3ca64f2 100644 --- a/txn.go +++ b/txn.go @@ -148,15 +148,13 @@ func (txn *Txn) Delete(methods []string, pattern string, opts ...MatcherOption) } } - parsed, err := txn.fox.parseRoute(pattern) + pat, _, err := txn.fox.parsePattern(pattern) if err != nil { return nil, err } rte := &Route{ - pattern: pattern, - hostEnd: parsed.endHost, - tokens: parsed.token, + pattern: pat, } for _, opt := range opts { @@ -250,7 +248,7 @@ func (txn *Txn) Route(methods []string, pattern string, matchers ...Matcher) *Ro return nil } idx := slices.IndexFunc(matched.routes, func(r *Route) bool { - return r.pattern == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) + return r.pattern.str == pattern && slicesutil.EqualUnsorted(r.methods, methods) && r.matchersEqual(matchers) }) if idx < 0 { return nil @@ -317,7 +315,7 @@ func (txn *Txn) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc *Con idx, n, tsr := txn.rootTxn.patterns.lookup(r.Method, r.Host, path, c, false) if n != nil { c.route = n.routes[idx] - c.pattern = c.route.pattern + c.pattern = c.route.pattern.str *c.paramsKeys = c.route.params return c.route, c, tsr } diff --git a/txn_test.go b/txn_test.go index ab0252d8..16d3042b 100644 --- a/txn_test.go +++ b/txn_test.go @@ -432,7 +432,7 @@ func TestUpdateWithName(t *testing.T) { WithHeaderMatcher("Authorization", "secret"), ) require.NoError(t, err) - assert.Equal(t, "/users/{name}", route.pattern) + assert.Equal(t, "/users/{name}", route.pattern.str) assert.Empty(t, route.name) assert.Nil(t, txn.Name("users_name")) txn.Commit() @@ -446,7 +446,7 @@ func TestUpdateWithName(t *testing.T) { WithName("foo"), ) require.NoError(t, err) - assert.Equal(t, "/users", route.pattern) + assert.Equal(t, "/users", route.pattern.str) assert.Equal(t, "foo", route.name) assert.NotNil(t, txn.Name("foo")) txn.Commit() @@ -462,7 +462,7 @@ func TestUpdateWithName(t *testing.T) { WithName("users"), ) require.NoError(t, err) - assert.Equal(t, "/users", route.pattern) + assert.Equal(t, "/users", route.pattern.str) assert.Equal(t, "users", route.name) assert.NotNil(t, txn.Name("users")) txn.Commit() @@ -478,7 +478,7 @@ func TestUpdateWithName(t *testing.T) { WithName("new_users"), ) require.NoError(t, err) - assert.Equal(t, "/users", route.pattern) + assert.Equal(t, "/users", route.pattern.str) assert.Equal(t, "new_users", route.name) assert.Nil(t, txn.Name("users")) assert.NotNil(t, txn.Name("new_users")) From 99a444879471f0fc9b538c2764c70159a350a4eb Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:27:41 +0200 Subject: [PATCH 05/11] perf(route): reorder Route struct fields for memory alignment --- route.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/route.go b/route.go index bfa4973d..6e252205 100644 --- a/route.go +++ b/route.go @@ -14,12 +14,12 @@ type Route struct { hself HandlerFunc hall HandlerFunc annots map[any]any - pattern pattern name string methods []string mws []middleware params []string matchers []Matcher + pattern pattern priority uint handleSlash TrailingSlashOption } From 5a4f080399223ce3bfa72a5d50e2bc39898e3b56 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:36:12 +0200 Subject: [PATCH 06/11] ci: bump minimum Go version to 1.26 --- .github/workflows/tests.yaml | 4 ++-- error.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4466b5a9..75d61b29 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: [ '>=1.24' ] + go: [ '>=1.26' ] steps: - name: Set up Go uses: actions/setup-go@v6 @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go: [ '>=1.24' ] + go: [ '>=1.26' ] steps: - name: Set up Go uses: actions/setup-go@v6 diff --git a/error.go b/error.go index 1c6bdd9e..43674321 100644 --- a/error.go +++ b/error.go @@ -98,7 +98,7 @@ func newRouteNotFoundError(route *Route) error { } type PatternError struct { - Pattern string // canonical form of the route pattern + Pattern string // provided pattern Type string // hostname | path Reason string // syntax | parameter | regexp | constraint Hint string // hint From 4a3178d17ccf7ac3e29ac39a8f4bc448e2312d2b Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:30:54 +0200 Subject: [PATCH 07/11] feat(error)!: add Unwrap to PatternError and remove redundant sentinel errors --- error.go | 9 +++-- fox.go | 13 +++++--- options.go | 8 ++--- parser.go | 29 ++++++++-------- parser_test.go | 89 ++++++++++++++++++++------------------------------ txn.go | 9 +++-- 6 files changed, 73 insertions(+), 84 deletions(-) diff --git a/error.go b/error.go index 43674321..113eae4b 100644 --- a/error.go +++ b/error.go @@ -19,10 +19,7 @@ var ( ErrNoClientIPResolver = errors.New("no client ip resolver") ErrReadOnlyTxn = errors.New("write on read-only transaction") ErrSettledTxn = errors.New("transaction settled") - ErrParamKeyTooLarge = errors.New("parameter key too large") - ErrTooManyParams = errors.New("too many params") ErrTooManyMatchers = errors.New("too many matchers") - ErrRegexpNotAllowed = errors.New("regexp not allowed") ErrInvalidConfig = errors.New("invalid config") ErrInvalidMatcher = errors.New("invalid matcher") ) @@ -98,6 +95,7 @@ func newRouteNotFoundError(route *Route) error { } type PatternError struct { + err error // wrapped error Pattern string // provided pattern Type string // hostname | path Reason string // syntax | parameter | regexp | constraint @@ -106,6 +104,11 @@ type PatternError struct { End int // end offset of the offending segment } +// Unwrap returns the underlying error, if any. +func (e *PatternError) Unwrap() error { + return e.err +} + // Error returns a human-readable error message with a visual pointer to the offending segment. func (e *PatternError) Error() string { var sb strings.Builder diff --git a/fox.go b/fox.go index ce437368..3482080b 100644 --- a/fox.go +++ b/fox.go @@ -194,9 +194,10 @@ func (fox *Router) MustAdd(methods []string, pattern string, handler HandlerFunc // Add registers a new route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteConflict]: If the route conflict with others. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // @@ -232,9 +233,10 @@ func (fox *Router) AddRoute(route *Route) error { // Update override an existing route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // @@ -272,8 +274,10 @@ func (fox *Router) UpdateRoute(route *Route) error { } // Delete deletes an existing route for the given methods, pattern and matchers. On success, it returns the deleted [Route]. +// If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid or the pattern is empty. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // // It's safe to delete a handler while the router is serving requests. This function is safe for concurrent use by @@ -397,7 +401,8 @@ func (fox *Router) Lookup(w ResponseWriter, r *http.Request) (route *Route, cc * // NewRoute create a new [Route], configured with the provided options. // If an error occurs, it returns one of the following: -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [*PatternError]: If the pattern syntax is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. func (fox *Router) NewRoute(methods []string, pattern string, handler HandlerFunc, opts ...RouteOption) (*Route, error) { diff --git a/options.go b/options.go index 34d02a96..1de5b42d 100644 --- a/options.go +++ b/options.go @@ -164,7 +164,7 @@ func WithHandleFixedPath(opt FixedPathOption) GlobalOption { } // WithMaxRouteParams set the maximum number of parameters allowed in a route. The default max is math.MaxUint8. -// Routes exceeding this limit will fail with an error that is ErrInvalidRoute and ErrTooManyParams. +// Routes exceeding this limit will fail with a [*PatternError]. func WithMaxRouteParams(max int) GlobalOption { return optionFunc(func(s sealedOption) error { s.router.maxParams = max @@ -173,8 +173,7 @@ func WithMaxRouteParams(max int) GlobalOption { } // WithMaxRouteParamKeyBytes set the maximum number of bytes allowed per parameter key in a route. The default max is -// math.MaxUint8. Routes with parameter keys exceeding this limit will fail with an error that Is ErrInvalidRoute and -// ErrParamKeyTooLarge. +// math.MaxUint8. Routes with parameter keys exceeding this limit will fail with a [*PatternError]. func WithMaxRouteParamKeyBytes(max int) GlobalOption { return optionFunc(func(s sealedOption) error { s.router.maxParamKeyBytes = max @@ -192,8 +191,7 @@ func WithMaxRouteMatchers(max int) GlobalOption { } // AllowRegexpParam enables support for regular expressions in route parameters. When enabled, parameters can include -// regex patterns (e.g., {id:[0-9]+}). When disabled, routes containing regex patterns will fail with and error that -// Is ErrInvalidRoute and ErrRegexpNotAllowed. +// regex patterns (e.g., {id:[0-9]+}). When disabled, routes containing regex patterns will fail with a [*PatternError]. func AllowRegexpParam(enable bool) GlobalOption { return optionFunc(func(s sealedOption) error { s.router.allowRegexp = enable diff --git a/parser.go b/parser.go index 5d85d988..8525da9f 100644 --- a/parser.go +++ b/parser.go @@ -17,14 +17,14 @@ func (fox *Router) parsePattern(raw string) (pattern, int, error) { endHost := strings.IndexByte(raw, '/') if endHost == -1 { if len(raw) == 0 { - return pattern{}, 0, &PatternError{ - Pattern: raw, - Reason: "syntax", - Hint: "empty pattern", - } + return pattern{}, 0, fmt.Errorf("%w: empty pattern", ErrInvalidRoute) + } + return pattern{}, 0, &PatternError{ + Pattern: raw, + Type: "hostname", + Reason: "syntax", + Hint: "missing trailing '/' after hostname", } - raw += "/" - endHost = len(raw) - 1 } path := raw[endHost:] @@ -387,7 +387,13 @@ func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *Pattern re, err := regexp.Compile("^" + rawRegex + "$") if err != nil { - return nil, newPatternError("regexp", 0, len(rawRegex), fmt.Sprintf("compile error: %s", err)) + return nil, &PatternError{ + Reason: "regexp", + Start: 0, + End: len(rawRegex), + Hint: "compile error: " + err.Error(), + err: err, + } } if re.NumSubexp() > 0 { return nil, newPatternError("regexp", 0, len(rawRegex), "capture group, use (?:...) instead") @@ -413,10 +419,3 @@ func braceIndex(s string, startLevel int) int { } return -1 } - -func normalizeHost(pattern string) string { - if pattern == "" || strings.IndexByte(pattern, slashDelim) >= 0 { - return pattern - } - return pattern + "/" -} diff --git a/parser_test.go b/parser_test.go index f0c5329e..78260a30 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,8 +1,10 @@ package fox import ( + "errors" "fmt" "regexp" + "regexp/syntax" "slices" "strings" "testing" @@ -234,13 +236,8 @@ func TestParsePattern(t *testing.T) { )), }, { - name: "missing a least one slash", - path: "foo.com", - wantN: 0, - wantTokens: slices.Collect(iterutil.SeqOf( - staticToken("foo.com", true), - staticToken("/", false), - )), + name: "missing trailing slash after hostname", + path: "foo.com", }, { name: "empty parameter", @@ -1104,7 +1101,7 @@ func TestParsePattern(t *testing.T) { assert.Equal(t, tc.wantTokens, pat.tokens) assert.Equal(t, tc.optionalCatchAll, pat.optionalCatchAll) if err == nil { - assert.Equal(t, strings.IndexByte(normalizeHost(tc.path), '/'), pat.endHost) + assert.Equal(t, strings.IndexByte(tc.path, '/'), pat.endHost) } }) } @@ -1195,12 +1192,13 @@ func TestPatternErrorPosition(t *testing.T) { wantMsg string }{ { - name: "empty raw pattern", - pattern: "", + name: "hostname missing trailing slash", + pattern: "foo.com", + wantType: "hostname", wantReason: "syntax", wantStart: 0, wantEnd: 0, - wantMsg: "empty pattern", + wantMsg: "missing trailing '/' after hostname", }, { name: "hostname dash after dot", @@ -1587,47 +1585,30 @@ func TestPatternErrorPosition(t *testing.T) { } } -func TestNormalizeHost(t *testing.T) { - cases := []struct { - name string - in string - want string - }{ - { - name: "empty string", - in: "", - want: "", - }, - { - name: "path only", - in: "/foo/bar", - want: "/foo/bar", - }, - { - name: "host with trailing slash", - in: "example.com/", - want: "example.com/", - }, - { - name: "host with path", - in: "example.com/foo", - want: "example.com/foo", - }, - { - name: "host only without slash", - in: "example.com", - want: "example.com/", - }, - { - name: "root slash only", - in: "/", - want: "/", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, normalizeHost(tc.in)) - }) - } +func TestPatternErrorUnwrap(t *testing.T) { + t.Run("regexp compile error wraps underlying error", func(t *testing.T) { + f := MustRouter(AllowRegexpParam(true)) + _, _, err := f.parsePattern("/foo/{a:a{5,2}}") + require.Error(t, err) + var pe *PatternError + require.ErrorAs(t, err, &pe) + var syntaxErr *syntax.Error + assert.ErrorAs(t, pe, &syntaxErr) + }) + t.Run("non-regexp error returns nil on unwrap", func(t *testing.T) { + f := MustRouter() + _, _, err := f.parsePattern("/foo//bar") + require.Error(t, err) + var pe *PatternError + require.ErrorAs(t, err, &pe) + assert.Nil(t, pe.Unwrap()) + }) + t.Run("empty pattern returns ErrInvalidRoute", func(t *testing.T) { + f := MustRouter() + _, _, err := f.parsePattern("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRoute) + var pe *PatternError + assert.False(t, errors.As(err, &pe)) + }) } diff --git a/txn.go b/txn.go index f3ca64f2..df58ea3f 100644 --- a/txn.go +++ b/txn.go @@ -19,9 +19,10 @@ type Txn struct { // Add registers a new route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteConflict]: If the route conflict with others. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // - [ErrReadOnlyTxn]: On write in a read-only transaction. @@ -71,9 +72,10 @@ func (txn *Txn) AddRoute(route *Route) error { // Update override an existing route for the given methods, pattern and matchers. On success, it returns the newly registered [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. // - [ErrRouteNameExist]: If the route name is already registered. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid, the handler is nil or the pattern is empty. // - [ErrInvalidConfig]: If the provided route options are invalid. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // - [ErrReadOnlyTxn]: On write in a read-only transaction. @@ -128,8 +130,9 @@ func (txn *Txn) UpdateRoute(route *Route) error { // Delete deletes an existing route for the given methods, pattern and matchers. On success, it returns the deleted [Route]. // If an error occurs, it returns one of the following: +// - [*PatternError]: If the pattern syntax is invalid. // - [ErrRouteNotFound]: If the route does not exist. -// - [ErrInvalidRoute]: If the provided method or pattern is invalid. +// - [ErrInvalidRoute]: If the method is invalid or the pattern is empty. // - [ErrInvalidMatcher]: If the provided matcher options are invalid. // - [ErrReadOnlyTxn]: On write in a read-only transaction. // From f83a6e8129c1328fe0bacfd9db67c53f4280d1d4 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:37:45 +0200 Subject: [PATCH 08/11] refactor(parser): inline hostnameValidator into parseHostname --- parser.go | 122 ++++++++++++++++++++++-------------------------------- 1 file changed, 50 insertions(+), 72 deletions(-) diff --git a/parser.go b/parser.go index 8525da9f..60d47fe2 100644 --- a/parser.go +++ b/parser.go @@ -65,81 +65,18 @@ func (fox *Router) parsePattern(raw string) (pattern, int, error) { }, paramCount, nil } -type hostnameValidator struct { - partLen int // Current label length in bytes. - totalLen int // Total hostname length in bytes. - last byte // Last static char for dot/dash adjacency rules. - nonNumeric bool // True once we've seen a letter, hyphen, or parameter. -} - -func (v *hostnameValidator) checkByte(c byte, pos int) *PatternError { - switch { - case 'a' <= c && c <= 'z' || c == '_': - v.nonNumeric = true - v.partLen++ - case '0' <= c && c <= '9': - v.partLen++ - case c == '-': - if v.last == '.' { - return newPatternError("syntax", pos, pos+1, "illegal character after '.'") - } - v.partLen++ - v.nonNumeric = true - case c == '.': - if v.last == '.' { - return newPatternError("syntax", pos, pos+1, "illegal consecutive '.'") - } - if v.last == '-' { - return newPatternError("syntax", pos-1, pos, "label ends with '-'") - } - if v.partLen > 63 { - return newPatternError("constraint", pos-v.partLen, pos, "label exceeds 63 characters") - } - v.totalLen += v.partLen + 1 // +1 counts the current dot. - v.partLen = 0 - case 'A' <= c && c <= 'Z': - return newPatternError("syntax", pos, pos+1, "uppercase character in label") - default: - return newPatternError("syntax", pos, pos+1, "illegal character in label") - } - v.last = c - return nil -} - -func (v *hostnameValidator) skipParam() { - v.last = 0 - v.nonNumeric = true -} - -func (v *hostnameValidator) postCheck(hostnameLen int) *PatternError { - v.totalLen += v.partLen - if v.last == '-' { - return newPatternError("syntax", hostnameLen-1, hostnameLen, "illegal trailing '-'") - } - if v.last == '.' { - return newPatternError("syntax", hostnameLen-1, hostnameLen, "illegal trailing '.'") - } - if !v.nonNumeric { - return newPatternError("syntax", 0, hostnameLen, "all numeric") - } - if v.partLen > 63 { - return newPatternError("constraint", hostnameLen-v.partLen, hostnameLen, "label exceeds 63 characters") - } - if v.totalLen > 253 { - return newPatternError("constraint", 0, hostnameLen, "exceeds 253 characters") - } - return nil -} - func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) { var sb strings.Builder sb.Grow(len(hostname)) tokens := make([]token, 0, 5) - validator := hostnameValidator{last: dotDelim} var ( paramCount int prevWild bool staticSinceWild int + partLen int + totalLen int + last = dotDelim + nonNumeric bool ) i := 0 @@ -184,7 +121,8 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) } tokens = append(tokens, token{typ: kind, value: name, regexp: re}) i += n - validator.skipParam() + last = 0 + nonNumeric = true if i < len(hostname) && hostname[i] != '.' { return nil, 0, newPatternError("syntax", i, i+1, "illegal character after parameter") } @@ -197,17 +135,57 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") default: - if pe := validator.checkByte(c, i); pe != nil { - return nil, 0, pe + switch { + case 'a' <= c && c <= 'z' || c == '_': + nonNumeric = true + partLen++ + case '0' <= c && c <= '9': + partLen++ + case c == '-': + if last == '.' { + return nil, 0, newPatternError("syntax", i, i+1, "illegal character after '.'") + } + partLen++ + nonNumeric = true + case c == '.': + if last == '.' { + return nil, 0, newPatternError("syntax", i, i+1, "illegal consecutive '.'") + } + if last == '-' { + return nil, 0, newPatternError("syntax", i-1, i, "label ends with '-'") + } + if partLen > 63 { + return nil, 0, newPatternError("constraint", i-partLen, i, "label exceeds 63 characters") + } + totalLen += partLen + 1 // +1 counts the current dot. + partLen = 0 + case 'A' <= c && c <= 'Z': + return nil, 0, newPatternError("syntax", i, i+1, "uppercase character in label") + default: + return nil, 0, newPatternError("syntax", i, i+1, "illegal character in label") } + last = c sb.WriteByte(c) staticSinceWild++ i++ } } - if pe := validator.postCheck(len(hostname)); pe != nil { - return nil, 0, pe + totalLen += partLen + if last == '-' { + return nil, 0, newPatternError("syntax", len(hostname)-1, len(hostname), "illegal trailing '-'") + } + if last == '.' { + return nil, 0, newPatternError("syntax", len(hostname)-1, len(hostname), "illegal trailing '.'") + } + if !nonNumeric { + return nil, 0, newPatternError("syntax", 0, len(hostname), "all numeric") + } + if partLen > 63 { + return nil, 0, newPatternError("constraint", len(hostname)-partLen, len(hostname), "label exceeds 63 characters") + } + if totalLen > 253 { + return nil, 0, newPatternError("constraint", 0, len(hostname), "exceeds 253 characters") } if sb.Len() > 0 { From ad440b0de7f8534657e2df33b79a54fe8b5586f0 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:29:17 +0200 Subject: [PATCH 09/11] fix(parser): improve error position accuracy for pattern diagnostics --- parser.go | 31 +++++++++++++--- parser_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 116 insertions(+), 12 deletions(-) diff --git a/parser.go b/parser.go index 60d47fe2..ee35b51b 100644 --- a/parser.go +++ b/parser.go @@ -24,6 +24,8 @@ func (fox *Router) parsePattern(raw string) (pattern, int, error) { Type: "hostname", Reason: "syntax", Hint: "missing trailing '/' after hostname", + Start: len(raw), + End: len(raw), } } @@ -97,7 +99,11 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") } if prevWild && staticSinceWild <= 1 { - return nil, 0, newPatternError("syntax", i-1, i, "consecutive wildcard") + paramEnd := len(hostname) + if idx := braceIndex(hostname[i+1:], 1); idx >= 0 { + paramEnd = i + 1 + idx + 1 + } + return nil, 0, newPatternError("syntax", i-1, paramEnd, "consecutive wildcard") } } name, re, n, pe := fox.parseBrace(hostname[i:], dotDelim, false) @@ -130,7 +136,11 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) case '*': i++ if i < len(hostname) && hostname[i] == '{' { - return nil, 0, newPatternError("syntax", i-1, i+1, "optional wildcard allowed only as suffix") + paramEnd := len(hostname) + if idx := braceIndex(hostname[i+1:], 1); idx >= 0 { + paramEnd = i + 1 + idx + 1 + } + return nil, 0, newPatternError("syntax", i-1, paramEnd, "optional wildcard allowed only as suffix") } return nil, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") @@ -143,13 +153,19 @@ func (fox *Router) parseHostname(hostname string) ([]token, int, *PatternError) partLen++ case c == '-': if last == '.' { + if i == 0 { + return nil, 0, newPatternError("syntax", i, i+1, "label starts with '-'") + } return nil, 0, newPatternError("syntax", i, i+1, "illegal character after '.'") } partLen++ nonNumeric = true case c == '.': if last == '.' { - return nil, 0, newPatternError("syntax", i, i+1, "illegal consecutive '.'") + if i == 0 { + return nil, 0, newPatternError("syntax", i, i+1, "label starts with '.'") + } + return nil, 0, newPatternError("syntax", i-1, i+1, "illegal consecutive '.'") } if last == '-' { return nil, 0, newPatternError("syntax", i-1, i, "label ends with '-'") @@ -223,13 +239,20 @@ func (fox *Router) parsePath(path string, paramCount int) ([]token, bool, int, * return nil, false, 0, newPatternError("syntax", i-1, i, "missing parameter after delimiter") } if prevWild && staticSinceWild <= 1 { - return nil, false, 0, newPatternError("syntax", i-1, i, "consecutive wildcard") + paramEnd := len(path) + if idx := braceIndex(path[i+1:], 1); idx >= 0 { + paramEnd = i + 1 + idx + 1 + } + return nil, false, 0, newPatternError("syntax", i-1, paramEnd, "consecutive wildcard") } } name, re, n, pe := fox.parseBrace(path[i:], slashDelim, isOpt) if pe != nil { pe.Start += i pe.End += i + if isOpt && pe.Reason == "regexp" { + pe.Start = paramStart + } return nil, false, 0, pe } paramCount++ diff --git a/parser_test.go b/parser_test.go index 78260a30..2d69023b 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1180,6 +1180,69 @@ func TestParsePatternParamsConstraint(t *testing.T) { }) } +func TestPatternErrorPositionDump(t *testing.T) { + cases := []struct { + name string + pattern string + options []GlobalOption + }{ + {"hostname missing trailing slash", "foo.com", nil}, + {"hostname label starts with dash", "-a.com/", nil}, + {"hostname label starts with dot", ".a.com/", nil}, + {"hostname dash after dot", "a.-b.com/", nil}, + {"hostname consecutive dots", "a..com/", nil}, + {"hostname label ends with dash", "a-.com/", nil}, + {"hostname label exceeds 63 chars at dot", "uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu.com/", nil}, + {"hostname uppercase character", "A.com/", nil}, + {"hostname illegal character in label", "a!.com/", nil}, + {"hostname trailing dash", "a.com-/", nil}, + {"hostname trailing dot", "a.com./", nil}, + {"hostname all numeric", "123/", nil}, + {"hostname trailing label exceeds 63 chars", "a.b.uj01dowf1x5lk6lysurbr0lgbdd1wfyw8sm8q17mnt0i9igk774vcwr5rly5dguu/", nil}, + {"hostname exceeds 253 characters", "a.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjx.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr.78fayzyiqkt3hh2mquv9szfroeexx8qztscu3oudoyfarjl6jmdyxk2cefvzjxr/", nil}, + {"hostname missing parameter after + delimiter", "+a.com/", nil}, + {"hostname consecutive wildcard", "+{a}.+{b}.com/", nil}, + {"hostname too many parameters", "{a}.{b}.com/", []GlobalOption{WithMaxRouteParams(1)}}, + {"hostname illegal character after parameter", "{a}b.com/", nil}, + {"hostname optional wildcard not allowed", "a.*{any}/", nil}, + {"hostname bare star missing parameter", "a.b*/", nil}, + {"path missing parameter after + delimiter", "/foo/+bar", nil}, + {"path consecutive wildcard", "/+{a}/+{b}", nil}, + {"path too many parameters", "/foo/{a}/{b}", []GlobalOption{WithMaxRouteParams(1)}}, + {"path optional wildcard not as suffix", "/foo/*{any}/bar", nil}, + {"path illegal character after parameter", "/foo/{a}b", nil}, + {"path illegal control character", "/foo\x01bar", nil}, + {"path consecutive slashes", "/foo//bar", nil}, + {"path consecutive slashes with hostname", "example.com/foo//bar", nil}, + {"path dot segment single dot mid", "/foo/./bar", nil}, + {"path dot segment single dot end", "/foo/.", nil}, + {"path dot segment double dot mid", "/foo/../bar", nil}, + {"path dot segment double dot end", "/foo/..", nil}, + {"path root single dot", "/.", nil}, + {"path root double dot", "/..", nil}, + {"unbalanced braces", "/foo/{bar", nil}, + {"parameter key too large", "/foo/{abcd}", []GlobalOption{WithMaxRouteParamKeyBytes(3)}}, + {"missing parameter name", "/foo/{}", nil}, + {"illegal character in parameter name", "/foo/{*bar}", nil}, + {"regexp not allowed in optional wildcard", "/foo/*{any:[A-z]+}", nil}, + {"regexp feature not enabled", "/foo/{a:[A-z]+}", nil}, + {"regexp missing expression", "/foo/{a:}", []GlobalOption{AllowRegexpParam(true)}}, + {"regexp compile error", "/foo/{a:a{5,2}}", []GlobalOption{AllowRegexpParam(true)}}, + {"regexp capture group not allowed", "/foo/{a:(foo|bar)}", []GlobalOption{AllowRegexpParam(true)}}, + } + + for _, tc := range cases { + f := MustRouter(tc.options...) + _, _, err := f.parsePattern(tc.pattern) + var pe *PatternError + if errors.As(err, &pe) { + fmt.Printf("%-50s start=%-3d end=%-3d\n%s\n\n", tc.name, pe.Start, pe.End, pe.Error()) + } else { + fmt.Printf("%-50s %v\n\n", tc.name, err) + } + } +} + func TestPatternErrorPosition(t *testing.T) { cases := []struct { name string @@ -1196,17 +1259,35 @@ func TestPatternErrorPosition(t *testing.T) { pattern: "foo.com", wantType: "hostname", wantReason: "syntax", - wantStart: 0, - wantEnd: 0, + wantStart: 7, + wantEnd: 7, wantMsg: "missing trailing '/' after hostname", }, { - name: "hostname dash after dot", + name: "hostname label starts with dash", pattern: "-a.com/", wantType: "hostname", wantReason: "syntax", wantStart: 0, wantEnd: 1, + wantMsg: "label starts with '-'", + }, + { + name: "hostname label starts with dot", + pattern: ".a.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 0, + wantEnd: 1, + wantMsg: "label starts with '.'", + }, + { + name: "hostname dash after dot", + pattern: "a.-b.com/", + wantType: "hostname", + wantReason: "syntax", + wantStart: 2, + wantEnd: 3, wantMsg: "illegal character after '.'", }, { @@ -1214,7 +1295,7 @@ func TestPatternErrorPosition(t *testing.T) { pattern: "a..com/", wantType: "hostname", wantReason: "syntax", - wantStart: 2, + wantStart: 1, wantEnd: 3, wantMsg: "illegal consecutive '.'", }, @@ -1314,7 +1395,7 @@ func TestPatternErrorPosition(t *testing.T) { wantType: "hostname", wantReason: "syntax", wantStart: 5, - wantEnd: 6, + wantEnd: 9, wantMsg: "consecutive wildcard", }, { @@ -1342,7 +1423,7 @@ func TestPatternErrorPosition(t *testing.T) { wantType: "hostname", wantReason: "syntax", wantStart: 2, - wantEnd: 4, + wantEnd: 8, wantMsg: "optional wildcard allowed only as suffix", }, { @@ -1369,7 +1450,7 @@ func TestPatternErrorPosition(t *testing.T) { wantType: "path", wantReason: "syntax", wantStart: 6, - wantEnd: 7, + wantEnd: 10, wantMsg: "consecutive wildcard", }, { @@ -1523,7 +1604,7 @@ func TestPatternErrorPosition(t *testing.T) { pattern: "/foo/*{any:[A-z]+}", wantType: "path", wantReason: "regexp", - wantStart: 6, + wantStart: 5, wantEnd: 18, wantMsg: "not allowed in optional wildcard", }, From 0b7b9b44720e4bf17877f8b9ce1b4edbfecb9952 Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:31:58 +0200 Subject: [PATCH 10/11] build: update golang.org/x dependencies --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index fe5f4033..45485714 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.26.0 require ( github.com/google/gofuzz v1.2.0 github.com/stretchr/testify v1.11.1 - golang.org/x/net v0.50.0 - golang.org/x/sys v0.41.0 + golang.org/x/net v0.53.0 + golang.org/x/sys v0.43.0 ) require ( @@ -14,7 +14,7 @@ require ( github.com/kr/pretty v0.3.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.36.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 4dce4599..1ddfd978 100644 --- a/go.sum +++ b/go.sum @@ -18,12 +18,12 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 99ef866a33c9b23fe912cd5c41530b94940f4a8d Mon Sep 17 00:00:00 2001 From: tigerwill90 <26261762+tigerwill90@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:44:07 +0200 Subject: [PATCH 11/11] fix(parser): use original regexp error as hint instead of prefixed message --- parser.go | 2 +- parser_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/parser.go b/parser.go index ee35b51b..b5372bd3 100644 --- a/parser.go +++ b/parser.go @@ -392,7 +392,7 @@ func (fox *Router) compileParamRegexp(rawRegex string) (*regexp.Regexp, *Pattern Reason: "regexp", Start: 0, End: len(rawRegex), - Hint: "compile error: " + err.Error(), + Hint: err.Error(), err: err, } } diff --git a/parser_test.go b/parser_test.go index 2d69023b..8925f686 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1635,7 +1635,7 @@ func TestPatternErrorPosition(t *testing.T) { wantReason: "regexp", wantStart: 8, wantEnd: 14, - wantMsg: "compile error", + wantMsg: "error parsing regexp", }, { name: "regexp capture group not allowed",