Skip to content

Commit 03ce3ad

Browse files
committed
perf(path): replace regex with custom functions in redirectTrailingSlash
1 parent 51ddcab commit 03ce3ad

File tree

3 files changed

+207
-10
lines changed

3 files changed

+207
-10
lines changed

gin.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"net/http"
1212
"os"
1313
"path"
14-
"regexp"
1514
"strings"
1615
"sync"
1716

@@ -48,11 +47,6 @@ var defaultTrustedCIDRs = []*net.IPNet{
4847
},
4948
}
5049

51-
var (
52-
regSafePrefix = regexp.MustCompile("[^a-zA-Z0-9/-]+")
53-
regRemoveRepeatedChar = regexp.MustCompile("/{2,}")
54-
)
55-
5650
// HandlerFunc defines the handler used by gin middleware as return value.
5751
type HandlerFunc func(*Context)
5852

@@ -782,12 +776,23 @@ func serveError(c *Context, code int, defaultMessage []byte) {
782776
c.writermem.WriteHeaderNow()
783777
}
784778

779+
// sanitizePathChars removes unsafe characters from path strings,
780+
// keeping only ASCII letters, ASCII numbers, forward slashes, and hyphens.
781+
func sanitizePathChars(s string) string {
782+
return strings.Map(func(r rune) rune {
783+
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '/' || r == '-' {
784+
return r
785+
}
786+
return -1
787+
}, s)
788+
}
789+
785790
func redirectTrailingSlash(c *Context) {
786791
req := c.Request
787792
p := req.URL.Path
788793
if prefix := path.Clean(c.Request.Header.Get("X-Forwarded-Prefix")); prefix != "." {
789-
prefix = regSafePrefix.ReplaceAllString(prefix, "")
790-
prefix = regRemoveRepeatedChar.ReplaceAllString(prefix, "/")
794+
prefix = sanitizePathChars(prefix)
795+
prefix = removeRepeatedChar(prefix, '/')
791796

792797
p = prefix + "/" + req.URL.Path
793798
}

path.go

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
package gin
77

8+
const stackBufSize = 128
9+
810
// cleanPath is the URL version of path.Clean, it returns a canonical URL path
911
// for p, eliminating . and .. elements.
1012
//
@@ -36,8 +38,6 @@ func cleanPath(p string) string {
3638
return "/" + p
3739
}
3840

39-
const stackBufSize = 128
40-
4141
// Reasonably sized buffer on stack to avoid allocations in the common case.
4242
// If a larger buffer is required, it gets allocated dynamically.
4343
buf := make([]byte, 0, stackBufSize)
@@ -165,3 +165,55 @@ func bufApp(buf *[]byte, s string, w int, c byte) {
165165
}
166166
b[w] = c
167167
}
168+
169+
// removeRepeatedChar removes multiple consecutive 'char's from a string.
170+
// if s == "/a//b///c////" && char == '/', it returns "/a/b/c/"
171+
func removeRepeatedChar(s string, char byte) string {
172+
// Check if there are any consecutive chars
173+
hasRepeatedChar := false
174+
for i := 1; i < len(s); i++ {
175+
if s[i] == char && s[i-1] == char {
176+
hasRepeatedChar = true
177+
break
178+
}
179+
}
180+
if !hasRepeatedChar {
181+
return s
182+
}
183+
184+
// Reasonably sized buffer on stack to avoid allocations in the common case.
185+
buf := make([]byte, 0, stackBufSize)
186+
187+
// Invariants:
188+
// reading from s; r is index of next byte to process.
189+
// writing to buf; w is index of next byte to write.
190+
r := 0
191+
w := 0
192+
193+
for n := len(s); r < n; {
194+
if s[r] == char {
195+
// Write the first char
196+
bufApp(&buf, s, w, char)
197+
w++
198+
r++
199+
200+
// Skip all consecutive chars
201+
for r < n && s[r] == char {
202+
r++
203+
}
204+
} else {
205+
// Copy non-char character
206+
bufApp(&buf, s, w, s[r])
207+
w++
208+
r++
209+
}
210+
}
211+
212+
// If the original string was not modified (or only shortened at the end),
213+
// return the respective substring of the original string.
214+
// Otherwise, return a new string from the buffer.
215+
if len(buf) == 0 {
216+
return s[:w]
217+
}
218+
return string(buf[:w])
219+
}

path_test.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
package gin
77

88
import (
9+
"regexp"
910
"runtime"
1011
"strings"
1112
"testing"
@@ -70,6 +71,44 @@ var cleanTests = []cleanPathTest{
7071
{"abc/../../././../def", "/def"},
7172
}
7273

74+
var regRemoveRepeatedChar = regexp.MustCompile("/{2,}")
75+
76+
func removeRepeatedSlashRegexp(s string) string {
77+
return regRemoveRepeatedChar.ReplaceAllString(s, "/")
78+
}
79+
80+
func removeRepeatedSlashLoopReplace(s string) string {
81+
for strings.Contains(s, "//") {
82+
s = strings.ReplaceAll(s, "//", "/")
83+
}
84+
return s
85+
}
86+
87+
func removeRepeatedSlashStringBuilder(s string) string {
88+
if !strings.Contains(s, "//") {
89+
return s
90+
}
91+
92+
var sb strings.Builder
93+
sb.Grow(len(s) - 1)
94+
prevChar := rune(0)
95+
96+
for _, r := range s {
97+
if r == '/' && prevChar == '/' {
98+
continue
99+
}
100+
sb.WriteRune(r)
101+
prevChar = r
102+
}
103+
104+
return sb.String()
105+
}
106+
107+
// removeRepeatedSlash removes multiple consecutive slashes from a string.
108+
func removeRepeatedSlash(s string) string {
109+
return removeRepeatedChar(s, '/')
110+
}
111+
73112
func TestPathClean(t *testing.T) {
74113
for _, test := range cleanTests {
75114
assert.Equal(t, test.result, cleanPath(test.path))
@@ -144,3 +183,104 @@ func BenchmarkPathCleanLong(b *testing.B) {
144183
}
145184
}
146185
}
186+
187+
func TestRemoveRepeatedChar(t *testing.T) {
188+
testCases := []struct {
189+
name string
190+
str string
191+
char byte
192+
want string
193+
}{
194+
{
195+
name: "empty",
196+
str: "",
197+
char: 'a',
198+
want: "",
199+
},
200+
{
201+
name: "noSlash",
202+
str: "abc",
203+
char: ',',
204+
want: "abc",
205+
},
206+
{
207+
name: "withSlash",
208+
str: "/a/b/c/",
209+
char: '/',
210+
want: "/a/b/c/",
211+
},
212+
{
213+
name: "withRepeatedSlashes",
214+
str: "/a//b///c////",
215+
char: '/',
216+
want: "/a/b/c/",
217+
},
218+
{
219+
name: "threeSlashes",
220+
str: "///",
221+
char: '/',
222+
want: "/",
223+
},
224+
}
225+
226+
for _, tc := range testCases {
227+
t.Run(tc.name, func(t *testing.T) {
228+
res := removeRepeatedChar(tc.str, tc.char)
229+
assert.Equal(t, tc.want, res)
230+
})
231+
}
232+
}
233+
234+
func benchmarkRemoveRepeatedSlash(b *testing.B, prefix string) {
235+
testCases := []struct {
236+
name string
237+
fn func(string) string
238+
}{
239+
{
240+
name: "regexp",
241+
fn: removeRepeatedSlashRegexp,
242+
},
243+
{
244+
name: "loopReplace",
245+
fn: removeRepeatedSlashLoopReplace,
246+
},
247+
{
248+
name: "stringBuilder",
249+
fn: removeRepeatedSlashStringBuilder,
250+
},
251+
{
252+
name: "buff",
253+
fn: removeRepeatedSlash,
254+
},
255+
}
256+
257+
for _, tc := range testCases {
258+
b.Run(prefix+" "+tc.name, func(b *testing.B) {
259+
b.ResetTimer()
260+
b.ReportAllocs()
261+
for b.Loop() {
262+
tc.fn(prefix)
263+
}
264+
})
265+
}
266+
}
267+
268+
func BenchmarkRemoveRepeatedSlash_MultipleSlashes(b *testing.B) {
269+
prefix := "/somePrefix/more//text///more////"
270+
benchmarkRemoveRepeatedSlash(b, prefix)
271+
}
272+
273+
func BenchmarkRemoveRepeatedSlash_TwoSlashes(b *testing.B) {
274+
prefix := "/somePrefix/more//"
275+
benchmarkRemoveRepeatedSlash(b, prefix)
276+
}
277+
278+
func BenchmarkRemoveNoRepeatedSlash(b *testing.B) {
279+
prefix := "/somePrefix/more/text/"
280+
benchmarkRemoveRepeatedSlash(b, prefix)
281+
}
282+
283+
func BenchmarkRemoveNoSlash(b *testing.B) {
284+
prefix := "/somePrefixmoretext"
285+
benchmarkRemoveRepeatedSlash(b, prefix)
286+
}

0 commit comments

Comments
 (0)