Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/gitcache/helpers_test.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()
}