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>
156 lines
4.2 KiB
Go
156 lines
4.2 KiB
Go
package goproxy
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
modzip "golang.org/x/mod/zip"
|
|
)
|
|
|
|
// errNotFound means the repository, revision or file does not exist (or is
|
|
// not visible with the configured credentials): the next rule may still
|
|
// serve the module. Any other source error is an outage.
|
|
var errNotFound = errors.New("not found")
|
|
|
|
type tag struct {
|
|
Name string // without refs/tags/
|
|
Hash string // commit the tag points at, peeled
|
|
}
|
|
|
|
type commit struct {
|
|
Hash string // full hash
|
|
Time time.Time
|
|
}
|
|
|
|
// source reads one git repository. Git-backed rules build every GOPROXY
|
|
// answer from these four operations.
|
|
type source interface {
|
|
// Tags lists the repository's tags.
|
|
Tags(ctx context.Context) ([]tag, error)
|
|
// Commit resolves rev — a full or abbreviated commit hash, a branch or
|
|
// a tag name, or "" for the default branch — to a commit.
|
|
Commit(ctx context.Context, rev string) (commit, error)
|
|
// ReadFile returns the contents of name at commit hash, or errNotFound.
|
|
ReadFile(ctx context.Context, hash, name string) ([]byte, error)
|
|
// Tree returns every file of the commit, paths relative to the repo
|
|
// root. The caller closes it.
|
|
Tree(ctx context.Context, hash string) (tree, error)
|
|
}
|
|
|
|
// tree is the file listing of one commit.
|
|
type tree interface {
|
|
Files() []modzip.File
|
|
io.Closer
|
|
}
|
|
|
|
// archiveTree is a tree read from a forge's zip download of a commit,
|
|
// spooled to a temp file so large repos do not sit in memory. Forges put
|
|
// everything under one top-level directory, which is stripped.
|
|
type archiveTree struct {
|
|
f *os.File
|
|
zr *zip.Reader
|
|
files []modzip.File
|
|
}
|
|
|
|
func (t *archiveTree) Files() []modzip.File { return t.files }
|
|
|
|
func (t *archiveTree) Close() error {
|
|
t.f.Close()
|
|
return os.Remove(t.f.Name())
|
|
}
|
|
|
|
// downloadArchive GETs a zip archive into a temp file and lists it.
|
|
func downloadArchive(ctx context.Context, c *http.Client, url string, hdr http.Header) (*archiveTree, error) {
|
|
resp, err := get(ctx, c, url, hdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
f, err := os.CreateTemp("", "tiny-proxy-go-*.zip")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t := &archiveTree{f: f}
|
|
size, err := io.Copy(f, resp.Body)
|
|
if err != nil {
|
|
t.Close()
|
|
return nil, fmt.Errorf("download archive: %w", err)
|
|
}
|
|
t.zr, err = zip.NewReader(f, size)
|
|
if err != nil {
|
|
t.Close()
|
|
return nil, fmt.Errorf("read archive: %w", err)
|
|
}
|
|
for _, zf := range t.zr.File {
|
|
_, name, ok := strings.Cut(zf.Name, "/")
|
|
if !ok || name == "" || strings.HasSuffix(name, "/") {
|
|
continue
|
|
}
|
|
t.files = append(t.files, zipEntry{name: name, f: zf})
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
type zipEntry struct {
|
|
name string
|
|
f *zip.File
|
|
}
|
|
|
|
func (e zipEntry) Path() string { return e.name }
|
|
func (e zipEntry) Lstat() (fs.FileInfo, error) { return e.f.FileInfo(), nil }
|
|
func (e zipEntry) Open() (io.ReadCloser, error) { return e.f.Open() }
|
|
|
|
// get performs a GET with extra headers, mapping 404 (and the 400/422 some
|
|
// forges answer for unknown refs) to errNotFound and other non-2xx
|
|
// statuses to errors.
|
|
func get(ctx context.Context, c *http.Client, url string, hdr http.Header) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range hdr {
|
|
req.Header[k] = v
|
|
}
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode/100 == 2 {
|
|
return resp, nil
|
|
}
|
|
resp.Body.Close()
|
|
switch resp.StatusCode {
|
|
case http.StatusNotFound, http.StatusBadRequest, http.StatusUnprocessableEntity:
|
|
return nil, errNotFound
|
|
}
|
|
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
|
}
|
|
|
|
// getBytes is get plus reading the body, capped at limit bytes.
|
|
func getBytes(ctx context.Context, c *http.Client, url string, hdr http.Header, limit int64) ([]byte, error) {
|
|
resp, err := get(ctx, c, url, hdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return nil, fmt.Errorf("GET %s: response larger than %d bytes", url, limit)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// apiLimit caps API responses and go.mod files read into memory.
|
|
const apiLimit = 16 << 20
|