perf: use more efficient removeRepeatedChar for path slash handling

This commit is contained in:
1911860538 2025-10-31 21:17:09 +08:00
parent 95504d9ec7
commit d5ee6e1cdd
4 changed files with 104 additions and 58 deletions

29
gin.go
View File

@ -13,7 +13,6 @@ import (
"path"
"strings"
"sync"
"unicode"
"github.com/gin-gonic/gin/internal/bytesconv"
filesystem "github.com/gin-gonic/gin/internal/fs"
@ -772,7 +771,7 @@ func redirectTrailingSlash(c *Context) {
p := req.URL.Path
if prefix := path.Clean(c.Request.Header.Get("X-Forwarded-Prefix")); prefix != "." {
prefix = sanitizePathChars(prefix)
prefix = removeRepeatedSlash(prefix)
prefix = removeRepeatedChar(prefix, '/')
p = prefix + "/" + req.URL.Path
}
@ -784,38 +783,16 @@ func redirectTrailingSlash(c *Context) {
}
// sanitizePathChars removes unsafe characters from path strings,
// keeping only letters, numbers, forward slashes, and hyphens.
// keeping only ASCII letters, ASCII numbers, forward slashes, and hyphens.
func sanitizePathChars(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '/' || r == '-' {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '/' || r == '-' {
return r
}
return -1
}, s)
}
// removeRepeatedSlash removes consecutive forward slashes from a string,
// replacing sequences of multiple slashes with a single slash.
func removeRepeatedSlash(s string) string {
if !strings.Contains(s, "//") {
return s
}
var sb strings.Builder
sb.Grow(len(s) - 1)
prevChar := rune(0)
for _, r := range s {
if r == '/' && prevChar == '/' {
continue
}
sb.WriteRune(r)
prevChar = r
}
return sb.String()
}
func redirectFixedPath(c *Context, root *node, trailingSlash bool) bool {
req := c.Request
rPath := req.URL.Path

View File

@ -1012,34 +1012,3 @@ func TestUpdateRouteTreesCalledOnce(t *testing.T) {
assert.Equal(t, "ok", w.Body.String())
}
}
func TestRemoveRepeatedSlash(t *testing.T) {
testCases := []struct {
name string
str string
want string
}{
{
name: "noSlash",
str: "abc",
want: "abc",
},
{
name: "withSlash",
str: "/a/b/c/",
want: "/a/b/c/",
},
{
name: "withRepeatedSlash",
str: "/a//b///c////",
want: "/a/b/c/",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res := removeRepeatedSlash(tc.str)
assert.Equal(t, tc.want, res)
})
}
}

55
path.go
View File

@ -5,6 +5,8 @@
package gin
const stackBufSize = 128
// cleanPath is the URL version of path.Clean, it returns a canonical URL path
// for p, eliminating . and .. elements.
//
@ -19,7 +21,6 @@ package gin
//
// If the result of this process is an empty string, "/" is returned.
func cleanPath(p string) string {
const stackBufSize = 128
// Turn empty string into "/"
if p == "" {
return "/"
@ -148,3 +149,55 @@ func bufApp(buf *[]byte, s string, w int, c byte) {
}
b[w] = c
}
// removeRepeatedChar removes multiple consecutive 'char's from a string.
// if s == "/a//b///c////" && char == '/', it returns "/a/b/c/"
func removeRepeatedChar(s string, char byte) string {
// Check if there are any consecutive chars
hasRepeatedChar := false
for i := 1; i < len(s); i++ {
if s[i] == char && s[i-1] == char {
hasRepeatedChar = true
break
}
}
if !hasRepeatedChar {
return s
}
// Reasonably sized buffer on stack to avoid allocations in the common case.
buf := make([]byte, 0, stackBufSize)
// Invariants:
// reading from s; r is index of next byte to process.
// writing to buf; w is index of next byte to write.
r := 0
w := 0
for n := len(s); r < n; {
if s[r] == char {
// Write the first char
bufApp(&buf, s, w, char)
w++
r++
// Skip all consecutive chars
for r < n && s[r] == char {
r++
}
} else {
// Copy non-char character
bufApp(&buf, s, w, s[r])
w++
r++
}
}
// If the original string was not modified (or only shortened at the end),
// return the respective substring of the original string.
// Otherwise, return a new string from the buffer.
if len(buf) == 0 {
return s[:w]
}
return string(buf[:w])
}

View File

@ -143,3 +143,50 @@ func BenchmarkPathCleanLong(b *testing.B) {
}
}
}
func TestRemoveRepeatedChar(t *testing.T) {
testCases := []struct {
name string
str string
char byte
want string
}{
{
name: "empty",
str: "",
char: 'a',
want: "",
},
{
name: "noSlash",
str: "abc",
char: ',',
want: "abc",
},
{
name: "withSlash",
str: "/a/b/c/",
char: '/',
want: "/a/b/c/",
},
{
name: "withRepeatedSlashes",
str: "/a//b///c////",
char: '/',
want: "/a/b/c/",
},
{
name: "threeSlashes",
str: "///",
char: '/',
want: "/",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
res := removeRepeatedChar(tc.str, tc.char)
assert.Equal(t, tc.want, res)
})
}
}