A go vrepo serves the GOPROXY protocol at /go/<vrepo>/. Its upstreams
are ordered rules matched by module path (GOPRIVATE-style patterns);
each one relays to another module proxy, or builds .info/.mod/.zip,
@v/list and @latest itself from a git repository: through the Gitea
or GitLab REST API, or over git smart-HTTP (go-git) for any other
host, without a git binary. Module layout follows the go command:
<dir>/vX.Y.Z tags, /vN major subdirectories, +incompatible, nested
modules and vendor/ left out of zips. go.mod is served as committed,
so forks keeping their upstream module path work through replace.
Repo templates map captured pattern elements to repository paths
({1}, {2}...). vanity_hosts answers ?go-get=1 with a go-import meta
tag pointing at this proxy or at the repository.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
661 lines
22 KiB
Go
661 lines
22 KiB
Go
package goproxy
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"tiny-proxy/internal/config"
|
|
"tiny-proxy/internal/proxycache"
|
|
"tiny-proxy/internal/storage"
|
|
)
|
|
|
|
// fakeCommit is one commit of a fake repository: its full file set.
|
|
type fakeCommit struct {
|
|
hash string
|
|
time time.Time
|
|
files map[string]string
|
|
}
|
|
|
|
// fakeRepo is a repository a fake forge serves.
|
|
type fakeRepo struct {
|
|
commits []*fakeCommit // oldest first; the default branch is the last
|
|
tags map[string]string
|
|
branches map[string]string
|
|
}
|
|
|
|
func (r *fakeRepo) rev(rev string) *fakeCommit {
|
|
if h, ok := r.branches[rev]; ok {
|
|
rev = h
|
|
} else if h, ok := r.tags[rev]; ok {
|
|
rev = h
|
|
}
|
|
if len(rev) < 7 {
|
|
return nil
|
|
}
|
|
for _, c := range r.commits {
|
|
if strings.HasPrefix(c.hash, rev) {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
t1 = time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
t2 = t1.Add(24 * time.Hour)
|
|
t3 = t2.Add(24 * time.Hour)
|
|
)
|
|
|
|
// libFiles is a repo holding a root module, a nested module in sub/ and a
|
|
// /v2 module in the v2/ major subdirectory.
|
|
func libFiles(lib string) map[string]string {
|
|
return map[string]string{
|
|
"go.mod": "module example.com/lib\n",
|
|
"lib.go": lib,
|
|
"LICENSE": "MIT\n",
|
|
"sub/go.mod": "module example.com/lib/sub\n",
|
|
"sub/sub.go": "package sub\n",
|
|
"vendor/x/x.go": "package x\n",
|
|
"v2/go.mod": "module example.com/lib/v2\n",
|
|
"v2/lib.go": "package lib\n",
|
|
"docs/README.md": "docs\n",
|
|
}
|
|
}
|
|
|
|
func newLibRepo() *fakeRepo {
|
|
a := &fakeCommit{hash: strings.Repeat("a", 40), time: t1, files: libFiles("package lib // A\n")}
|
|
b := &fakeCommit{hash: strings.Repeat("b", 40), time: t2, files: libFiles("package lib // B\n")}
|
|
d := &fakeCommit{hash: "d" + strings.Repeat("0123456789", 3) + "abcdefabc", time: t3, files: libFiles("package lib // D\n")}
|
|
return &fakeRepo{
|
|
commits: []*fakeCommit{a, b, d},
|
|
tags: map[string]string{"v1.0.0": a.hash, "sub/v0.1.0": a.hash, "v2.0.0": a.hash, "v1.1.0-rc.1": b.hash, "not-semver": a.hash},
|
|
branches: map[string]string{"main": d.hash},
|
|
}
|
|
}
|
|
|
|
// newLegacyRepo has no go.mod: its v3 tag is a +incompatible version.
|
|
func newLegacyRepo() *fakeRepo {
|
|
c := &fakeCommit{hash: strings.Repeat("c", 40), time: t1, files: map[string]string{"legacy.go": "package legacy\n"}}
|
|
return &fakeRepo{
|
|
commits: []*fakeCommit{c},
|
|
tags: map[string]string{"v1.0.0": c.hash, "v3.0.0": c.hash},
|
|
branches: map[string]string{"main": c.hash},
|
|
}
|
|
}
|
|
|
|
// forge is a fake Gitea or GitLab server over a set of repos keyed by
|
|
// "owner/name".
|
|
type forge struct {
|
|
srv *httptest.Server
|
|
repos map[string]*fakeRepo
|
|
hits atomic.Int32
|
|
mu sync.Mutex
|
|
auth []string // Authorization (or PRIVATE-TOKEN) headers seen
|
|
}
|
|
|
|
func newForge(t *testing.T, kind config.GoKind, repos map[string]*fakeRepo) *forge {
|
|
f := &forge{repos: repos}
|
|
var h http.HandlerFunc
|
|
if kind == config.GoKindGitea {
|
|
h = f.gitea
|
|
} else {
|
|
h = f.gitlab
|
|
}
|
|
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.hits.Add(1)
|
|
f.mu.Lock()
|
|
f.auth = append(f.auth, r.Header.Get("Authorization"))
|
|
f.mu.Unlock()
|
|
h(w, r)
|
|
}))
|
|
t.Cleanup(f.srv.Close)
|
|
return f
|
|
}
|
|
|
|
func (f *forge) sawAuth(want string) bool {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return slices.Contains(f.auth, want)
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func archive(w http.ResponseWriter, top string, c *fakeCommit) {
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
zw.Create(top + "/")
|
|
for _, name := range sortedKeys(c.files) {
|
|
fw, _ := zw.Create(top + "/" + name)
|
|
io.WriteString(fw, c.files[name])
|
|
}
|
|
zw.Close()
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Write(buf.Bytes())
|
|
}
|
|
|
|
func sortedKeys(m map[string]string) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
func (f *forge) gitea(w http.ResponseWriter, r *http.Request) {
|
|
rest, ok := strings.CutPrefix(r.URL.Path, "/api/v1/repos/")
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
parts := strings.SplitN(rest, "/", 3)
|
|
if len(parts) < 2 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
name := parts[0] + "/" + parts[1]
|
|
repo, ok := f.repos[name]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
sub := ""
|
|
if len(parts) == 3 {
|
|
sub = parts[2]
|
|
}
|
|
q := r.URL.Query()
|
|
switch {
|
|
case sub == "":
|
|
writeJSON(w, map[string]string{"default_branch": "main"})
|
|
case sub == "tags":
|
|
page := q.Get("page")
|
|
type tagJSON struct {
|
|
Name string `json:"name"`
|
|
Commit map[string]string `json:"commit"`
|
|
}
|
|
var out []tagJSON
|
|
if page == "1" {
|
|
for _, n := range sortedKeys(repo.tags) {
|
|
out = append(out, tagJSON{Name: n, Commit: map[string]string{"sha": repo.tags[n]}})
|
|
}
|
|
}
|
|
writeJSON(w, out)
|
|
case sub == "commits":
|
|
c := repo.rev(q.Get("sha"))
|
|
if c == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
writeJSON(w, []any{map[string]any{"sha": c.hash, "commit": map[string]any{"committer": map[string]any{"date": c.time}}}})
|
|
case strings.HasPrefix(sub, "raw/"):
|
|
c := repo.rev(q.Get("ref"))
|
|
data, ok := "", false
|
|
if c != nil {
|
|
data, ok = c.files[strings.TrimPrefix(sub, "raw/")]
|
|
}
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
io.WriteString(w, data)
|
|
case strings.HasPrefix(sub, "archive/") && strings.HasSuffix(sub, ".zip"):
|
|
c := repo.rev(strings.TrimSuffix(strings.TrimPrefix(sub, "archive/"), ".zip"))
|
|
if c == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
archive(w, parts[1], c)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func (f *forge) gitlab(w http.ResponseWriter, r *http.Request) {
|
|
// The project id is the URL-encoded full path: work on the raw path.
|
|
rest, ok := strings.CutPrefix(r.URL.EscapedPath(), "/api/v4/projects/")
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
id, sub, _ := strings.Cut(rest, "/")
|
|
name, _ := url.PathUnescape(id)
|
|
repo, ok := f.repos[name]
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
switch {
|
|
case sub == "":
|
|
writeJSON(w, map[string]string{"default_branch": "main"})
|
|
case sub == "repository/tags":
|
|
type tagJSON struct {
|
|
Name string `json:"name"`
|
|
Commit map[string]string `json:"commit"`
|
|
}
|
|
var out []tagJSON
|
|
if q.Get("page") == "1" {
|
|
for _, n := range sortedKeys(repo.tags) {
|
|
out = append(out, tagJSON{Name: n, Commit: map[string]string{"id": repo.tags[n]}})
|
|
}
|
|
}
|
|
writeJSON(w, out)
|
|
case strings.HasPrefix(sub, "repository/commits/"):
|
|
ref, _ := url.PathUnescape(strings.TrimPrefix(sub, "repository/commits/"))
|
|
c := repo.rev(ref)
|
|
if c == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"id": c.hash, "committed_date": c.time})
|
|
case strings.HasPrefix(sub, "repository/files/") && strings.HasSuffix(sub, "/raw"):
|
|
file, _ := url.PathUnescape(strings.TrimSuffix(strings.TrimPrefix(sub, "repository/files/"), "/raw"))
|
|
c := repo.rev(q.Get("ref"))
|
|
data, ok := "", false
|
|
if c != nil {
|
|
data, ok = c.files[file]
|
|
}
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
io.WriteString(w, data)
|
|
case sub == "repository/archive.zip":
|
|
c := repo.rev(q.Get("sha"))
|
|
if c == nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
archive(w, name[strings.LastIndex(name, "/")+1:]+"-"+c.hash, c)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}
|
|
|
|
func newTestHandler(t *testing.T, vrepo config.VRepo) *Handler {
|
|
t.Helper()
|
|
ups := vrepo.AllUpstreams()
|
|
clients := map[string][]*http.Client{}
|
|
for range ups {
|
|
clients[vrepo.Name] = append(clients[vrepo.Name], http.DefaultClient)
|
|
}
|
|
store, err := storage.NewLocalFS(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return NewHandler([]config.VRepo{vrepo}, clients, proxycache.New(store))
|
|
}
|
|
|
|
func goRepo(ups ...config.UpstreamConf) config.VRepo {
|
|
for i := range ups {
|
|
if ups[i].Kind == "" {
|
|
ups[i].Kind = config.GoKindGoproxy
|
|
}
|
|
if ups[i].GoGet == "" {
|
|
ups[i].GoGet = config.GoGetMod
|
|
}
|
|
}
|
|
return config.VRepo{
|
|
Name: "go", Type: config.VRepoGo, Upstreams: ups,
|
|
Auth: config.AuthConfig{Mode: config.AuthPublic},
|
|
Cache: config.CacheConfig{MetadataTTL: time.Minute, NotFoundTTL: time.Minute},
|
|
}
|
|
}
|
|
|
|
func serve(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
return w
|
|
}
|
|
|
|
func wantBody(t *testing.T, h http.Handler, path string, want string) {
|
|
t.Helper()
|
|
w := serve(t, h, path)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("GET %s = %d %s", path, w.Code, w.Body)
|
|
}
|
|
if got := w.Body.String(); got != want {
|
|
t.Errorf("GET %s =\n%q\nwant\n%q", path, got, want)
|
|
}
|
|
}
|
|
|
|
func wantStatus(t *testing.T, h http.Handler, path string, want int) {
|
|
t.Helper()
|
|
if w := serve(t, h, path); w.Code != want {
|
|
t.Errorf("GET %s = %d %s, want %d", path, w.Code, w.Body, want)
|
|
}
|
|
}
|
|
|
|
func zipNames(t *testing.T, body []byte) []string {
|
|
t.Helper()
|
|
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var names []string
|
|
for _, f := range zr.File {
|
|
names = append(names, f.Name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
func infoJSON(v string, t time.Time) string {
|
|
data, _ := json.Marshal(info{Version: v, Time: t})
|
|
return string(data) + "\n"
|
|
}
|
|
|
|
// TestForges runs the same GOPROXY scenarios against the Gitea and GitLab
|
|
// sources.
|
|
func TestForges(t *testing.T) {
|
|
for _, kind := range []config.GoKind{config.GoKindGitea, config.GoKindGitlab} {
|
|
t.Run(string(kind), func(t *testing.T) {
|
|
f := newForge(t, kind, map[string]*fakeRepo{"Team/lib": newLibRepo(), "Team/legacy": newLegacyRepo()})
|
|
h := newTestHandler(t, goRepo(config.UpstreamConf{
|
|
Kind: kind, URL: f.srv.URL, Modules: []string{"go.test/*"}, Repo: "Team/{1}",
|
|
}))
|
|
const base = "/go/go/go.test"
|
|
|
|
wantBody(t, h, base+"/lib/@v/list", "v1.0.0\nv1.1.0-rc.1\n")
|
|
wantBody(t, h, base+"/lib/@latest", infoJSON("v1.0.0", t1))
|
|
wantBody(t, h, base+"/lib/@v/v1.0.0.info", infoJSON("v1.0.0", t1))
|
|
wantBody(t, h, base+"/lib/@v/v1.0.0.mod", "module example.com/lib\n")
|
|
// A tagged branch head is its release; an untagged one a
|
|
// pseudo-version, which then resolves by its short hash.
|
|
wantBody(t, h, base+"/lib/@v/bbbbbbbbbbbb.info", infoJSON("v1.1.0-rc.1", t2))
|
|
pseudo := "v0.0.0-20250104030405-d01234567890"
|
|
wantBody(t, h, base+"/lib/@v/main.info", infoJSON(pseudo, t3))
|
|
wantBody(t, h, base+"/lib/@v/"+pseudo+".mod", "module example.com/lib\n")
|
|
|
|
w := serve(t, h, base+"/lib/@v/v1.0.0.zip")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("zip = %d %s", w.Code, w.Body)
|
|
}
|
|
// Nested modules (sub/, v2/) and vendor/ are left out.
|
|
p := "go.test/lib@v1.0.0/"
|
|
if got, want := zipNames(t, w.Body.Bytes()), []string{p + "LICENSE", p + "docs/README.md", p + "go.mod", p + "lib.go"}; !slices.Equal(got, want) {
|
|
t.Errorf("zip files = %v, want %v", got, want)
|
|
}
|
|
|
|
// sub/ is its own module with sub/-prefixed tags, and gets the
|
|
// repo's LICENSE.
|
|
wantBody(t, h, base+"/lib/sub/@v/list", "v0.1.0\n")
|
|
w = serve(t, h, base+"/lib/sub/@v/v0.1.0.zip")
|
|
p = "go.test/lib/sub@v0.1.0/"
|
|
if got, want := zipNames(t, w.Body.Bytes()), []string{p + "LICENSE", p + "go.mod", p + "sub.go"}; !slices.Equal(got, want) {
|
|
t.Errorf("sub zip files = %v, want %v", got, want)
|
|
}
|
|
|
|
// /v2 lives in the v2/ major subdirectory.
|
|
wantBody(t, h, base+"/lib/v2/@v/list", "v2.0.0\n")
|
|
wantBody(t, h, base+"/lib/v2/@v/v2.0.0.mod", "module example.com/lib/v2\n")
|
|
w = serve(t, h, base+"/lib/v2/@v/v2.0.0.zip")
|
|
p = "go.test/lib/v2@v2.0.0/"
|
|
if got, want := zipNames(t, w.Body.Bytes()), []string{p + "LICENSE", p + "go.mod", p + "lib.go"}; !slices.Equal(got, want) {
|
|
t.Errorf("v2 zip files = %v, want %v", got, want)
|
|
}
|
|
// v2.0.0 of the root module has a go.mod: not +incompatible.
|
|
wantStatus(t, h, base+"/lib/@v/v2.0.0+incompatible.info", http.StatusNotFound)
|
|
wantStatus(t, h, base+"/lib/@v/v2.0.0.info", http.StatusNotFound)
|
|
|
|
// Without go.mod, v3 is +incompatible and gets a synthesized one.
|
|
wantBody(t, h, base+"/legacy/@v/list", "v1.0.0\nv3.0.0+incompatible\n")
|
|
wantBody(t, h, base+"/legacy/@v/v3.0.0+incompatible.mod", "module go.test/legacy\n")
|
|
|
|
wantStatus(t, h, base+"/missing/@v/list", http.StatusNotFound)
|
|
wantStatus(t, h, base+"/lib/@v/v9.9.9.info", http.StatusNotFound)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVersionedFilesCachedForever(t *testing.T) {
|
|
f := newForge(t, config.GoKindGitea, map[string]*fakeRepo{"o/lib": newLibRepo()})
|
|
h := newTestHandler(t, goRepo(config.UpstreamConf{Kind: config.GoKindGitea, URL: f.srv.URL, Modules: []string{"go.test/*"}, Repo: "o/{1}"}))
|
|
wantStatus(t, h, "/go/go/go.test/lib/@v/v1.0.0.zip", http.StatusOK)
|
|
hits := f.hits.Load()
|
|
wantStatus(t, h, "/go/go/go.test/lib/@v/v1.0.0.zip", http.StatusOK)
|
|
if f.hits.Load() != hits {
|
|
t.Errorf("second zip request reached the forge")
|
|
}
|
|
}
|
|
|
|
func TestForgeAuth(t *testing.T) {
|
|
repos := map[string]*fakeRepo{"o/lib": newLibRepo()}
|
|
gitea := newForge(t, config.GoKindGitea, repos)
|
|
gitlab := newForge(t, config.GoKindGitlab, repos)
|
|
h := newTestHandler(t, goRepo(
|
|
config.UpstreamConf{Kind: config.GoKindGitea, URL: gitea.srv.URL, Modules: []string{"gitea.test/*"}, Repo: "o/{1}", Password: "gt-token"},
|
|
config.UpstreamConf{Kind: config.GoKindGitlab, URL: gitlab.srv.URL, Modules: []string{"gitlab.test/*"}, Repo: "o/{1}", Password: "gl-token"},
|
|
))
|
|
wantStatus(t, h, "/go/go/gitea.test/lib/@v/list", http.StatusOK)
|
|
wantStatus(t, h, "/go/go/gitlab.test/lib/@v/list", http.StatusOK)
|
|
if !gitea.sawAuth("token gt-token") {
|
|
t.Errorf("gitea saw %v, want a token header", gitea.auth)
|
|
}
|
|
if !gitlab.sawAuth("Bearer gl-token") {
|
|
t.Errorf("gitlab saw %v, want a Bearer header", gitlab.auth)
|
|
}
|
|
}
|
|
|
|
// stubProxy is a GOPROXY upstream answering from a path → body map.
|
|
func stubProxy(t *testing.T, missStatus int, files map[string]string) (*httptest.Server, *atomic.Int32) {
|
|
var hits atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
if body, ok := files[r.URL.Path]; ok {
|
|
io.WriteString(w, body)
|
|
return
|
|
}
|
|
http.Error(w, "not found", missStatus)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
return srv, &hits
|
|
}
|
|
|
|
func TestRuleOrderAndFallthrough(t *testing.T) {
|
|
f := newForge(t, config.GoKindGitea, map[string]*fakeRepo{"o/lib": newLibRepo()})
|
|
// The first proxy has nothing (410, as proxy.golang.org answers), the
|
|
// second serves the public module.
|
|
empty, emptyHits := stubProxy(t, http.StatusGone, nil)
|
|
public, _ := stubProxy(t, http.StatusNotFound, map[string]string{
|
|
"/rsc.io/quote/@v/list": "v1.5.2\n",
|
|
"/go.test/other/@v/list": "v0.1.0\n",
|
|
"/github.com/!burnt!sushi/toml/@v/list": "v1.0.0\n",
|
|
})
|
|
h := newTestHandler(t, goRepo(
|
|
config.UpstreamConf{Kind: config.GoKindGitea, URL: f.srv.URL, Modules: []string{"go.test/*"}, Repo: "o/{1}"},
|
|
config.UpstreamConf{URL: empty.URL},
|
|
config.UpstreamConf{URL: public.URL},
|
|
))
|
|
wantBody(t, h, "/go/go/rsc.io/quote/@v/list", "v1.5.2\n")
|
|
if emptyHits.Load() != 1 {
|
|
t.Errorf("empty proxy hits = %d, want 1", emptyHits.Load())
|
|
}
|
|
// Not in the gitea repo set: falls through to the proxies.
|
|
wantBody(t, h, "/go/go/go.test/other/@v/list", "v0.1.0\n")
|
|
// Served from git, never asked of the proxies.
|
|
before := emptyHits.Load()
|
|
wantBody(t, h, "/go/go/go.test/lib/@v/list", "v1.0.0\nv1.1.0-rc.1\n")
|
|
if emptyHits.Load() != before {
|
|
t.Error("git-served module reached the proxies")
|
|
}
|
|
// Case-encoded paths are relayed as sent.
|
|
wantBody(t, h, "/go/go/github.com/!burnt!sushi/toml/@v/list", "v1.0.0\n")
|
|
}
|
|
|
|
func TestUpstreamOutageIsNotNotFound(t *testing.T) {
|
|
down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "boom", http.StatusInternalServerError)
|
|
}))
|
|
t.Cleanup(down.Close)
|
|
h := newTestHandler(t, goRepo(config.UpstreamConf{Kind: config.GoKindGitea, URL: down.URL, Modules: []string{"go.test/*"}, Repo: "o/{1}"}))
|
|
wantStatus(t, h, "/go/go/go.test/lib/@v/list", http.StatusBadGateway)
|
|
}
|
|
|
|
func TestNoRule(t *testing.T) {
|
|
f := newForge(t, config.GoKindGitea, map[string]*fakeRepo{"o/lib": newLibRepo()})
|
|
h := newTestHandler(t, goRepo(config.UpstreamConf{Kind: config.GoKindGitea, URL: f.srv.URL, Modules: []string{"go.test/*"}, Repo: "o/{1}"}))
|
|
wantStatus(t, h, "/go/go/rsc.io/quote/@v/list", http.StatusNotFound)
|
|
if f.hits.Load() != 0 {
|
|
t.Error("unmatched module reached the forge")
|
|
}
|
|
}
|
|
|
|
func TestBadRequests(t *testing.T) {
|
|
h := newTestHandler(t, goRepo(config.UpstreamConf{URL: "http://127.0.0.1:1"}))
|
|
for _, p := range []string{
|
|
"/go/go/rsc.io/quote/@v/main.zip", // .zip needs a version
|
|
"/go/go/rsc.io/quote/@v/main.mod", // .mod too
|
|
"/go/go/rsc.io/quote/@v/v1.0.0.tar.gz", // unknown file
|
|
"/go/go/rsc.io/quote", // no @v
|
|
"/go/go/Rsc.io/quote/@v/list", // not case-encoded
|
|
"/go/nope/rsc.io/quote/@v/list", // unknown vrepo
|
|
} {
|
|
wantStatus(t, h, p, http.StatusNotFound)
|
|
}
|
|
wantStatus(t, h, "/go/go/../etc/@v/list", http.StatusBadRequest)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodPut, "/go/go/rsc.io/quote/@v/list", nil))
|
|
if w.Code != http.StatusMethodNotAllowed {
|
|
t.Errorf("PUT = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAuthEnforced(t *testing.T) {
|
|
public, _ := stubProxy(t, http.StatusNotFound, map[string]string{"/rsc.io/quote/@v/list": "v1.5.2\n"})
|
|
vr := goRepo(config.UpstreamConf{URL: public.URL})
|
|
vr.Auth = config.AuthConfig{Mode: config.AuthPassword, Password: "pw"}
|
|
h := newTestHandler(t, vr)
|
|
wantStatus(t, h, "/go/go/rsc.io/quote/@v/list", http.StatusUnauthorized)
|
|
r := httptest.NewRequest(http.MethodGet, "/go/go/rsc.io/quote/@v/list", nil)
|
|
r.SetBasicAuth("any", "pw")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, r)
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("with password = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestVanity(t *testing.T) {
|
|
vr := goRepo(
|
|
config.UpstreamConf{Kind: config.GoKindGitea, URL: "https://gitea.test", Modules: []string{"go.test/team/*"}, Repo: "Team/{1}"},
|
|
config.UpstreamConf{Kind: config.GoKindGit, URL: "https://git.test/", Modules: []string{"go.test/raw/*"}, Repo: "{1}", GoGet: config.GoGetGit},
|
|
config.UpstreamConf{URL: "https://proxy.golang.org"},
|
|
)
|
|
vr.VanityHosts = []string{"go.test"}
|
|
h := newTestHandler(t, vr)
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "next") })
|
|
v := h.Vanity(next)
|
|
|
|
lookup := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
|
|
r := httptest.NewRequest(http.MethodGet, "http://go.test"+path, nil)
|
|
for k, val := range hdr {
|
|
r.Header.Set(k, val)
|
|
}
|
|
w := httptest.NewRecorder()
|
|
v.ServeHTTP(w, r)
|
|
return w
|
|
}
|
|
w := lookup("/team/lib/v2/pkg?go-get=1", map[string]string{"X-Forwarded-Proto": "https"})
|
|
if want := `content="go.test/team/lib mod https://go.test/go/go"`; !strings.Contains(w.Body.String(), want) {
|
|
t.Errorf("mod vanity = %s, want %s", w.Body, want)
|
|
}
|
|
w = lookup("/raw/tool?go-get=1", nil)
|
|
if want := `content="go.test/raw/tool git https://git.test/tool.git"`; !strings.Contains(w.Body.String(), want) {
|
|
t.Errorf("git vanity = %s, want %s", w.Body, want)
|
|
}
|
|
if w := lookup("/elsewhere/x?go-get=1", nil); w.Code != http.StatusNotFound {
|
|
t.Errorf("unmatched vanity = %d", w.Code)
|
|
}
|
|
if w := lookup("/team/lib", nil); w.Body.String() != "next" {
|
|
t.Errorf("non go-get request = %q, want passed through", w.Body)
|
|
}
|
|
r := httptest.NewRequest(http.MethodGet, "http://other.test/team/lib?go-get=1", nil)
|
|
w = httptest.NewRecorder()
|
|
v.ServeHTTP(w, r)
|
|
if w.Body.String() != "next" {
|
|
t.Errorf("other host = %q, want passed through", w.Body)
|
|
}
|
|
}
|
|
|
|
func TestMatch(t *testing.T) {
|
|
cases := []struct {
|
|
pattern, mod string
|
|
caps []string
|
|
root, rest string
|
|
ok bool
|
|
}{
|
|
{"go.test/*", "go.test/lib", []string{"lib"}, "go.test/lib", "", true},
|
|
{"go.test/*", "go.test/lib/sub/v2", []string{"lib"}, "go.test/lib", "sub/v2", true},
|
|
{"go.test/*/*", "go.test/a/b/c", []string{"a", "b"}, "go.test/a/b", "c", true},
|
|
{"go.test/lib", "go.test/lib/x", nil, "go.test/lib", "x", true},
|
|
{"*.test/x-*", "go.test/x-y", []string{"go.test", "x-y"}, "go.test/x-y", "", true},
|
|
{"go.test/*", "go.test", nil, "", "", false},
|
|
{"go.test/*", "other.test/lib", nil, "", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
caps, root, rest, ok := match(c.pattern, c.mod)
|
|
if ok != c.ok || root != c.root || rest != c.rest || !slices.Equal(caps, c.caps) {
|
|
t.Errorf("match(%q, %q) = %v %q %q %v", c.pattern, c.mod, caps, root, rest, ok)
|
|
}
|
|
}
|
|
if got := expand("Team/{1}-{2}.git", []string{"a", "b"}); got != "Team/a-b.git" {
|
|
t.Errorf("expand = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestLayout(t *testing.T) {
|
|
cases := []struct{ mod, root, rest, codeDir, pathMajor string }{
|
|
{"go.test/lib", "go.test/lib", "", "", ""},
|
|
{"go.test/lib/v3", "go.test/lib", "v3", "", "/v3"},
|
|
{"go.test/lib/sub", "go.test/lib", "sub", "sub", ""},
|
|
{"go.test/lib/sub/v2", "go.test/lib", "sub/v2", "sub", "/v2"},
|
|
// The suffix is part of the repo path itself: no major layout.
|
|
{"go.test/v2", "go.test/v2", "", "", ""},
|
|
}
|
|
for _, c := range cases {
|
|
l := layoutFor(c.mod, c.root, c.rest)
|
|
if l.codeDir != c.codeDir || l.pathMajor != c.pathMajor {
|
|
t.Errorf("layoutFor(%q) = %+v", c.mod, l)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseRequest(t *testing.T) {
|
|
rq, ok := parseRequest("github.com/!burnt!sushi/toml/@v/v1.2.3-!r!c1.info")
|
|
if !ok || rq.mod != "github.com/BurntSushi/toml" || rq.version != "v1.2.3-RC1" || rq.ext != ".info" {
|
|
t.Errorf("parse = %+v %v", rq, ok)
|
|
}
|
|
if !rq.immutable() {
|
|
t.Error("v1.2.3-RC1.info not immutable")
|
|
}
|
|
rq, ok = parseRequest("go.test/lib/@latest")
|
|
if !ok || rq.file != "@latest" || rq.immutable() {
|
|
t.Errorf("latest = %+v %v", rq, ok)
|
|
}
|
|
rq, _ = parseRequest("go.test/lib/@v/v1.0.0.zip")
|
|
if !rq.immutable() {
|
|
t.Error("v1.0.0.zip not immutable")
|
|
}
|
|
rq, _ = parseRequest("go.test/lib/@v/main.info")
|
|
if rq.immutable() {
|
|
t.Error("main.info immutable")
|
|
}
|
|
}
|