diff --git a/CHANGELOG.md b/CHANGELOG.md index 7922247..a36f5a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEV-VERSION b/DEV-VERSION index c4bec82..ebe0357 100644 --- a/DEV-VERSION +++ b/DEV-VERSION @@ -1 +1 @@ -25w44a \ No newline at end of file +25w45a \ No newline at end of file diff --git a/VERSION b/VERSION index e5b8203..3c8ff8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.5.0 \ No newline at end of file +3.5.1 \ No newline at end of file diff --git a/go.mod b/go.mod index 90b925e..1ccfe73 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 834e6c7..4881472 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/proxy/chunkreq.go b/proxy/chunkreq.go index c760b9a..c50b7fb 100644 --- a/proxy/chunkreq.go +++ b/proxy/chunkreq.go @@ -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" { diff --git a/proxy/match.go b/proxy/match.go index cee4eaa..896a99b 100644 --- a/proxy/match.go +++ b/proxy/match.go @@ -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'"]+`) diff --git a/proxy/matcher_test.go b/proxy/matcher_test.go new file mode 100644 index 0000000..3293817 --- /dev/null +++ b/proxy/matcher_test.go @@ -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) + } +}