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>
382 lines
10 KiB
Go
382 lines
10 KiB
Go
package goproxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/go-git/go-git/v5/plumbing/filemode"
|
|
"github.com/go-git/go-git/v5/plumbing/format/packfile"
|
|
"github.com/go-git/go-git/v5/plumbing/object"
|
|
"github.com/go-git/go-git/v5/plumbing/protocol/packp"
|
|
"github.com/go-git/go-git/v5/plumbing/protocol/packp/capability"
|
|
"github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband"
|
|
"github.com/go-git/go-git/v5/plumbing/transport"
|
|
githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
|
|
"github.com/go-git/go-git/v5/storage/memory"
|
|
modzip "golang.org/x/mod/zip"
|
|
)
|
|
|
|
// gitSource reads a repository over the git smart-HTTP protocol, in
|
|
// process: refs come from the ref advertisement, and a commit's files from
|
|
// a depth-1 fetch of just that commit into memory. Over HTTP git servers
|
|
// accept wants for any commit reachable from an advertised ref, so
|
|
// pseudo-versions of older commits work too as long as their full hash is
|
|
// known (see resolve).
|
|
type gitSource struct {
|
|
tr transport.Transport
|
|
ep *transport.Endpoint
|
|
auth transport.AuthMethod
|
|
|
|
mu sync.Mutex
|
|
refs *packp.AdvRefs
|
|
refsAt time.Time
|
|
known map[string]string // abbreviated → full hash, of commits seen
|
|
fetched []fetchedCommit // most recent last
|
|
fetching map[string]*sync.Mutex
|
|
}
|
|
|
|
type fetchedCommit struct {
|
|
hash string
|
|
st *memory.Storage
|
|
}
|
|
|
|
const (
|
|
// refsTTL bounds how stale the ref advertisement may be; the
|
|
// GOPROXY answers built from it have their own cache TTLs on top.
|
|
refsTTL = 30 * time.Second
|
|
// fetchedKeep is how many fetched commits stay in memory, so that the
|
|
// .info → .mod → .zip sequence the go command sends fetches once.
|
|
fetchedKeep = 4
|
|
)
|
|
|
|
func newGitSource(c *http.Client, base, repo, username, password string) (*gitSource, error) {
|
|
ep, err := transport.NewEndpoint(strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(repo, "/"))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s := &gitSource{
|
|
tr: githttp.NewClient(c),
|
|
ep: ep,
|
|
known: map[string]string{},
|
|
fetching: map[string]*sync.Mutex{},
|
|
}
|
|
if password != "" {
|
|
if username == "" {
|
|
username = "git" // forges ignore it for token auth
|
|
}
|
|
s.auth = &githttp.BasicAuth{Username: username, Password: password}
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *gitSource) advRefs(ctx context.Context) (*packp.AdvRefs, error) {
|
|
s.mu.Lock()
|
|
if s.refs != nil && time.Since(s.refsAt) < refsTTL {
|
|
refs := s.refs
|
|
s.mu.Unlock()
|
|
return refs, nil
|
|
}
|
|
s.mu.Unlock()
|
|
sess, err := s.tr.NewUploadPackSession(s.ep, s.auth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer sess.Close()
|
|
refs, err := sess.AdvertisedReferencesContext(ctx)
|
|
if err != nil {
|
|
return nil, gitErr(err)
|
|
}
|
|
s.mu.Lock()
|
|
s.refs, s.refsAt = refs, time.Now()
|
|
s.mu.Unlock()
|
|
return refs, nil
|
|
}
|
|
|
|
// gitErr maps go-git's "no such repository" errors to errNotFound.
|
|
func gitErr(err error) error {
|
|
switch {
|
|
case errors.Is(err, transport.ErrRepositoryNotFound),
|
|
errors.Is(err, transport.ErrEmptyRemoteRepository),
|
|
errors.Is(err, transport.ErrAuthenticationRequired),
|
|
errors.Is(err, transport.ErrAuthorizationFailed):
|
|
return fmt.Errorf("%w: %v", errNotFound, err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// peeled returns the commit a ref points at: the peeled target for an
|
|
// annotated tag, the ref's own hash otherwise.
|
|
func peeled(refs *packp.AdvRefs, name string) (plumbing.Hash, bool) {
|
|
if h, ok := refs.Peeled[name]; ok {
|
|
return h, true
|
|
}
|
|
h, ok := refs.References[name]
|
|
return h, ok
|
|
}
|
|
|
|
func (s *gitSource) Tags(ctx context.Context) ([]tag, error) {
|
|
refs, err := s.advRefs(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var tags []tag
|
|
for name := range refs.References {
|
|
if t, ok := strings.CutPrefix(name, "refs/tags/"); ok {
|
|
h, _ := peeled(refs, name)
|
|
tags = append(tags, tag{Name: t, Hash: h.String()})
|
|
}
|
|
}
|
|
return tags, nil
|
|
}
|
|
|
|
// resolve turns rev into a full commit hash using the ref advertisement
|
|
// and the commits seen so far.
|
|
func (s *gitSource) resolve(ctx context.Context, rev string) (string, error) {
|
|
refs, err := s.advRefs(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if rev == "" {
|
|
if refs.Head != nil {
|
|
return refs.Head.String(), nil
|
|
}
|
|
return "", errNotFound
|
|
}
|
|
for _, name := range []string{"refs/heads/" + rev, "refs/tags/" + rev, rev} {
|
|
if h, ok := peeled(refs, name); ok {
|
|
return h.String(), nil
|
|
}
|
|
}
|
|
if !isHex(rev) || len(rev) < 7 || len(rev) > 40 {
|
|
return "", errNotFound
|
|
}
|
|
rev = strings.ToLower(rev)
|
|
if len(rev) == 40 {
|
|
return rev, nil
|
|
}
|
|
// An abbreviated hash: a ref tip, or a commit seen before (typically
|
|
// the one a pseudo-version the go command now asks about was built
|
|
// from).
|
|
for name := range refs.References {
|
|
if h, _ := peeled(refs, name); strings.HasPrefix(h.String(), rev) {
|
|
return h.String(), nil
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
full, ok := s.known[rev]
|
|
s.mu.Unlock()
|
|
if ok {
|
|
return full, nil
|
|
}
|
|
return "", errNotFound
|
|
}
|
|
|
|
func isHex(s string) bool {
|
|
for _, c := range s {
|
|
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
|
|
return false
|
|
}
|
|
}
|
|
return s != ""
|
|
}
|
|
|
|
func (s *gitSource) Commit(ctx context.Context, rev string) (commit, error) {
|
|
hash, err := s.resolve(ctx, rev)
|
|
if err != nil {
|
|
return commit{}, err
|
|
}
|
|
c, err := s.commitObject(ctx, hash)
|
|
if err != nil {
|
|
return commit{}, err
|
|
}
|
|
full := c.Hash.String()
|
|
s.mu.Lock()
|
|
s.known[full[:12]] = full
|
|
s.mu.Unlock()
|
|
return commit{Hash: full, Time: c.Committer.When}, nil
|
|
}
|
|
|
|
// commitObject fetches hash and returns its commit, peeling an annotated
|
|
// tag (servers that do not advertise peeled refs leave tag hashes in Tags).
|
|
func (s *gitSource) commitObject(ctx context.Context, hash string) (*object.Commit, error) {
|
|
st, err := s.fetch(ctx, hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
obj, err := object.GetObject(st, plumbing.NewHash(hash))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
switch o := obj.(type) {
|
|
case *object.Commit:
|
|
return o, nil
|
|
case *object.Tag:
|
|
return o.Commit()
|
|
}
|
|
return nil, fmt.Errorf("%w: %s is a %s, not a commit", errNotFound, hash, obj.Type())
|
|
}
|
|
|
|
func (s *gitSource) ReadFile(ctx context.Context, hash, name string) ([]byte, error) {
|
|
t, err := s.commitTree(ctx, hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := t.File(name)
|
|
if errors.Is(err, object.ErrFileNotFound) || errors.Is(err, object.ErrDirectoryNotFound) {
|
|
return nil, errNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if f.Size > apiLimit {
|
|
return nil, fmt.Errorf("%s: larger than %d bytes", name, apiLimit)
|
|
}
|
|
text, err := f.Contents()
|
|
return []byte(text), err
|
|
}
|
|
|
|
func (s *gitSource) Tree(ctx context.Context, hash string) (tree, error) {
|
|
t, err := s.commitTree(ctx, hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var gt gitTree
|
|
err = t.Files().ForEach(func(f *object.File) error {
|
|
gt.files = append(gt.files, gitFile{f})
|
|
return nil
|
|
})
|
|
return gt, err
|
|
}
|
|
|
|
func (s *gitSource) commitTree(ctx context.Context, hash string) (*object.Tree, error) {
|
|
c, err := s.commitObject(ctx, hash)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.Tree()
|
|
}
|
|
|
|
// fetch returns an in-memory store holding the commit and its tree,
|
|
// fetching it at depth 1 unless it is among the recently fetched.
|
|
func (s *gitSource) fetch(ctx context.Context, hash string) (*memory.Storage, error) {
|
|
s.mu.Lock()
|
|
lock, ok := s.fetching[hash]
|
|
if !ok {
|
|
lock = &sync.Mutex{}
|
|
s.fetching[hash] = lock
|
|
}
|
|
s.mu.Unlock()
|
|
lock.Lock()
|
|
defer func() {
|
|
s.mu.Lock()
|
|
delete(s.fetching, hash)
|
|
s.mu.Unlock()
|
|
lock.Unlock()
|
|
}()
|
|
if st := s.recent(hash); st != nil {
|
|
return st, nil
|
|
}
|
|
|
|
refs, err := s.advRefs(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sess, err := s.tr.NewUploadPackSession(s.ep, s.auth)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer sess.Close()
|
|
// go-git's HTTP session must see the advertisement before a POST.
|
|
if _, err := sess.AdvertisedReferencesContext(ctx); err != nil {
|
|
return nil, gitErr(err)
|
|
}
|
|
req := packp.NewUploadPackRequestFromCapabilities(refs.Capabilities)
|
|
req.Wants = []plumbing.Hash{plumbing.NewHash(hash)}
|
|
if refs.Capabilities.Supports(capability.Shallow) {
|
|
if err := req.Capabilities.Set(capability.Shallow); err != nil {
|
|
return nil, err
|
|
}
|
|
req.Depth = packp.DepthCommits(1)
|
|
}
|
|
resp, err := sess.UploadPack(ctx, req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("fetch %s: %w", hash, gitErr(err))
|
|
}
|
|
defer resp.Close()
|
|
// The pack arrives multiplexed with progress messages when the
|
|
// request negotiated side-band (go-git's own fetch demuxes it too).
|
|
var pack io.Reader = resp
|
|
switch {
|
|
case req.Capabilities.Supports(capability.Sideband64k):
|
|
pack = sideband.NewDemuxer(sideband.Sideband64k, resp)
|
|
case req.Capabilities.Supports(capability.Sideband):
|
|
pack = sideband.NewDemuxer(sideband.Sideband, resp)
|
|
}
|
|
st := memory.NewStorage()
|
|
if err := packfile.UpdateObjectStorage(st, pack); err != nil {
|
|
return nil, fmt.Errorf("fetch %s: %w", hash, err)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.fetched = append(s.fetched, fetchedCommit{hash: hash, st: st})
|
|
if len(s.fetched) > fetchedKeep {
|
|
s.fetched = s.fetched[1:]
|
|
}
|
|
s.mu.Unlock()
|
|
return st, nil
|
|
}
|
|
|
|
func (s *gitSource) recent(hash string) *memory.Storage {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, f := range s.fetched {
|
|
if f.hash == hash {
|
|
return f.st
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type gitTree struct{ files []modzip.File }
|
|
|
|
func (t gitTree) Files() []modzip.File { return t.files }
|
|
func (gitTree) Close() error { return nil }
|
|
|
|
type gitFile struct{ f *object.File }
|
|
|
|
func (g gitFile) Path() string { return g.f.Name }
|
|
|
|
func (g gitFile) Lstat() (fs.FileInfo, error) { return gitInfo{g.f}, nil }
|
|
|
|
func (g gitFile) Open() (io.ReadCloser, error) { return g.f.Reader() }
|
|
|
|
// gitInfo is the fs.FileInfo of a tree entry: symlinks and submodules are
|
|
// reported as such so module zips skip them, as the go command does.
|
|
type gitInfo struct{ f *object.File }
|
|
|
|
func (i gitInfo) Name() string { return i.f.Name[strings.LastIndex(i.f.Name, "/")+1:] }
|
|
func (i gitInfo) Size() int64 { return i.f.Size }
|
|
func (i gitInfo) ModTime() time.Time { return time.Time{} }
|
|
func (i gitInfo) IsDir() bool { return false }
|
|
func (i gitInfo) Sys() any { return nil }
|
|
|
|
func (i gitInfo) Mode() fs.FileMode {
|
|
switch i.f.Mode {
|
|
case filemode.Symlink:
|
|
return fs.ModeSymlink | 0o777
|
|
case filemode.Executable:
|
|
return 0o755
|
|
case filemode.Regular, filemode.Deprecated:
|
|
return 0o644
|
|
}
|
|
return fs.ModeIrregular
|
|
}
|