mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 07:39:19 -05:00
437 lines
12 KiB
Go
437 lines
12 KiB
Go
package gitcache
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"errors"
|
|
"io"
|
|
"log"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/cgi"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// gitEnv isolates the tests' own git commands from the machine's config.
|
|
func gitEnv(home string) []string {
|
|
return []string{
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"HOME=" + home,
|
|
"GIT_CONFIG_NOSYSTEM=1",
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
"GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@example.com",
|
|
"GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@example.com",
|
|
}
|
|
}
|
|
|
|
func runGit(t *testing.T, dir string, args ...string) string {
|
|
t.Helper()
|
|
out, err := tryGit(t, dir, args...)
|
|
if err != nil {
|
|
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
|
|
}
|
|
return strings.TrimSpace(out)
|
|
}
|
|
|
|
func tryGit(t *testing.T, dir string, args ...string) (string, error) {
|
|
t.Helper()
|
|
cmd := exec.Command("git", args...)
|
|
cmd.Dir = dir
|
|
cmd.Env = gitEnv(t.TempDir())
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// httpBackendPath finds git http-backend, which serves the fake upstreams,
|
|
// and skips the test where git is installed without it.
|
|
func httpBackendPath(t *testing.T) string {
|
|
t.Helper()
|
|
dir := runGit(t, "", "--exec-path")
|
|
for _, name := range []string{"git-http-backend", "git-http-backend.exe"} {
|
|
if path := filepath.Join(dir, name); fileExists(path) {
|
|
return path
|
|
}
|
|
}
|
|
t.Skip("git http-backend is not installed")
|
|
return ""
|
|
}
|
|
|
|
func fileExists(path string) bool {
|
|
_, err := os.Stat(path)
|
|
return err == nil
|
|
}
|
|
|
|
// fakeUpstream serves bare repositories under root with git http-backend.
|
|
type fakeUpstream struct {
|
|
t *testing.T
|
|
root string
|
|
server *httptest.Server
|
|
|
|
// status, when set, is returned instead of serving the request, and
|
|
// fetchStatus instead of serving git's own upload-pack requests.
|
|
status atomic.Int32
|
|
fetchStatus atomic.Int32
|
|
// requireAuth, when set, is the Authorization every request must carry.
|
|
requireAuth string
|
|
// v0Only drops Git-Protocol, so only protocol v0 is spoken.
|
|
v0Only bool
|
|
// hold, when set, blocks requests until it is closed.
|
|
holdMu sync.Mutex
|
|
hold chan struct{}
|
|
|
|
requests atomic.Int32
|
|
// gitRequests counts requests from git processes, which reach the
|
|
// upstream through the relay, and gitSessions the fetches they make.
|
|
gitRequests atomic.Int32
|
|
gitSessions atomic.Int32
|
|
unauthed atomic.Int32
|
|
|
|
// stallFetches makes git's own upload-pack requests hang, and
|
|
// delayFetches holds them for that long.
|
|
stallFetches atomic.Bool
|
|
delayFetches atomic.Int64
|
|
// fetchHold, while set, holds git's own upload-pack requests until it
|
|
// is closed; fetchArrived is closed when the first one arrives.
|
|
fetchHold chan struct{}
|
|
fetchArrived chan struct{}
|
|
fetchOnce *sync.Once
|
|
|
|
bodiesMu sync.Mutex
|
|
bodies []string
|
|
}
|
|
|
|
// recordBody keeps the decoded body of a POST. It also gives the request a
|
|
// Content-Length, since net/http/cgi refuses chunked bodies, which git sends
|
|
// once a request outgrows http.postBuffer.
|
|
func (u *fakeUpstream) recordBody(r *http.Request) {
|
|
raw, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
u.t.Errorf("reading request body: %v", err)
|
|
}
|
|
r.Body = io.NopCloser(bytes.NewReader(raw))
|
|
r.ContentLength = int64(len(raw))
|
|
r.TransferEncoding = nil
|
|
decoded := raw
|
|
if r.Header.Get("Content-Encoding") == "gzip" {
|
|
if zr, err := gzip.NewReader(bytes.NewReader(raw)); err == nil {
|
|
decoded, _ = io.ReadAll(zr)
|
|
}
|
|
}
|
|
u.bodiesMu.Lock()
|
|
u.bodies = append(u.bodies, string(decoded))
|
|
u.bodiesMu.Unlock()
|
|
}
|
|
|
|
// lsRefsBodies returns the ls-refs requests the upstream received.
|
|
func (u *fakeUpstream) lsRefsBodies() []string {
|
|
u.bodiesMu.Lock()
|
|
defer u.bodiesMu.Unlock()
|
|
var out []string
|
|
for _, body := range u.bodies {
|
|
if strings.Contains(body, "command=ls-refs") {
|
|
out = append(out, body)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func newFakeUpstream(t *testing.T) *fakeUpstream {
|
|
t.Helper()
|
|
u := &fakeUpstream{t: t, root: t.TempDir()}
|
|
backend := &cgi.Handler{
|
|
Path: httpBackendPath(t),
|
|
Stderr: io.Discard,
|
|
Logger: log.New(io.Discard, "", 0),
|
|
Env: append(gitEnv(t.TempDir()),
|
|
"GIT_PROJECT_ROOT="+u.root,
|
|
"GIT_HTTP_EXPORT_ALL=1"),
|
|
}
|
|
u.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u.requests.Add(1)
|
|
if r.UserAgent() != gitUserAgent {
|
|
u.gitRequests.Add(1)
|
|
if r.Method == http.MethodGet {
|
|
u.gitSessions.Add(1)
|
|
}
|
|
}
|
|
u.holdMu.Lock()
|
|
hold := u.hold
|
|
u.holdMu.Unlock()
|
|
if hold != nil {
|
|
<-hold
|
|
}
|
|
if status := u.status.Load(); status != 0 {
|
|
http.Error(w, "unavailable", int(status))
|
|
return
|
|
}
|
|
if u.requireAuth != "" && r.Header.Get("Authorization") != u.requireAuth {
|
|
u.unauthed.Add(1)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
if u.v0Only {
|
|
r.Header.Del("Git-Protocol")
|
|
}
|
|
if r.UserAgent() != gitUserAgent && r.Method == http.MethodPost && u.interceptFetch(w, r) {
|
|
return
|
|
}
|
|
if r.Method == http.MethodPost {
|
|
u.recordBody(r)
|
|
}
|
|
backend.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(u.server.Close)
|
|
return u
|
|
}
|
|
|
|
// interceptFetch applies the fetch knobs to one of git's own upload-pack
|
|
// requests, and reports whether it answered the request.
|
|
func (u *fakeUpstream) interceptFetch(w http.ResponseWriter, r *http.Request) bool {
|
|
u.holdMu.Lock()
|
|
fetchHold, fetchArrived, fetchOnce := u.fetchHold, u.fetchArrived, u.fetchOnce
|
|
u.holdMu.Unlock()
|
|
if fetchHold != nil {
|
|
fetchOnce.Do(func() { close(fetchArrived) })
|
|
<-fetchHold
|
|
}
|
|
if delay := u.delayFetches.Load(); delay > 0 {
|
|
time.Sleep(time.Duration(delay))
|
|
}
|
|
if u.stallFetches.Load() {
|
|
// The server notices the client leaving only once the body is read.
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
<-r.Context().Done()
|
|
return true
|
|
}
|
|
if status := u.fetchStatus.Load(); status != 0 {
|
|
w.Header().Set("Location", u.server.URL+"/elsewhere")
|
|
http.Error(w, "unavailable", int(status))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// holdFetches holds git's own upload-pack requests until release is called.
|
|
// arrived is closed when the first one reaches the upstream.
|
|
func (u *fakeUpstream) holdFetches() (arrived <-chan struct{}, release func()) {
|
|
u.holdMu.Lock()
|
|
defer u.holdMu.Unlock()
|
|
hold, first := make(chan struct{}), make(chan struct{})
|
|
u.fetchHold, u.fetchArrived, u.fetchOnce = hold, first, &sync.Once{}
|
|
var once sync.Once
|
|
return first, func() {
|
|
once.Do(func() {
|
|
u.holdMu.Lock()
|
|
u.fetchHold = nil
|
|
u.holdMu.Unlock()
|
|
close(hold)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (u *fakeUpstream) holdRequests() func() {
|
|
u.holdMu.Lock()
|
|
defer u.holdMu.Unlock()
|
|
hold := make(chan struct{})
|
|
u.hold = hold
|
|
var once sync.Once
|
|
return func() {
|
|
once.Do(func() {
|
|
u.holdMu.Lock()
|
|
u.hold = nil
|
|
u.holdMu.Unlock()
|
|
close(hold)
|
|
})
|
|
}
|
|
}
|
|
|
|
// newRepo creates {name}.git upstream with one commit on main and a tag, and
|
|
// returns a working clone to commit and push from.
|
|
func (u *fakeUpstream) newRepo(name string) string {
|
|
u.t.Helper()
|
|
bare := filepath.Join(u.root, name+".git")
|
|
runGit(u.t, "", "init", "--quiet", "--bare", "--initial-branch=main", bare)
|
|
work := u.t.TempDir()
|
|
runGit(u.t, "", "clone", "--quiet", bare, work)
|
|
runGit(u.t, work, "checkout", "--quiet", "-b", "main")
|
|
u.commit(work, "first")
|
|
runGit(u.t, work, "tag", "-a", "-m", "v1", "v1")
|
|
runGit(u.t, work, "push", "--quiet", "origin", "main", "v1")
|
|
return work
|
|
}
|
|
|
|
func (u *fakeUpstream) commit(work, msg string) string {
|
|
u.t.Helper()
|
|
if err := os.WriteFile(filepath.Join(work, "file.txt"), []byte(msg+"\n"), 0o600); err != nil {
|
|
u.t.Fatal(err)
|
|
}
|
|
runGit(u.t, work, "add", "file.txt")
|
|
runGit(u.t, work, "commit", "--quiet", "-m", msg)
|
|
return runGit(u.t, work, "rev-parse", "HEAD")
|
|
}
|
|
|
|
// waitIdle waits until no request or refresh is using a repository, so a
|
|
// test can inspect or change the cache without racing a request finishing.
|
|
func waitIdle(t *testing.T, c *Cache) {
|
|
t.Helper()
|
|
waitFor(t, func() bool {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for _, rp := range c.repos {
|
|
if rp.users > 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// bare is the upstream repository named "repo".
|
|
// repo returns the cache's entry for key, reading the map under the lock.
|
|
func (tc *testCache) repo(key string) *repo {
|
|
tc.mu.Lock()
|
|
defer tc.mu.Unlock()
|
|
return tc.repos[key]
|
|
}
|
|
|
|
// usersOf is the number of requests and refreshes using the repository.
|
|
func (tc *testCache) usersOf(key string) int {
|
|
tc.mu.Lock()
|
|
defer tc.mu.Unlock()
|
|
return tc.repos[key].users
|
|
}
|
|
|
|
func (u *fakeUpstream) bare() string {
|
|
return filepath.Join(u.root, "repo.git")
|
|
}
|
|
|
|
type testCache struct {
|
|
*Cache
|
|
server *httptest.Server
|
|
logs *lockedBuffer
|
|
}
|
|
|
|
func newTestCache(t *testing.T, upstreamURL string, configure func(*Options)) *testCache {
|
|
t.Helper()
|
|
// The cache's log is shown when the test fails.
|
|
logs := &lockedBuffer{}
|
|
t.Cleanup(func() {
|
|
if t.Failed() {
|
|
t.Logf("git cache log:\n%s", logs.String())
|
|
}
|
|
})
|
|
opts := Options{
|
|
Upstreams: map[string]string{"up": "up"},
|
|
CacheDir: t.TempDir(),
|
|
RefreshWait: 5 * time.Second,
|
|
StaleMaxAge: time.Hour,
|
|
Client: &http.Client{},
|
|
Logger: slog.New(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelDebug})),
|
|
}
|
|
if configure != nil {
|
|
configure(&opts)
|
|
}
|
|
c, err := New(opts)
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
server := httptest.NewServer(testFront(c, upstreamURL, true))
|
|
t.Cleanup(func() {
|
|
server.Close()
|
|
_ = c.Close()
|
|
})
|
|
return &testCache{Cache: c, server: server, logs: logs}
|
|
}
|
|
|
|
// testFront serves /git/up/{repo}/info/refs and git-upload-pack from c, as
|
|
// internal/handler does once it has validated the request, with repo
|
|
// fetched from upstreamURL/{repo}. fetchWants is OpenOptions.FetchWants.
|
|
func testFront(c *Cache, upstreamURL string, fetchWants bool) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
path := strings.TrimPrefix(r.URL.Path, "/git/up/")
|
|
repo, infoRefs := strings.CutSuffix(path, "/info/refs")
|
|
if !infoRefs {
|
|
var ok bool
|
|
if repo, ok = strings.CutSuffix(path, "/git-upload-pack"); !ok || r.Method != http.MethodPost {
|
|
http.Error(w, "not served", http.StatusForbidden)
|
|
return
|
|
}
|
|
} else if r.URL.RawQuery != "service=git-upload-pack" {
|
|
http.Error(w, "not served", http.StatusForbidden)
|
|
return
|
|
}
|
|
var req *Request
|
|
if !infoRefs {
|
|
var err error
|
|
if req, err = c.ReadRequest(r); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer req.Close()
|
|
}
|
|
lease, err := c.Open(r.Context(), "up/"+strings.TrimSuffix(repo, ".git")+".git", upstreamURL+"/"+repo,
|
|
OpenOptions{Refresh: infoRefs, FetchWants: fetchWants})
|
|
switch {
|
|
case errors.Is(err, ErrNotFound):
|
|
http.Error(w, err.Error(), http.StatusNotFound)
|
|
return
|
|
case errors.Is(err, ErrTooLarge), errors.Is(err, ErrCacheFull):
|
|
http.Error(w, err.Error(), http.StatusInsufficientStorage)
|
|
return
|
|
case err != nil:
|
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer lease.Release()
|
|
if infoRefs {
|
|
lease.ServeInfoRefs(w, r)
|
|
} else {
|
|
lease.ServeUploadPack(w, r, req)
|
|
}
|
|
})
|
|
}
|
|
|
|
func (tc *testCache) url(repo string) string {
|
|
return tc.server.URL + "/git/up/" + repo
|
|
}
|
|
|
|
// clone clones repo through the cache with the given protocol version and
|
|
// returns the working copy.
|
|
func (tc *testCache) clone(t *testing.T, repo string, args ...string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "clone")
|
|
runGit(t, "", append(append([]string{"clone", "--quiet"}, args...), tc.url(repo), dir)...)
|
|
return dir
|
|
}
|
|
|
|
// cachedRefs lists the refs of the cached copy of "repo".
|
|
func (tc *testCache) cachedRefs(t *testing.T) string {
|
|
t.Helper()
|
|
return runGit(t, filepath.Join(tc.dir, "up", "repo.git"), "for-each-ref", "--format=%(objectname) %(refname)")
|
|
}
|
|
|
|
// lockedBuffer is a bytes.Buffer safe for concurrent writers.
|
|
type lockedBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *lockedBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|