// ⚡️ Fiber is an Express inspired web framework written in Go with ☕️ // 📝 GitHub Repository: https://github.com/gofiber/fiber // 📌 API Documentation: https://docs.gofiber.io package fiber import ( "fmt" "net/http" "net/http/httptest" "reflect" "regexp" "strings" "testing" "github.com/stretchr/testify/require" ) // go test -race -run Test_Path_parseRoute func Test_Path_parseRoute(t *testing.T) { t.Parallel() var rp routeParser rp = parseRoute("/shop/product/::filter/color::color/size::size", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/shop/product/:", Length: 15}, {IsParam: true, ParamName: "filter", ComparePart: "/color:", PartCount: 1}, {Const: "/color:", Length: 7}, {IsParam: true, ParamName: "color", ComparePart: "/size:", PartCount: 1}, {Const: "/size:", Length: 6}, {IsParam: true, ParamName: "size", IsLast: true}, }, params: []string{"filter", "color", "size"}, minSlashes: 5, maxSlashes: 5, maxBounded: true, probe: newConstProbe("/color:", 15, 0), }, rp) rp = parseRoute("/api/v1/:param/abc/*", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/api/v1/", Length: 8}, {IsParam: true, ParamName: "param", ComparePart: "/abc", PartCount: 1}, {Const: "/abc/", Length: 5, HasOptionalSlash: true}, {IsParam: true, ParamName: "*1", IsGreedy: true, IsOptional: true, IsLast: true}, }, params: []string{"param", "*1"}, wildCardCount: 1, minSlashes: 4, probe: newConstProbe("/abc", 8, 0), }, rp) rp = parseRoute("/v1/some/resource/name\\:customVerb", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/v1/some/resource/name:customVerb", Length: 33, IsLast: true}, }, params: nil, minSlashes: 4, maxSlashes: 4, maxBounded: true, }, rp) rp = parseRoute("/v1/some/resource/:name\\:customVerb", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/v1/some/resource/", Length: 18}, {IsParam: true, ParamName: "name", ComparePart: ":customVerb", PartCount: 1}, {Const: ":customVerb", Length: 11, IsLast: true}, }, params: []string{"name"}, minSlashes: 4, maxSlashes: 4, maxBounded: true, }, rp) // heavy test with escaped characters rp = parseRoute("/v1/some/resource/name\\\\:customVerb?\\?/:param/*", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/v1/some/resource/name:customVerb??/", Length: 36}, {IsParam: true, ParamName: "param", ComparePart: "/", PartCount: 1}, {Const: "/", Length: 1, HasOptionalSlash: true}, {IsParam: true, ParamName: "*1", IsGreedy: true, IsOptional: true, IsLast: true}, }, params: []string{"param", "*1"}, wildCardCount: 1, minSlashes: 5, }, rp) rp = parseRoute("/api/*/:param/:param2", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/api/", Length: 5, HasOptionalSlash: true}, {IsParam: true, ParamName: "*1", IsGreedy: true, IsOptional: true, ComparePart: "/", PartCount: 2}, {Const: "/", Length: 1}, {IsParam: true, ParamName: "param", ComparePart: "/", PartCount: 1}, {Const: "/", Length: 1}, {IsParam: true, ParamName: "param2", IsLast: true}, }, params: []string{"*1", "param", "param2"}, wildCardCount: 1, minSlashes: 3, }, rp) rp = parseRoute("/test:optional?:optional2?", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/test", Length: 5}, {IsParam: true, ParamName: "optional", IsOptional: true, Length: 1}, {IsParam: true, ParamName: "optional2", IsOptional: true, IsLast: true}, }, params: []string{"optional", "optional2"}, minSlashes: 1, }, rp) rp = parseRoute("/config/+.json", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/config/", Length: 8}, {IsParam: true, ParamName: "+1", IsGreedy: true, IsOptional: false, ComparePart: ".json", PartCount: 1}, {Const: ".json", Length: 5, IsLast: true}, }, params: []string{"+1"}, plusCount: 1, minSlashes: 2, }, rp) rp = parseRoute("/api/:day.:month?.:year?", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/api/", Length: 5}, {IsParam: true, ParamName: "day", IsOptional: false, ComparePart: ".", PartCount: 2}, {Const: ".", Length: 1}, {IsParam: true, ParamName: "month", IsOptional: true, ComparePart: ".", PartCount: 1}, {Const: ".", Length: 1}, {IsParam: true, ParamName: "year", IsOptional: true, IsLast: true}, }, params: []string{"day", "month", "year"}, minSlashes: 2, }, rp) rp = parseRoute("/*v1*/proxy", regexp.MustCompile) require.Equal(t, routeParser{ segs: []*routeSegment{ {Const: "/", Length: 1, HasOptionalSlash: true}, {IsParam: true, ParamName: "*1", IsGreedy: true, IsOptional: true, ComparePart: "v1", PartCount: 1}, {Const: "v1", Length: 2}, {IsParam: true, ParamName: "*2", IsGreedy: true, IsOptional: true, ComparePart: "/proxy", PartCount: 1}, {Const: "/proxy", Length: 6, IsLast: true}, }, params: []string{"*1", "*2"}, wildCardCount: 2, minSlashes: 1, }, rp) } // go test -race -run Test_Path_matchParams func Test_Path_matchParams(t *testing.T) { t.Parallel() var ctxParams [maxParams]string testCaseFn := func(testCollection routeCaseCollection) { parser := parseRoute(testCollection.pattern, regexp.MustCompile) for _, c := range testCollection.testCases { match := parser.getMatch(c.url, c.url, &ctxParams, c.partialCheck) require.Equal(t, c.match, match, "route: '%s', url: '%s'", testCollection.pattern, c.url) if match && len(c.params) > 0 { require.Equal(t, c.params[0:len(c.params)], ctxParams[0:len(c.params)], "route: '%s', url: '%s'", testCollection.pattern, c.url) } } } for _, testCaseCollection := range routeTestCases { testCaseFn(testCaseCollection) } } // Test_Path_ParameterEndChars_MatchesRouteGrammar pins the shared set against // the delimiter constants it is built from but no longer names, so widening the // route grammar has to reach the client's copy too (#4635). func Test_Path_ParameterEndChars_MatchesRouteGrammar(t *testing.T) { t.Parallel() var want [256]bool want[optionalParam] = true want[paramStarterChar] = true want[escapeChar] = true for _, delimiter := range routeDelimiter { want[delimiter] = true } require.Equal(t, want, parameterEndChars) } // go test -race -run Test_RouteParser_SlashBounds func Test_RouteParser_SlashBounds(t *testing.T) { t.Parallel() testCases := []struct { pattern string minSlashes int32 maxSlashes int32 maxBounded bool }{ {pattern: "/", minSlashes: 0, maxSlashes: 1, maxBounded: true}, {pattern: "/api/v1/const", minSlashes: 3, maxSlashes: 3, maxBounded: true}, {pattern: "/api/v1/:param", minSlashes: 3, maxSlashes: 3, maxBounded: true}, {pattern: "/api/v1/:param?", minSlashes: 2, maxSlashes: 3, maxBounded: true}, {pattern: "/api/v1/:param/fixedEnd", minSlashes: 4, maxSlashes: 4, maxBounded: true}, // greedy parameters match across '/', so no upper bound {pattern: "/api/*", minSlashes: 1}, {pattern: "/api/+", minSlashes: 2}, // optional segments drop their leading slashes from the lower bound {pattern: "/api/:day/:month?/:year?", minSlashes: 2, maxSlashes: 4, maxBounded: true}, // single-byte compare parts have no slash guard in findParamLen, // so such parameters can swallow '/' and the max is unbounded {pattern: "/api/v1/:a-:b", minSlashes: 3}, {pattern: "/api/:day.:month?.:year?", minSlashes: 2}, // successive parameters consume one byte each, possibly a '/' {pattern: "/test:sign:param", minSlashes: 1}, // multi-byte compare parts reject slashes, so bounds stay exact {pattern: "/shop/product/::filter/color::color/size::size", minSlashes: 5, maxSlashes: 5, maxBounded: true}, {pattern: "/v1/some/resource/name\\:customVerb", minSlashes: 4, maxSlashes: 4, maxBounded: true}, } for _, tc := range testCases { parser := parseRoute(tc.pattern, regexp.MustCompile) require.Equal(t, tc.minSlashes, parser.minSlashes, "route: '%s' minSlashes", tc.pattern) require.Equal(t, tc.maxSlashes, parser.maxSlashes, "route: '%s' maxSlashes", tc.pattern) require.Equal(t, tc.maxBounded, parser.maxBounded, "route: '%s' maxBounded", tc.pattern) } } // Test_Route_Match_SlashBoundsDifferential generatively proves the slash-count // quick-reject and the probe compare in Route.match are transparent: for every // generated pattern and path, the filtered Route.match must agree with a raw // getMatch. It needs no hand-authored expectations, so it also binds pattern // shapes nobody thought to add to the fixture. // go test -race -run Test_Route_Match_SlashBoundsDifferential func Test_Route_Match_SlashBoundsDifferential(t *testing.T) { t.Parallel() segments := []string{ "/api", "/foo/", "/:a", "/:b?", "/*", "/+", "/:a-:b", "/:f.:e?", ":tail", "/::c", "/:x:y", "/name\\:verb", "/:p/fixed", // probe shapes "/:a/:b/fixed", "/:p/verylongconstantsegment", "/x/:y/z/", "/:q/a/b/c", "/:a?/fixed", "/:x:y/fixed", "/*/fixed", "/:s/x", } // patterns: every single segment and every ordered pair patterns := make([]string, 0, len(segments)*(len(segments)+1)) for _, s1 := range segments { patterns = append(patterns, s1) for _, s2 := range segments { patterns = append(patterns, s1+s2) } } pieces := []string{ "", "/a", "/a/b", "/a-b", "/a.b", "/x/y-z", "/enti/ty-x", "/a/", "/:c", "/api", "/api/foo/bar", "/name:verb", "/fixed", "/a/fixed", "/a/b/fixed", "/fixedx", "/verylongconstantsegment", "/verylongconstantsegmentx", "/x/1/z", "/x/1/z/", "/1/a/b/c", "//fixed", } // paths: every ordered pair of pieces (skipping the empty result) paths := make([]string, 0, len(pieces)*len(pieces)) for _, p1 := range pieces { for _, p2 := range pieces { if p1+p2 == "" { continue } paths = append(paths, p1+p2) } } for _, pattern := range patterns { parser := parseRoute(pattern, regexp.MustCompile) if len(parser.params) == 0 { // non-parametric routes never take the filtered getMatch path continue } route := &Route{ routeParser: parser, Params: parser.params, path: pattern, Path: pattern, } for _, use := range []bool{false, true} { route.use = use for _, path := range paths { // 0 is the "count unknown" state and must bypass the filters for _, pathSlashes := range []int{strings.Count(path, "/"), 0} { var filteredParams, rawParams [maxParams]string filtered := route.match(path, path, &filteredParams, pathSlashes) raw := parser.getMatch(path, path, &rawParams, use) if filtered != raw { t.Fatalf("filter changed outcome: pattern %q, path %q, use %v, pathSlashes %d: filtered=%v raw=%v", pattern, path, use, pathSlashes, filtered, raw) } if raw { require.Equal(t, rawParams[:len(parser.params)], filteredParams[:len(parser.params)], "params diverged: pattern %q, path %q, use %v", pattern, path, use) } } } } } } // Test_Route_Match_SlashBoundsConsistency proves the slash-count quick-reject in // Route.match never flips the outcome of the exhaustive matching fixture. Only // parametric patterns are checked, since only they take the filtered path. // go test -race -run Test_Route_Match_SlashBoundsConsistency func Test_Route_Match_SlashBoundsConsistency(t *testing.T) { t.Parallel() for _, testCollection := range routeTestCases { parser := parseRoute(testCollection.pattern, regexp.MustCompile) if len(parser.params) == 0 { continue } route := &Route{ routeParser: parser, Params: parser.params, path: testCollection.pattern, Path: testCollection.pattern, } for _, c := range testCollection.testCases { route.use = c.partialCheck var ctxParams [maxParams]string match := route.match(c.url, c.url, &ctxParams, strings.Count(c.url, "/")) require.Equal(t, c.match, match, "route: '%s', url: '%s'", testCollection.pattern, c.url) if match && len(c.params) > 0 { require.Equal(t, c.params[0:len(c.params)], ctxParams[0:len(c.params)], "route: '%s', url: '%s'", testCollection.pattern, c.url) } } } } // go test -race -run Test_RoutePatternMatch func Test_RoutePatternMatch(t *testing.T) { t.Parallel() testCaseFn := func(pattern string, cases []routeTestCase) { for _, c := range cases { // skip all cases for partial checks if c.partialCheck { continue } match := RoutePatternMatch(c.url, pattern) require.Equal(t, c.match, match, "route: '%s', url: '%s'", pattern, c.url) } } for _, testCase := range routeTestCases { testCaseFn(testCase.pattern, testCase.testCases) } } func TestHasPartialMatchBoundary(t *testing.T) { t.Parallel() testCases := []struct { name string path string matchedLength int expected bool }{ { name: "negative length", path: "/demo", matchedLength: -1, expected: false, }, { name: "greater than length", path: "/demo", matchedLength: 6, expected: false, }, { name: "exact match", path: "/demo", matchedLength: len("/demo"), expected: true, }, { name: "zero length", path: "/demo", matchedLength: 0, expected: false, }, { name: "previous rune slash", path: "/demo/child", matchedLength: len("/demo/"), expected: true, }, { name: "next rune slash", path: "/demo/child", matchedLength: len("/demo"), expected: true, }, { name: "no boundary", path: "/demo/child", matchedLength: len("/dem"), expected: false, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { t.Parallel() require.Equal(t, testCase.expected, hasPartialMatchBoundary(testCase.path, testCase.matchedLength)) }) } } func Test_Utils_GetTrimmedParam(t *testing.T) { t.Parallel() res := GetTrimmedParam("") require.Empty(t, res) res = GetTrimmedParam("*") require.Equal(t, "*", res) res = GetTrimmedParam(":param") require.Equal(t, "param", res) res = GetTrimmedParam(":param1?") require.Equal(t, "param1", res) res = GetTrimmedParam("noParam") require.Equal(t, "noParam", res) } func Test_Utils_RemoveEscapeChar(t *testing.T) { t.Parallel() res := RemoveEscapeChar(":test\\:bla") require.Equal(t, ":test:bla", res) res = RemoveEscapeChar("\\abc") require.Equal(t, "abc", res) res = RemoveEscapeChar("noEscapeChar") require.Equal(t, "noEscapeChar", res) } func Test_ConstraintCheckConstraint_InvalidMetadata(t *testing.T) { t.Parallel() testCases := []struct { name string param string constraint Constraint }{ { name: "minLen invalid metadata", constraint: *newConstraint(minLenConstraintType{}, ConstraintMinLen, []string{"abc"}), param: "abcd", }, { name: "maxLen invalid metadata", constraint: *newConstraint(maxLenConstraintType{}, ConstraintMaxLen, []string{"abc"}), param: "abcd", }, { name: "len invalid metadata", constraint: *newConstraint(lenConstraintType{}, ConstraintLen, []string{"abc"}), param: "abcd", }, { name: "betweenLen invalid first metadata", constraint: *newConstraint(betweenLenConstraintType{}, ConstraintBetweenLen, []string{"abc", "5"}), param: "abcd", }, { name: "betweenLen invalid second metadata", constraint: *newConstraint(betweenLenConstraintType{}, ConstraintBetweenLen, []string{"1", "abc"}), param: "abcd", }, { name: "min invalid metadata", constraint: *newConstraint(minConstraintType{}, ConstraintMin, []string{"abc"}), param: "10", }, { name: "max invalid metadata", constraint: *newConstraint(maxConstraintType{}, ConstraintMax, []string{"abc"}), param: "10", }, { name: "range invalid first metadata", constraint: *newConstraint(rangeConstraintType{}, ConstraintRange, []string{"abc", "10"}), param: "7", }, { name: "range invalid second metadata", constraint: *newConstraint(rangeConstraintType{}, ConstraintRange, []string{"1", "abc"}), param: "7", }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { t.Parallel() require.False(t, testCase.constraint.CheckConstraint(testCase.param)) }) } } func Test_ConstraintCheckConstraint_NilRegexMatcher(t *testing.T) { t.Parallel() constraint := *newConstraint(regexConstraintType{}, ConstraintRegex, []string{"("}) require.NotPanics(t, func() { require.False(t, constraint.CheckConstraint("123")) }) } func Benchmark_Utils_RemoveEscapeChar(b *testing.B) { b.ReportAllocs() var res string for b.Loop() { res = RemoveEscapeChar(":test\\:bla") } require.Equal(b, ":test:bla", res) } // go test -race -run Test_Path_matchParams func Benchmark_Path_matchParams(t *testing.B) { var ctxParams [maxParams]string benchCaseFn := func(testCollection routeCaseCollection) { parser := parseRoute(testCollection.pattern, regexp.MustCompile) for _, c := range testCollection.testCases { var matchRes bool state := "match" if !c.match { state = "not match" } t.Run(testCollection.pattern+"_"+state+"_"+c.url, func(b *testing.B) { for b.Loop() { if match := parser.getMatch(c.url, c.url, &ctxParams, c.partialCheck); match { // Get testCases from the original path matchRes = true } } require.Equal(t, c.match, matchRes, "route: '%s', url: '%s'", testCollection.pattern, c.url) if matchRes && len(c.params) > 0 { require.Equal(t, c.params[0:len(c.params)-1], ctxParams[0:len(c.params)-1], "route: '%s', url: '%s'", testCollection.pattern, c.url) } }) } } for _, testCollection := range benchmarkCases { benchCaseFn(testCollection) } } // go test -race -run Test_RoutePatternMatch func Benchmark_ConstraintExecution(b *testing.B) { var ctxParams [maxParams]string var match bool constraintPatterns := []struct { name string pattern string url string }{ {"int", "/api/:id", "/api/12345"}, {"bool", "/api/:flag", "/api/true"}, {"float", "/api/:val", "/api/3.14"}, {"alpha", "/api/:name", "/api/hello"}, {"guid", "/api/:id", "/api/12345678-1234-1234-1234-123456789abc"}, {"minLen", "/api/:name", "/api/hello"}, {"maxLen", "/api/:name", "/api/hello"}, {"len", "/api/:name", "/api/hello"}, {"betweenLen", "/api/:name", "/api/hello"}, {"min", "/api/:id", "/api/10"}, {"max", "/api/:id", "/api/10"}, {"range", "/api/:id", "/api/10"}, {"datetime", "/api/:date", "/api/2024-01-15"}, {"regex", "/api/:id", "/api/12345"}, } for _, tc := range constraintPatterns { b.Run(tc.name, func(b *testing.B) { parser := parseRoute(tc.pattern, regexp.MustCompile) for b.Loop() { match = parser.getMatch(tc.url, tc.url, &ctxParams, false) } }) } _ = match } func Benchmark_RoutePatternMatch(t *testing.B) { benchCaseFn := func(testCollection routeCaseCollection) { for _, c := range testCollection.testCases { // skip all cases for partial checks if c.partialCheck { continue } var matchRes bool state := "match" if !c.match { state = "not match" } t.Run(testCollection.pattern+"_"+state+"_"+c.url, func(b *testing.B) { for b.Loop() { if match := RoutePatternMatch(c.url, testCollection.pattern); match { // Get testCases from the original path matchRes = true } } require.Equal(t, c.match, matchRes, "route: '%s', url: '%s'", testCollection.pattern, c.url) }) } } for _, testCollection := range benchmarkCases { benchCaseFn(testCollection) } } func Test_Route_TooManyParams_Panic(t *testing.T) { t.Parallel() // Test with exactly maxParams (30) - should work t.Run("exactly_maxParams", func(t *testing.T) { t.Parallel() route := paramsRoute(t, maxParams) require.NotPanics(t, func() { parseRoute(route, regexp.MustCompile) }) }) // Test with maxParams + 1 (31) - should panic t.Run("maxParams_plus_one", func(t *testing.T) { t.Parallel() route := paramsRoute(t, maxParams+1) require.PanicsWithValue(t, "Route '"+route+"' has 31 parameters, which exceeds the maximum of 30", func() { parseRoute(route, regexp.MustCompile) }) }) // Test with 35 params - should panic t.Run("35_params", func(t *testing.T) { t.Parallel() route := paramsRoute(t, maxParams+5) require.PanicsWithValue(t, "Route '"+route+"' has 35 parameters, which exceeds the maximum of 30", func() { parseRoute(route, regexp.MustCompile) }) }) } func Test_App_Register_TooManyParams_Panic(t *testing.T) { t.Parallel() // Test registering a route with too many params via app t.Run("register_via_Get", func(t *testing.T) { t.Parallel() app := New() route := paramsRoute(t, maxParams+1) require.PanicsWithValue(t, "Route '"+route+"' has 31 parameters, which exceeds the maximum of 30", func() { app.Get(route, func(c Ctx) error { return c.SendString("test") }) }) }) // Test registering a route with maxParams works t.Run("register_maxParams_works", func(t *testing.T) { t.Parallel() app := New() route := paramsRoute(t, maxParams) require.NotPanics(t, func() { app.Get(route, func(c Ctx) error { return c.SendString("test") }) }) }) } // paramsRoute generates a route with n parameters for testing parseRoute maxParams condition. // Returns a route in the format "/:p1/:p2/:p3/.../:pN" func paramsRoute(t *testing.T, n int) string { t.Helper() params := make([]string, n) for i := range params { params[i] = fmt.Sprintf(":p%d", i+1) } return "/" + strings.Join(params, "/") } // Test_RegexHandler_Default verifies Fiber defaults RegexHandler to regexp.MustCompile. func Test_RegexHandler_Default(t *testing.T) { t.Parallel() app := New() require.NotNil(t, app.config.RegexHandler) require.Equal(t, reflect.ValueOf(regexp.MustCompile).Pointer(), reflect.ValueOf(app.config.RegexHandler).Pointer()) app.Get("/api/:id", func(c Ctx) error { return c.SendString("matched") }) resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/123", http.NoBody)) require.NoError(t, err) require.Equal(t, StatusOK, resp.StatusCode) } // mockRegexCompiler is a mock implementation of regex matching for testing type mockRegexCompiler struct { *regexp.Regexp matchCalled bool } func (m *mockRegexCompiler) MatchString(s string) bool { m.matchCalled = true return m.Regexp.MatchString(s) } type matchOnlyRegexCompiler struct { re *regexp.Regexp } func (m *matchOnlyRegexCompiler) MatchString(s string) bool { return m.re.MatchString(s) } type regexPattern string // mockRegexHandler is a mock regex handler function for testing func mockRegexHandler(lastPattern *string, compileCalled *bool) any { return func(pattern string) regexMatcher { *compileCalled = true *lastPattern = pattern return &mockRegexCompiler{ Regexp: regexp.MustCompile(pattern), } } } // Test_RegexHandler_Custom verifies that a custom regex handler can be used func Test_RegexHandler_Custom(t *testing.T) { t.Parallel() var lastPattern string var compileCalled bool // Create app with custom regex handler app := New(Config{ RegexHandler: mockRegexHandler(&lastPattern, &compileCalled), }) // Register a route with regex constraint app.Get("/api/:id", func(c Ctx) error { return c.SendString("matched") }) // Verify the mock handler was used during route registration require.True(t, compileCalled, "RegexHandler should have been called") require.Equal(t, `\d+`, lastPattern, "Pattern should match") // Test the route resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/123", http.NoBody)) require.NoError(t, err) require.Equal(t, StatusOK, resp.StatusCode) // Test with non-matching pattern resp, err = app.Test(httptest.NewRequest(http.MethodGet, "/api/abc", http.NoBody)) require.NoError(t, err) require.Equal(t, StatusNotFound, resp.StatusCode) } // Test_RegexHandler_MatchOnlyCompiler verifies Fiber accepts compilers that only implement MatchString. func Test_RegexHandler_MatchOnlyCompiler(t *testing.T) { t.Parallel() var compileCalled bool app := New(Config{ RegexHandler: func(pattern string) *matchOnlyRegexCompiler { compileCalled = true return &matchOnlyRegexCompiler{re: regexp.MustCompile(pattern)} }, }) app.Get("/api/:id", func(c Ctx) error { return c.SendString("matched") }) require.True(t, compileCalled) resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/123", http.NoBody)) require.NoError(t, err) require.Equal(t, StatusOK, resp.StatusCode) } // Test_RegexHandler_DefaultCompilerPreservesConstraintField verifies stdlib handlers still populate the exported regexp field. func Test_RegexHandler_DefaultCompilerPreservesConstraintField(t *testing.T) { t.Parallel() parser := parseRoute("/api/:id", regexp.MustCompile) require.Len(t, parser.segs, 2) require.Len(t, parser.segs[1].Constraints, 1) require.NotNil(t, parser.segs[1].Constraints[0].RegexCompiler) require.True(t, parser.segs[1].Constraints[0].matchConstraint("123")) require.False(t, parser.segs[1].Constraints[0].matchConstraint("abc")) } func Test_RegexHandler_CustomCompilerUsesSegmentMatcher(t *testing.T) { t.Parallel() parser := parseRoute("/api/:id", func(pattern string) *matchOnlyRegexCompiler { return &matchOnlyRegexCompiler{re: regexp.MustCompile(pattern)} }) require.Len(t, parser.segs, 2) require.Len(t, parser.segs[1].Constraints, 1) require.True(t, parser.segs[1].Constraints[0].matchConstraint("123")) require.False(t, parser.segs[1].Constraints[0].matchConstraint("abc")) } // Test_RoutePatternMatch_WithRegex verifies RoutePatternMatch works with regex constraints func Test_RoutePatternMatch_WithRegex(t *testing.T) { t.Parallel() // Test with default handler require.True(t, RoutePatternMatch("/api/123", "/api/:id")) require.False(t, RoutePatternMatch("/api/abc", "/api/:id")) // Test with custom config var lastPattern string var compileCalled bool require.True(t, RoutePatternMatch("/api/123", "/api/:id", Config{ RegexHandler: mockRegexHandler(&lastPattern, &compileCalled), })) require.True(t, compileCalled, "RegexHandler should have been called") } // Test_RegexHandler_NilDefaultsToStdlib verifies that nil RegexHandler defaults to stdlib func Test_RegexHandler_NilDefaultsToStdlib(t *testing.T) { t.Parallel() // Create app without specifying RegexHandler (should default) app := New() // Verify it's set to the default require.NotNil(t, app.config.RegexHandler) // Register a route with regex constraint app.Get("/api/:id", func(c Ctx) error { return c.SendString("matched") }) // Test the route works resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/api/123", http.NoBody)) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) } // Test_RegexHandler_ComplexPattern tests complex regex patterns func Test_RegexHandler_ComplexPattern(t *testing.T) { t.Parallel() app := New() // Test date pattern app.Get("/date/:date", func(c Ctx) error { return c.SendString("date: " + c.Params("date")) }) resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/date/2024-01-15", http.NoBody)) require.NoError(t, err) require.Equal(t, 200, resp.StatusCode) resp, err = app.Test(httptest.NewRequest(http.MethodGet, "/date/2024-1-5", http.NoBody)) require.NoError(t, err) require.Equal(t, 404, resp.StatusCode) } // Test_RegexHandler_InvalidConfigurationPanics verifies invalid handlers fail fast. func Test_RegexHandler_InvalidConfigurationPanics(t *testing.T) { t.Parallel() t.Run("typed_nil_function", func(t *testing.T) { t.Parallel() var handler func(string) *regexp.Regexp require.PanicsWithValue(t, "fiber: Config.RegexHandler must be a non-nil function", func() { New(Config{RegexHandler: handler}) }) }) t.Run("non_function", func(t *testing.T) { t.Parallel() require.PanicsWithValue(t, "fiber: Config.RegexHandler must be a non-nil function", func() { New(Config{RegexHandler: "invalid"}) }) }) t.Run("invalid_signature", func(t *testing.T) { t.Parallel() require.PanicsWithValue(t, "fiber: Config.RegexHandler must have signature func(string) T", func() { New(Config{RegexHandler: func(int) *regexp.Regexp { return nil }}) }) }) t.Run("named_string_parameter", func(t *testing.T) { t.Parallel() require.PanicsWithValue(t, "fiber: Config.RegexHandler must have signature func(string) T", func() { New(Config{RegexHandler: func(regexPattern) *regexp.Regexp { return nil }}) }) }) t.Run("invalid_return_type", func(t *testing.T) { t.Parallel() require.PanicsWithValue(t, "fiber: Config.RegexHandler return type must support MatchString(string) bool", func() { New(Config{RegexHandler: func(string) string { return "" }}) }) }) } // Test_RegexHandler_NilReturnPanics verifies a nil compiled matcher is rejected. func Test_RegexHandler_NilReturnPanics(t *testing.T) { t.Parallel() app := New(Config{ RegexHandler: func(string) *regexp.Regexp { return nil }, }) require.PanicsWithValue(t, "fiber: Config.RegexHandler must not return nil", func() { app.Get("/api/:id", func(c Ctx) error { return c.SendString("matched") }) }) } // Test_RoutePatternMatch_InvalidRegexHandlerPanics verifies RoutePatternMatch also validates RegexHandler configuration. // Test_RoutePatternMatch_NormalizesInputs covers the guards that run before any // parsing: an empty path and an empty or slash-less pattern are all coerced to // the forms the router itself registers, so callers do not have to pre-normalize. func Test_RoutePatternMatch_NormalizesInputs(t *testing.T) { t.Parallel() testCases := []struct { name string path string pattern string want bool }{ {name: "empty pattern becomes root", path: "/", pattern: "", want: true}, {name: "empty pattern does not match a child", path: "/a", pattern: "", want: false}, {name: "empty path becomes root", path: "", pattern: "/", want: true}, {name: "both empty", path: "", pattern: "", want: true}, {name: "pattern gains a leading slash", path: "/a", pattern: "a", want: true}, {name: "slash-less pattern with a param", path: "/1", pattern: ":id", want: true}, {name: "slash-less pattern that should not match", path: "/b", pattern: "a", want: false}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { t.Parallel() require.Equal(t, tc.want, RoutePatternMatch(tc.path, tc.pattern), "path=%q pattern=%q", tc.path, tc.pattern) }) } } func Test_RoutePatternMatch_InvalidRegexHandlerPanics(t *testing.T) { t.Parallel() require.PanicsWithValue(t, "fiber: Config.RegexHandler must be a non-nil function", func() { RoutePatternMatch("/api/123", "/api/:id", Config{RegexHandler: "invalid"}) }) } // Test_RouteParser_ConstParamShape pins which patterns the "/const/:param" // specialization claims. A gate that silently stops firing turns the // specialization into dead code, and one that fires too widely is a // correctness bug, so both directions are asserted. // go test -race -run Test_RouteParser_ConstParamShape func Test_RouteParser_ConstParamShape(t *testing.T) { t.Parallel() specialized := []string{ "/user/keys/:key_id", "/api/v1/:param", "/:param", "/a/:b", "/repos/:owner", "/x/:y", } generic := []string{ "/", "/const", "/*", "/+", "/api/*", "/api/+", "/api/:a/:b", // two params "/api/:a/fixed", // param not last "/api/:a?", // optional param (const carries the optional slash) "/apix:a?", // optional param with no optional slash on the const "/api/:a", // constrained param "/api/:a-:b", // adjacent params "/api/", // no param at all "/user/keys/:id/extra", // trailing const } for _, pattern := range specialized { parser := parseRoute(pattern, regexp.MustCompile) require.True(t, parser.constParam, "expected specialization for %q", pattern) } for _, pattern := range generic { parser := parseRoute(pattern, regexp.MustCompile) require.False(t, parser.constParam, "unexpected specialization for %q", pattern) } } // Test_RouteParser_Probe pins which patterns get a probe and what it holds. // go test -race -run Test_RouteParser_Probe func Test_RouteParser_Probe(t *testing.T) { t.Parallel() testCases := []struct { pattern string want constProbe }{ // no parameter, or nothing after it {pattern: "/", want: constProbe{}}, {pattern: "/api/v1/const", want: constProbe{}}, {pattern: "/api/:id", want: constProbe{}}, {pattern: "/api/:id/", want: constProbe{}}, {pattern: "/repos/:owner/:repo", want: constProbe{}}, // first constant after a parameter; skip counts the lone '/' in between {pattern: "/:p/fixed", want: newConstProbe("/fixed", 1, 0)}, {pattern: "/repos/:owner/:repo/issues", want: newConstProbe("/issues", 7, 1)}, {pattern: "/repos/:owner/:repo/issues/:number", want: newConstProbe("/issues/", 7, 1)}, {pattern: "/:a/:b/:c/fixed", want: newConstProbe("/fixed", 1, 2)}, {pattern: "/api/:a/b/:c/dd/:e", want: newConstProbe("/b/", 5, 0)}, {pattern: "/api/:a/x", want: newConstProbe("/x", 5, 0)}, {pattern: "/v1/some/resource/:name/x", want: newConstProbe("/x", 18, 0)}, // longer than a word: the first word is the probe {pattern: "/api/:v/collaborators/:user", want: newConstProbe("/collabo", 5, 0)}, // a trailing '/' getMatch may drop is left out of the probe {pattern: "/user/:name/keys/", want: newConstProbe("/keys", 6, 0)}, {pattern: "/api/:a/x/:b?/y", want: newConstProbe("/x", 5, 0)}, // parameters that do not end at the next '/' {pattern: "/api/*/x", want: constProbe{}}, {pattern: "/api/+/x", want: constProbe{}}, {pattern: "/api/:a?/x", want: constProbe{}}, {pattern: "/api/:a:b/x", want: constProbe{}}, {pattern: "/api/:a-:b/x", want: constProbe{}}, {pattern: "/files/:name.json/:x", want: constProbe{}}, {pattern: "/v1/some/resource/:name\\:customVerb", want: constProbe{}}, // a droppable trailing '/' on the leading constant moves the first parameter {pattern: "/api/:a?/:b/x", want: constProbe{}}, } for _, tc := range testCases { parser := parseRoute(tc.pattern, regexp.MustCompile) require.Equal(t, tc.want, parser.probe, "route: '%s'", tc.pattern) } // reset must not carry a probe over into RoutePatternMatch's pooled parser parser := parseRoute("/:p/fixed", regexp.MustCompile) require.NotZero(t, parser.probe.mask) parser.reset() require.Equal(t, constProbe{}, parser.probe) } // Test_ConstProbe_Rejects pins the probe compare and the paths it rejects outright. // go test -race -run Test_ConstProbe_Rejects func Test_ConstProbe_Rejects(t *testing.T) { t.Parallel() probe := newConstProbe("/issues/", 7, 1) require.False(t, probe.rejects("/repos/a/b/issues/1")) require.False(t, probe.rejects("/repos/a/b/issues/")) require.True(t, probe.rejects("/repos/a/b/pulls/1")) // compared in full require.True(t, probe.rejects("/repos/a/b/issues")) require.True(t, probe.rejects("/repos/a/b/issue")) // too few slashes, or too short for the leading constant require.True(t, probe.rejects("/repos/a/b")) require.True(t, probe.rejects("/repos/a")) require.True(t, probe.rejects("/repos/")) require.True(t, probe.rejects("/repos")) require.True(t, probe.rejects("")) // the leading constant's own bytes are not compared here require.False(t, probe.rejects("/xxxxx/a/b/issues/1")) // slashes in a later word, in the overlapping tail, and at the end require.False(t, probe.rejects("/repos/abcdefghijklmnop/b/issues/1")) require.False(t, probe.rejects("/repos/abcdefghijklmnop/qrstuvwxyz/issues/")) require.True(t, probe.rejects("/repos/abcdefghijklmnop/qrstuvwxyz")) require.True(t, probe.rejects("/repos/abcdefghijklmnop/qrstuvwxyz/")) require.True(t, probe.rejects("/repos/abcdefghijklmnop/")) require.True(t, probe.rejects("/repos/abcdefghi/")) require.True(t, probe.rejects("/repos/abcdefghi")) // a short probe masks the lanes past it; skip 0 follows the first parameter short := newConstProbe("/x", 3, 0) require.False(t, short.rejects("/a/b/x")) require.False(t, short.rejects("/a/b/xyz/q")) require.True(t, short.rejects("/a/b/y")) require.True(t, short.rejects("/a/b/")) require.True(t, short.rejects("/a/b")) require.True(t, short.rejects("/a/")) require.True(t, short.rejects("/a/bcdefghijklmnop")) require.False(t, short.rejects("/a/bcdefghijklmnop/x")) // every parameter and tail length around the word boundaries deep := newConstProbe("/k", 3, 1) for paramLen := range 20 { for tailLen := range 20 { path := "/a/" + strings.Repeat("p", paramLen) + "/" + strings.Repeat("q", tailLen) require.True(t, deep.rejects(path), "path %q lacks a second slash after the parameter", path) require.False(t, deep.rejects(path+"/k"), "path %q has the constant at the second slash", path+"/k") require.False(t, deep.rejects(path+"/kj"), "path %q: the bytes after the constant are getMatch's business", path+"/kj") require.True(t, deep.rejects(path+"/j"), "path %q has a different byte at the second slash", path+"/j") } } } // Test_wordAt checks every offset of a few strings against a byte loop. // go test -race -run Test_wordAt func Test_wordAt(t *testing.T) { t.Parallel() pack := func(s string) uint64 { var w uint64 for i := 0; i < len(s) && i < 8; i++ { w |= uint64(s[i]) << (8 * i) } return w } for _, s := range []string{"", "/", "/a", "/abcdef", "/abcdefg", "/abcdefgh", "/abcdefghi", "/repos/a/b/issues/1"} { for i := 0; i <= len(s); i++ { require.Equal(t, pack(s[i:]), wordAt(s, i), "s=%q i=%d", s, i) } } } // Test_packConst pins the word and mask the prefix filter and probes are built from. // go test -race -run Test_packConst func Test_packConst(t *testing.T) { t.Parallel() word, mask := packConst("") require.Zero(t, word) require.Zero(t, mask) word, mask = packConst("/ab") require.Equal(t, uint64('/')|uint64('a')<<8|uint64('b')<<16, word) require.Equal(t, uint64(0xFFFFFF), mask) word, mask = packConst("/abcdefghij") require.Equal(t, pathHeadWord("/abcdefg"), word) require.Equal(t, ^uint64(0), mask) } // Test_RouteParser_ConstParamDifferential proves matchConstParam is a pure // rewrite of the generic segment walk: for every specialized pattern and every // path, the specialized and generic matchers must agree on both the outcome // and the captured parameter. Running the two against each other is the whole // safety argument for the specialization. // go test -race -run Test_RouteParser_ConstParamDifferential func Test_RouteParser_ConstParamDifferential(t *testing.T) { t.Parallel() prefixes := []string{"/", "/a/", "/user/keys/", "/api/v1/", "/verylongprefix/"} patterns := make([]string, 0, len(prefixes)) for _, p := range prefixes { patterns = append(patterns, p+":id") } pieces := []string{ "", "/", "/a", "/a/", "/a/b", "/a/b/c", "/user", "/user/", "/user/keys", "/user/keys/", "/user/keys/1337", "/user/keys/1337/x", "/api", "/api/v1", "/api/v1/", "/api/v1/x", "/api/v1/x/y", "/verylongprefix", "/verylongprefix/z", "/VERYLONGPREFIX/z", } paths := make([]string, 0, len(pieces)*len(pieces)) for _, p1 := range pieces { for _, p2 := range pieces { paths = append(paths, p1+p2) } } for _, pattern := range patterns { parser := parseRoute(pattern, regexp.MustCompile) require.True(t, parser.constParam, "pattern %q lost its specialization", pattern) // Same parser with the specialization switched off: segs is shared and // read-only, so this exercises the generic walk over identical data. fallback := parser fallback.constParam = false for _, path := range paths { for _, partialCheck := range []bool{false, true} { var got, want [maxParams]string gotOK := parser.getMatch(path, path, &got, partialCheck) wantOK := fallback.getMatch(path, path, &want, partialCheck) require.Equal(t, wantOK, gotOK, "outcome diverged: pattern %q, path %q, partialCheck %v", pattern, path, partialCheck) if wantOK { require.Equal(t, want[0], got[0], "param diverged: pattern %q, path %q, partialCheck %v", pattern, path, partialCheck) } } } } } // Test_RouteParser_ConstParamFixture runs the specialization against the // exhaustive path-matching fixture, which covers pattern and URL shapes the // generated set above does not reach. // go test -race -run Test_RouteParser_ConstParamFixture func Test_RouteParser_ConstParamFixture(t *testing.T) { t.Parallel() checked := 0 for _, testCollection := range routeTestCases { parser := parseRoute(testCollection.pattern, regexp.MustCompile) if !parser.constParam { continue } fallback := parser fallback.constParam = false for _, c := range testCollection.testCases { var got, want [maxParams]string gotOK := parser.getMatch(c.url, c.url, &got, c.partialCheck) wantOK := fallback.getMatch(c.url, c.url, &want, c.partialCheck) require.Equal(t, wantOK, gotOK, "route: '%s', url: '%s'", testCollection.pattern, c.url) if wantOK { require.Equal(t, want[0], got[0], "route: '%s', url: '%s'", testCollection.pattern, c.url) } checked++ } } require.NotZero(t, checked, "fixture exercised no specialized routes") } // Test_RoutePatternMatch_MatchesRouter is a differential test: RoutePatternMatch // documents itself as "see logic in (*Route).match and (*App).register", so for // every pattern/path/config combination it must agree with what the router // actually does. It caught RoutePatternMatch trimming trailing slashes from the // pattern but not from the path. func Test_RoutePatternMatch_MatchesRouter(t *testing.T) { t.Parallel() patterns := []string{ "/", "/a", "/a/b", "/:id", "/a/:id", "/a/:id?", "/a/*", "/*", "/+", "/a/+", "/:a/:b", "/a-:b", "/a.:b", "/:a?/b", "/api/v1/:id/x", "/a/*/b", "/:id", "/:id", "/a/:b?/c", "/ab/*", // Case-sensitive constraints: these are evaluated against the value // getMatch slices out of the untouched path, not the detection path. "/:id", "/:id", "/:id", "/user/:n", // Escaped specials: register strips the escapes before deriving the // root/star flags, so the helper has to as well. `/\*`, `/\:id`, `/a\-b`, } paths := []string{ "/", "/a", "/a/", "/a/b", "/a/b/", "/1", "/a/1", "/a/b/c", "/a-b", "/a.b", "/b", "/api/v1/9/x", "/a/x/b", "/abc", "/ab", "/a/b/c/d", "//", "/a//b", "/ab/", "/A", "/A/", "/ABC", "/Abc", "/user/John", "/a%2Fb", "/a%20b", "/a%41b", // Spellings the router normalizes before matching. "/a/./b", "/a/../b", "/./a", "/a/b/..", "/%61", "/a/%2e%2e/b", "/a%2e/b", "/%2561", } configs := []Config{ {}, {StrictRouting: true}, {CaseSensitive: true}, {StrictRouting: true, CaseSensitive: true}, {UnescapePath: true}, {UnescapePath: true, CaseSensitive: true}, {UnescapePath: true, StrictRouting: true}, } for _, cfg := range configs { name := fmt.Sprintf("strict=%v/casesensitive=%v/unescape=%v", cfg.StrictRouting, cfg.CaseSensitive, cfg.UnescapePath) t.Run(name, func(t *testing.T) { t.Parallel() for _, pattern := range patterns { for _, path := range paths { app := New(cfg) matched := false app.Get(pattern, func(_ Ctx) error { matched = true return nil }) _, err := app.Test(httptest.NewRequest(MethodGet, path, http.NoBody)) require.NoError(t, err) require.Equal(t, matched, RoutePatternMatch(path, pattern, cfg), "pattern=%q path=%q", pattern, path) } } }) } } func Test_UnescapePath_MalformedEscape(t *testing.T) { t.Parallel() tests := []struct { in, out string }{ {in: "/no-escape", out: "/no-escape"}, {in: "/a%2Fb", out: "/a/b"}, {in: "/a%2fb", out: "/a/b"}, {in: "/a%2Ab", out: "/a*b"}, {in: "/a%zzb", out: "/a%zzb"}, {in: "/a%2zb", out: "/a%2zb"}, {in: "/a%z2b", out: "/a%z2b"}, {in: "/trailing%2", out: "/trailing%2"}, {in: "/plus+kept%20", out: "/plus+kept "}, } for _, tc := range tests { require.Equal(t, tc.out, string(unescapePath([]byte(tc.in))), "in=%q", tc.in) } } func Test_UnescapeSafePath(t *testing.T) { t.Parallel() tests := []struct { in, out string }{ {in: "/no-escape", out: "/no-escape"}, // escapes of unreserved characters are decoded (RFC 3986 Section 6.2.2.2) {in: "/%41%62%63", out: "/Abc"}, {in: "/%7e/%2D%2e%5f", out: "/~/-._"}, {in: "/%30%39", out: "/09"}, // every other escape is kept with uppercase hex digits (Section 6.2.2.1) {in: "/cr%C3%A9er", out: "/cr%C3%A9er"}, {in: "/cr%c3%a9er", out: "/cr%C3%A9er"}, {in: "/a%20b", out: "/a%20b"}, {in: "/%7B%7d%22", out: "/%7B%7D%22"}, {in: "/a%2Fb%2fc", out: "/a%2Fb%2Fc"}, {in: "/a%3Fb%23c%40d%2Be", out: "/a%3Fb%23c%40d%2Be"}, {in: "/100%2525", out: "/100%2525"}, {in: "/a%5cb", out: "/a%5Cb"}, {in: "/%00%1f%7f", out: "/%00%1F%7F"}, // Malformed escapes are kept. {in: "/a%zzb", out: "/a%zzb"}, {in: "/trailing%2", out: "/trailing%2"}, {in: "/%", out: "/%"}, {in: "/%2g%41", out: "/%2gA"}, // Decoding runs once: "%25" never becomes a new escape. {in: "/%2570rivate", out: "/%2570rivate"}, {in: "/%2E%2E/%70", out: "/../p"}, } for _, tc := range tests { require.Equal(t, tc.out, string(unescapeSafePath([]byte(tc.in))), "in=%q", tc.in) } } func Test_CleanPathSegments(t *testing.T) { t.Parallel() tests := []struct { in, out string }{ {in: "", out: ""}, {in: "/", out: "/"}, {in: "/a", out: "/a"}, {in: "/a/", out: "/a/"}, {in: "/a/b/c/./../../g", out: "/a/g"}, {in: "mid/content=5/../6", out: "mid/6"}, {in: "/./a", out: "/a"}, {in: "/../a", out: "/a"}, {in: "/.", out: "/"}, {in: "/..", out: "/"}, {in: "/a/..", out: "/"}, {in: "/a/b/..", out: "/a/"}, {in: "/a/b/.", out: "/a/b/"}, {in: "/a/../..", out: "/"}, {in: "/a/../../b", out: "/b"}, // empty segments are not dot segments and stay (Section 5.2.4) {in: "//", out: "//"}, {in: "//a", out: "//a"}, {in: "/a//b", out: "/a//b"}, {in: "/a//", out: "/a//"}, {in: "/a/.//b", out: "/a//b"}, {in: "/a//..", out: "/a/"}, {in: "/a/b//../c", out: "/a/b/c"}, {in: "/..//..//etc", out: "//etc"}, {in: "/..a/.b/c..", out: "/..a/.b/c.."}, {in: "/a/.../b", out: "/a/.../b"}, {in: "a/./b", out: "a/b"}, } for _, tc := range tests { require.Equal(t, tc.out, string(cleanPathSegments([]byte(tc.in))), "in=%q", tc.in) } } func Test_NormalizeRequestPath(t *testing.T) { t.Parallel() tests := []struct { in, out string unescape bool }{ {in: "/static/%2E%2E/x", out: "/x"}, {in: "/static/%2e/x", out: "/static/x"}, {in: "/static/%70rivate//x/./y/../z", out: "/static/private//x/z"}, // Only unreserved characters are decoded unless UnescapePath is set. {in: "/cr%c3%a9er/a%20b", out: "/cr%C3%A9er/a%20b"}, {in: "/cr%c3%a9er/a%20b", unescape: true, out: "/créer/a b"}, // An encoded slash is not a separator unless UnescapePath decodes it. {in: "/a%2F..%2Fb", out: "/a%2F..%2Fb"}, {in: "/a%2F..%2Fb", unescape: true, out: "/b"}, // Decoding runs once, so "%25" never turns into a new escape. {in: "/%2570rivate", out: "/%2570rivate"}, {in: "/%2570rivate", unescape: true, out: "/%70rivate"}, {in: "/%252e%252e/x", unescape: true, out: "/%2e%2e/x"}, } for _, tc := range tests { got := string(normalizeRequestPath([]byte(tc.in), tc.unescape)) require.Equal(t, tc.out, got, "in=%q unescape=%v", tc.in, tc.unescape) } } // forEachPathSample calls fn with every string of length 0 to 9 over "/.%a" // and a few longer ones that cross word boundaries. func forEachPathSample(fn func(string)) { const alphabet = "/.%a" var walk func(prefix string, depth int) walk = func(prefix string, depth int) { fn(prefix) if depth == 0 { return } for i := range len(alphabet) { walk(prefix+alphabet[i:i+1], depth-1) } } walk("", 9) for _, s := range []string{ "/user/keys/1337", "/aaaaaaa//", "/aaaaaa/.", "/aaaaaaa/./b", "/aaaaaaaa%", "%aaaaaaaaaaaaaa", "/aaaaaaa/aaaaaaa/aaaaaaa/../b", "/aaaaaaa/aaaaaaa/aaaaaaa/aaaaaaa", "/aaaaaaa/aaaaaaa/aaaaaaa//", } { fn(s) } } // Test_NeedsPathNormalization_MatchesReference checks the word-at-a-time scan // against a byte-at-a-time definition of what it must find. func Test_NeedsPathNormalization_MatchesReference(t *testing.T) { t.Parallel() ref := func(s string) bool { if s != "" && s[0] == '.' { return true } return strings.Contains(s, "%") || strings.Contains(s, "/.") } count := 0 forEachPathSample(func(s string) { count++ require.Equal(t, ref(s), needsPathNormalization(s), "path=%q", s) }) require.Greater(t, count, 300000) } // Test_HasDotSegment_MatchesReference does the same for the scan that gates // cleanPathSegments. func Test_HasDotSegment_MatchesReference(t *testing.T) { t.Parallel() ref := func(s string) bool { if s != "" && s[0] == '.' { return true } return strings.Contains(s, "/.") } forEachPathSample(func(s string) { require.Equal(t, ref(s), hasDotSegment([]byte(s)), "path=%q", s) }) } func Benchmark_NeedsPathNormalization(b *testing.B) { for _, s := range []string{"/", "/user/keys/1337", "/api/v1/entity/1", "/aaaaaaa/bbbbbbb/ccccccc/ddddddd/e"} { b.Run(s, func(b *testing.B) { b.ReportAllocs() var r bool for b.Loop() { r = needsPathNormalization(s) } if r { b.Fatal("unexpected normalization") } }) } } func Test_NeedsPathNormalization(t *testing.T) { t.Parallel() for _, s := range []string{"/a/./b", "/a/../b", "/%41", "/%2F", "/.", "/..", "./x", "../x", "/.well-known/x"} { require.True(t, needsPathNormalization(s), "path=%q", s) } for _, s := range []string{"", "/", "/a", "/a/b", "/a/b/", "/a//b", "//", "/a.b/c", "/a-b_c~d", "/a/b.", "/a..b", "/user/keys/1337"} { require.False(t, needsPathNormalization(s), "path=%q", s) } } func Test_RouteParser_AdoptConstraints_Mismatch(t *testing.T) { t.Parallel() var pretty, raw routeParser pretty.parseRoute("/:id/:name", nil) raw.parseRoute("/:id", nil) before := make([]int, len(pretty.segs)) for i, seg := range pretty.segs { before[i] = len(seg.Constraints) } pretty.adoptConstraints(&raw) for i, seg := range pretty.segs { require.Len(t, seg.Constraints, before[i]) } } func Test_RouteParser_AdoptConstraints_FewerRawSegments(t *testing.T) { t.Parallel() var pretty, raw routeParser pretty.parseRoute("/a/:id/b/:name", nil) raw.parseRoute("/:id", nil) raw.params = append(raw.params, "name") require.NotPanics(t, func() { pretty.adoptConstraints(&raw) }) }