Merge pull request #119 from WJQSERVER-STUDIO/dev
Some checks failed
Build / prepare (push) Has been cancelled
Build / build (amd64, darwin) (push) Has been cancelled
Build / build (amd64, freebsd) (push) Has been cancelled
Build / build (amd64, linux) (push) Has been cancelled
Build / build (arm64, darwin) (push) Has been cancelled
Build / build (arm64, freebsd) (push) Has been cancelled
Build / build (arm64, linux) (push) Has been cancelled
Build / docker (push) Has been cancelled

optimize matcher performance
This commit is contained in:
WJQSERVER 2025-06-09 23:32:08 +08:00 committed by GitHub
commit 65769975b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 505 additions and 43 deletions

View file

@ -1,5 +1,14 @@
# 更新日志
3.5.1 - 2025-06-09
---
- CHANGE: 大幅优化`Matcher`的性能, 实现零分配, 大幅提升性能; 单次操作时间: `254.3 ns/op` => `29.59 ns/op`
25w45a - 2025-06-09
---
- PRE-RELEASE: 此版本是v3.5.1预发布版本,请勿在生产环境中使用;
- CHANGE: 大幅优化`Matcher`的性能, 实现零分配, 大幅提升性能; 单次操作时间: `254.3 ns/op` => `29.59 ns/op`
3.5.0 - 2025-06-05
---
- CHANGE: 更新许可证 v2.0 => v2.1

View file

@ -1 +1 @@
25w44a
25w45a

View file

@ -1 +1 @@
3.5.0
3.5.1

10
go.mod
View file

@ -8,8 +8,8 @@ require (
github.com/WJQSERVER-STUDIO/logger v1.7.3
github.com/cloudwego/hertz v0.10.0
github.com/hertz-contrib/http2 v0.1.8
golang.org/x/net v0.40.0
golang.org/x/time v0.11.0
golang.org/x/net v0.41.0
golang.org/x/time v0.12.0
)
require (
@ -36,10 +36,10 @@ require (
github.com/tidwall/pretty v1.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/arch v0.17.0 // indirect
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b // indirect
golang.org/x/arch v0.18.0 // indirect
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/text v0.26.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

10
go.sum
View file

@ -89,12 +89,16 @@ github.com/wjqserver/modembed v0.0.1/go.mod h1:sYbQJMAjSBsdYQrUsuHY380XXE1CuRh8g
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc=
golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b h1:QoALfVG9rhQ/M7vYDScfPdWjGL9dlsVVM5VGh7aKoAA=
golang.org/x/exp v0.0.0-20250531010427-b6e5de432a8b/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4=
golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -106,6 +110,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -135,8 +141,12 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View file

@ -110,7 +110,7 @@ func ChunkedProxyRequest(ctx context.Context, c *app.RequestContext, u string, c
bodyReader = limitreader.NewRateLimitedReader(bodyReader, bandwidthLimit, int(bandwidthBurst), ctx)
}
if MatcherShell(u) && matchString(matcher, matchedMatchers) && cfg.Shell.Editor {
if MatcherShell(u) && matchString(matcher) && cfg.Shell.Editor {
// 判断body是不是gzip
var compress string
if resp.Header.Get("Content-Encoding") == "gzip" {

View file

@ -6,8 +6,141 @@ import (
"net/url"
"regexp"
"strings"
"sync"
)
var (
githubPrefix = "https://github.com/"
rawPrefix = "https://raw.githubusercontent.com/"
gistPrefix = "https://gist.github.com/"
apiPrefix = "https://api.github.com/"
githubPrefixLen int
rawPrefixLen int
gistPrefixLen int
apiPrefixLen int
)
func init() {
githubPrefixLen = len(githubPrefix)
rawPrefixLen = len(rawPrefix)
gistPrefixLen = len(gistPrefix)
apiPrefixLen = len(apiPrefix)
//log.Printf("githubPrefixLen: %d, rawPrefixLen: %d, gistPrefixLen: %d, apiPrefixLen: %d", githubPrefixLen, rawPrefixLen, gistPrefixLen, apiPrefixLen)
}
// Matcher 从原始URL路径中高效地解析并匹配代理规则.
func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHProxyErrors) {
if len(rawPath) < 18 {
return "", "", "", NewErrorWithStatusLookup(404, "path too short")
}
// 匹配 "https://github.com/"
if strings.HasPrefix(rawPath, githubPrefix) {
remaining := rawPath[githubPrefixLen:]
i := strings.IndexByte(remaining, '/')
if i <= 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed github path: missing user")
}
user := remaining[:i]
remaining = remaining[i+1:]
i = strings.IndexByte(remaining, '/')
if i <= 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed github path: missing repo")
}
repo := remaining[:i]
remaining = remaining[i+1:]
if len(remaining) == 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed github path: missing action")
}
i = strings.IndexByte(remaining, '/')
action := remaining
if i != -1 {
action = remaining[:i]
}
var matcher string
switch action {
case "releases", "archive":
matcher = "releases"
case "blob":
matcher = "blob"
case "raw":
matcher = "raw"
case "info", "git-upload-pack":
matcher = "clone"
default:
return "", "", "", NewErrorWithStatusLookup(400, fmt.Sprintf("unsupported github action: %s", action))
}
return user, repo, matcher, nil
}
// 匹配 "https://raw.githubusercontent.com/"
if strings.HasPrefix(rawPath, rawPrefix) {
remaining := rawPath[rawPrefixLen:]
// 这里的逻辑与 github.com 的类似, 需要提取 user, repo, branch, file...
// 我们只需要 user 和 repo
i := strings.IndexByte(remaining, '/')
if i <= 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: missing user")
}
user := remaining[:i]
remaining = remaining[i+1:]
i = strings.IndexByte(remaining, '/')
if i <= 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: missing repo")
}
repo := remaining[:i]
// raw 链接至少需要 user/repo/branch 三部分
remaining = remaining[i+1:]
if len(remaining) == 0 {
return "", "", "", NewErrorWithStatusLookup(400, "malformed raw url: missing branch/commit")
}
return user, repo, "raw", nil
}
// 匹配 "https://gist.github.com/"
if strings.HasPrefix(rawPath, gistPrefix) {
remaining := rawPath[gistPrefixLen:]
i := strings.IndexByte(remaining, '/')
if i <= 0 {
// case: https://gist.github.com/user
// 这种情况下, gist_id 缺失, 但我们仍然可以认为 user 是有效的
if len(remaining) > 0 {
return remaining, "", "gist", nil
}
return "", "", "", NewErrorWithStatusLookup(400, "malformed gist url: missing user")
}
// case: https://gist.github.com/user/gist_id...
user := remaining[:i]
return user, "", "gist", nil
}
// 匹配 "https://api.github.com/"
if strings.HasPrefix(rawPath, apiPrefix) {
if !cfg.Auth.ForceAllowApi && (cfg.Auth.Method != "header" || !cfg.Auth.Enabled) {
return "", "", "", NewErrorWithStatusLookup(403, "API proxy requires header authentication")
}
remaining := rawPath[apiPrefixLen:]
var user, repo string
if strings.HasPrefix(remaining, "repos/") {
parts := strings.SplitN(remaining[6:], "/", 3)
if len(parts) >= 2 {
user = parts[0]
repo = parts[1]
}
} else if strings.HasPrefix(remaining, "users/") {
parts := strings.SplitN(remaining[6:], "/", 2)
if len(parts) >= 1 {
user = parts[0]
}
}
return user, repo, "api", nil
}
return "", "", "", NewErrorWithStatusLookup(404, "no matcher found for the given path")
}
// 原实现
/*
func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHProxyErrors) {
var (
user string
@ -17,11 +150,11 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro
// 匹配 "https://github.com"开头的链接
if strings.HasPrefix(rawPath, "https://github.com") {
remainingPath := strings.TrimPrefix(rawPath, "https://github.com")
/*
if strings.HasPrefix(remainingPath, "/") {
remainingPath = strings.TrimPrefix(remainingPath, "/")
}
*/
//if strings.HasPrefix(remainingPath, "/") {
// remainingPath = strings.TrimPrefix(remainingPath, "/")
//}
remainingPath = strings.TrimPrefix(remainingPath, "/")
// 预期格式/user/repo/more...
// 取出user和repo和最后部分
@ -103,55 +236,56 @@ func Matcher(rawPath string, cfg *config.Config) (string, string, string, *GHPro
errMsg := "Didn't match any matcher"
return "", "", "", NewErrorWithStatusLookup(404, errMsg)
}
*/
var (
matchedMatchers = []string{
"blob",
"raw",
"gist",
}
proxyableMatchersMap map[string]struct{}
initMatchersOnce sync.Once
)
// matchString 检查目标字符串是否在给定的字符串集合中
func matchString(target string, stringsToMatch []string) bool {
matchMap := make(map[string]struct{}, len(stringsToMatch))
for _, str := range stringsToMatch {
matchMap[str] = struct{}{}
}
_, exists := matchMap[target]
func initMatchers() {
initMatchersOnce.Do(func() {
matchers := []string{"blob", "raw", "gist"}
proxyableMatchersMap = make(map[string]struct{}, len(matchers))
for _, m := range matchers {
proxyableMatchersMap[m] = struct{}{}
}
})
}
// matchString 与原始版本签名兼容
func matchString(target string) bool {
initMatchers()
_, exists := proxyableMatchersMap[target]
return exists
}
// extractParts 从给定的 URL 中提取所需的部分
// extractParts 与原始版本签名兼容
func extractParts(rawURL string) (string, string, string, url.Values, error) {
// 解析 URL
parsedURL, err := url.Parse(rawURL)
if err != nil {
return "", "", "", nil, err
}
// 获取路径部分并分割
pathParts := strings.Split(parsedURL.Path, "/")
path := parsedURL.Path
if len(path) > 0 && path[0] == '/' {
path = path[1:]
}
// 提取所需的部分
if len(pathParts) < 3 {
parts := strings.SplitN(path, "/", 3)
if len(parts) < 2 {
return "", "", "", nil, fmt.Errorf("URL path is too short")
}
// 提取 /WJQSERVER-STUDIO 和 /go-utils.git
repoOwner := "/" + pathParts[1]
repoName := "/" + pathParts[2]
// 剩余部分
remainingPath := strings.Join(pathParts[3:], "/")
if remainingPath != "" {
remainingPath = "/" + remainingPath
repoOwner := "/" + parts[0]
repoName := "/" + parts[1]
var remainingPath string
if len(parts) > 2 {
remainingPath = "/" + parts[2]
}
// 查询参数
queryParams := parsedURL.Query()
return repoOwner, repoName, remainingPath, queryParams, nil
return repoOwner, repoName, remainingPath, parsedURL.Query(), nil
}
var urlPattern = regexp.MustCompile(`https?://[^\s'"]+`)

309
proxy/matcher_test.go Normal file
View file

@ -0,0 +1,309 @@
package proxy
import (
"ghproxy/config"
"net/url"
"reflect"
"testing"
)
func TestMatcher_Compatibility(t *testing.T) {
// --- 准备各种配置用于测试 ---
cfgWithAuth := &config.Config{
Auth: config.AuthConfig{Enabled: true, Method: "header", ForceAllowApi: false},
}
cfgNoAuth := &config.Config{
Auth: config.AuthConfig{Enabled: false},
}
cfgApiForceAllowed := &config.Config{
Auth: config.AuthConfig{ForceAllowApi: true},
}
cfgWrongAuthMethod := &config.Config{
Auth: config.AuthConfig{Enabled: true, Method: "none"},
}
testCases := []struct {
name string
rawPath string
config *config.Config
expectedUser string
expectedRepo string
expectedMatcher string
expectError bool
expectedErrCode int
}{
{
name: "GH Releases Path",
rawPath: "https://github.com/owner/repo/releases/download/v1.0/asset.zip",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "releases",
},
{
name: "GH Archive Path",
rawPath: "https://github.com/owner/repo.git/archive/main.zip",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo.git", expectedMatcher: "releases",
},
{
name: "GH Blob Path",
rawPath: "https://github.com/owner/repo/blob/main/path/to/file.go",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "blob",
},
{
name: "GH Raw Path",
rawPath: "https://github.com/owner/repo/raw/main/image.png",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "raw",
},
{
name: "GH Clone Info Refs",
rawPath: "https://github.com/owner/repo.git/info/refs?service=git-upload-pack",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo.git", expectedMatcher: "clone",
},
{
name: "GH Clone Git Upload Pack",
rawPath: "https://github.com/owner/repo/git-upload-pack",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "clone",
},
{
name: "Girhub Broken Path",
rawPath: "https://github.com/owner",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "RawGHUserContent Path",
rawPath: "https://raw.githubusercontent.com/owner/repo/branch/file.sh",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "raw",
},
{
name: "Gist Path",
rawPath: "https://gist.github.com/user/abcdef1234567890",
config: cfgWithAuth,
expectedUser: "user", expectedRepo: "", expectedMatcher: "gist",
},
{
name: "API Repos Path (with Auth)",
rawPath: "https://api.github.com/repos/owner/repo/pulls",
config: cfgWithAuth,
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "api",
},
{
name: "API Users Path (with Auth)",
rawPath: "https://api.github.com/users/someuser/repos",
config: cfgWithAuth,
expectedUser: "someuser", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Other Path (with Auth)",
rawPath: "https://api.github.com/octocat",
config: cfgWithAuth,
expectedUser: "", expectedRepo: "", expectedMatcher: "api",
},
{
name: "API Path (Force Allowed)",
rawPath: "https://api.github.com/repos/owner/repo",
config: cfgApiForceAllowed, // Auth disabled, but force allowed
expectedUser: "owner", expectedRepo: "repo", expectedMatcher: "api",
},
{
name: "Malformed GH Path (no repo)",
rawPath: "https://github.com/owner/",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "Malformed GH Path (no action)",
rawPath: "https://github.com/owner/repo",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "Malformed GH Path (empty user)",
rawPath: "https://github.com//repo/blob/main/file.go",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "Malformed Raw Path (no repo)",
rawPath: "https://raw.githubusercontent.com/owner/",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "Malformed Gist Path (no user)",
rawPath: "https://gist.github.com/",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "Unsupported GH Action",
rawPath: "https://github.com/owner/repo/issues/123",
config: cfgWithAuth,
expectError: true, expectedErrCode: 400,
},
{
name: "API Path (No Auth)",
rawPath: "https://api.github.com/user",
config: cfgNoAuth,
expectError: true, expectedErrCode: 403,
},
{
name: "API Path (Wrong Auth Method)",
rawPath: "https://api.github.com/user",
config: cfgWrongAuthMethod,
expectError: true, expectedErrCode: 403,
},
{
name: "No Matcher Found (other domain)",
rawPath: "https://bitbucket.org/owner/repo",
config: cfgWithAuth,
expectError: true, expectedErrCode: 404,
},
{
name: "No Matcher Found (path too short)",
rawPath: "https://a.co",
config: cfgWithAuth,
expectError: true, expectedErrCode: 404,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
user, repo, matcher, ghErr := Matcher(tc.rawPath, tc.config)
if tc.expectError {
if ghErr == nil {
t.Fatalf("Expected a GHProxyErrors error, but got nil")
}
if ghErr.StatusCode != tc.expectedErrCode {
t.Errorf("Expected error code %d, but got %d (msg: %s)",
tc.expectedErrCode, ghErr.StatusCode, ghErr.ErrorMessage)
}
} else {
if ghErr != nil {
t.Fatalf("Expected no error, but got: %s", ghErr.ErrorMessage)
}
if user != tc.expectedUser {
t.Errorf("user: got %q, want %q", user, tc.expectedUser)
}
if repo != tc.expectedRepo {
t.Errorf("repo: got %q, want %q", repo, tc.expectedRepo)
}
if matcher != tc.expectedMatcher {
t.Errorf("matcher: got %q, want %q", matcher, tc.expectedMatcher)
}
}
})
}
}
func TestExtractParts_Compatibility(t *testing.T) {
testCases := []struct {
name string
rawURL string
expectedOwner string
expectedRepo string
expectedRem string
expectedQuery url.Values
expectError bool
}{
{
name: "Standard git clone URL",
rawURL: "https://github.com/WJQSERVER-STUDIO/go-utils.git/info/refs?service=git-upload-pack",
expectedOwner: "/WJQSERVER-STUDIO",
expectedRepo: "/go-utils.git",
expectedRem: "/info/refs",
expectedQuery: url.Values{"service": []string{"git-upload-pack"}},
},
{
name: "No remaining path",
rawURL: "https://example.com/owner/repo",
expectedOwner: "/owner",
expectedRepo: "/repo",
expectedRem: "",
expectedQuery: url.Values{},
},
{
name: "Root path only",
rawURL: "https://example.com/",
expectError: true, // Path is too short
},
{
name: "One level path",
rawURL: "https://example.com/owner",
expectError: true, // Path is too short
},
{
name: "Empty path segments",
rawURL: "https://example.com//repo/a", // Will be treated as /repo/a
expectedOwner: "", // First part is empty
expectedRepo: "/repo",
expectedRem: "/a",
},
{
name: "Invalid URL format",
rawURL: "://invalid",
expectError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
owner, repo, rem, query, err := extractParts(tc.rawURL)
if (err != nil) != tc.expectError {
t.Fatalf("extractParts() error = %v, expectError %v", err, tc.expectError)
}
if !tc.expectError {
if owner != tc.expectedOwner {
t.Errorf("owner: got %q, want %q", owner, tc.expectedOwner)
}
if repo != tc.expectedRepo {
t.Errorf("repo: got %q, want %q", repo, tc.expectedRepo)
}
if rem != tc.expectedRem {
t.Errorf("remaining path: got %q, want %q", rem, tc.expectedRem)
}
if !reflect.DeepEqual(query, tc.expectedQuery) {
t.Errorf("query: got %v, want %v", query, tc.expectedQuery)
}
}
})
}
}
func TestMatchString_Compatibility(t *testing.T) {
testCases := []struct {
target string
expected bool
}{
{"blob", true}, {"raw", true}, {"gist", true},
{"clone", false}, {"releases", false},
}
for _, tc := range testCases {
t.Run(tc.target, func(t *testing.T) {
if got := matchString(tc.target); got != tc.expected {
t.Errorf("matchString('%s') = %v; want %v", tc.target, got, tc.expected)
}
})
}
}
func BenchmarkMatcher(b *testing.B) {
cfg := &config.Config{}
path := "https://github.com/WJQSERVER/speedtest-ex/releases/download/v1.2.0/speedtest-linux-amd64.tar.gz"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _, _, _ = Matcher(path, cfg)
}
}