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/cache_test.go
Simon Chrzanowski 2ac80bf76b Merge remote-tracking branch 'origin/main' into fix/cache-key-collisions
# Conflicts:
#	internal/gitcache/cache_test.go
2026-11-08 21:47:56 +01:00

1034 lines
35 KiB
Go

package gitcache
import (
"context"
"crypto/rand"
"errors"
"fmt"
"maps"
"math"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/git-pkgs/registries/safehttp"
)
func TestCloneMatchesUpstream(t *testing.T) {
for _, version := range []string{"0", "1", "2"} {
t.Run("protocol v"+version, func(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
clone := tc.clone(t, "repo.git", "-c", "protocol.version="+version)
want := runGit(t, "", "ls-remote", "--heads", "--tags", upstream.bare())
if got := runGit(t, "", "-c", "protocol.version="+version, "ls-remote", "--heads", "--tags", tc.url("repo.git")); got != want {
t.Errorf("ls-remote through the cache:\n%s\nwant:\n%s", got, want)
}
if got, want := runGit(t, clone, "rev-parse", "HEAD"), runGit(t, upstream.bare(), "rev-parse", "main"); got != want {
t.Errorf("HEAD = %s, want %s", got, want)
}
runGit(t, clone, "fsck", "--no-dangling", "--no-progress")
})
}
}
func TestUpstreamWithoutProtocolV2(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.v0Only = true
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
clone := tc.clone(t, "repo")
if got, want := runGit(t, clone, "rev-parse", "HEAD"), runGit(t, upstream.bare(), "rev-parse", "main"); got != want {
t.Errorf("HEAD = %s, want %s", got, want)
}
}
func TestUnchangedRepositoryIsNotFetchedAgain(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
fetches := upstream.gitRequests.Load()
tc.clone(t, "repo")
if got := upstream.gitRequests.Load(); got != fetches {
t.Errorf("second clone made %d upstream fetch requests, want none", got-fetches)
}
}
func TestPushedCommitIsServedOnNextClone(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
existing := tc.clone(t, "repo")
head := upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
if got := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD"); got != head {
t.Errorf("clone after push: HEAD = %s, want %s", got, head)
}
runGit(t, existing, "pull", "--quiet", "--ff-only")
if got := runGit(t, existing, "rev-parse", "HEAD"); got != head {
t.Errorf("pull after push: HEAD = %s, want %s", got, head)
}
if got := runGit(t, tc.clone(t, "repo", "--depth=1"), "rev-list", "--count", "HEAD"); got != "1" {
t.Errorf("shallow clone has %s commits, want 1", got)
}
}
func TestOnlyBranchesAndTagsAreCachedAndPruned(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
runGit(t, work, "push", "--quiet", "origin", "main:refs/changes/01/1/1", "main:refs/heads/gone", "main:refs/tags/gone")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
refs := tc.cachedRefs(t)
if strings.Contains(refs, "refs/changes/") {
t.Errorf("cached refs/changes:\n%s", refs)
}
if !strings.Contains(refs, "refs/heads/gone") || !strings.Contains(refs, "refs/tags/gone") {
t.Fatalf("branch and tag not cached:\n%s", refs)
}
runGit(t, work, "push", "--quiet", "origin", ":refs/heads/gone", ":refs/tags/gone")
tc.clone(t, "repo")
if refs := tc.cachedRefs(t); strings.Contains(refs, "gone") {
t.Errorf("deleted branch or tag still cached:\n%s", refs)
}
// Gerrit advertises a ref per patch set, so asking for every ref would
// list hundreds of thousands for a large repository.
bodies := upstream.lsRefsBodies()
if len(bodies) == 0 {
t.Fatal("no ls-refs request reached the upstream")
}
for _, body := range bodies {
if !strings.Contains(body, "ref-prefix refs/heads/") || !strings.Contains(body, "ref-prefix refs/tags/") {
t.Errorf("ls-refs asked for every ref, want branches and tags only:\n%q", body)
}
}
}
func TestDefaultBranchChangeIsFollowed(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
runGit(t, work, "checkout", "--quiet", "-b", "trunk")
head := upstream.commit(work, "on trunk")
runGit(t, work, "push", "--quiet", "origin", "trunk")
runGit(t, upstream.bare(), "symbolic-ref", "HEAD", "refs/heads/trunk")
clone := tc.clone(t, "repo")
if got := runGit(t, clone, "rev-parse", "HEAD"); got != head {
t.Errorf("HEAD = %s, want trunk's %s", got, head)
}
}
func TestConcurrentColdClonesShareOneFetch(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
release := upstream.holdRequests()
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
dir := filepath.Join(t.TempDir(), "clone")
if out, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), dir); err != nil {
t.Errorf("clone: %v\n%s", err, out)
}
})
}
time.Sleep(200 * time.Millisecond)
release()
wg.Wait()
if got := upstream.gitSessions.Load(); got != 1 {
t.Errorf("upstream saw %d fetches, want 1", got)
}
}
func TestMissingRepositoryLeavesNothingBehind(t *testing.T) {
upstream := newFakeUpstream(t)
tc := newTestCache(t, upstream.server.URL, nil)
resp, err := http.Get(tc.url("missing") + "/info/refs?service=git-upload-pack")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status %d, want 404", resp.StatusCode)
}
waitForRepos(t, tc.Cache, 0)
if _, err := os.Stat(filepath.Join(tc.dir, "up", "missing.git")); !os.IsNotExist(err) {
t.Errorf("repository directory left behind: %v", err)
}
}
func TestStaleCopyServedOnlyWithinStaleMaxAge(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
upstream.status.Store(http.StatusServiceUnavailable)
tc.clone(t, "repo")
waitIdle(t, tc.Cache)
rp := tc.acquire("up/repo.git")
rp.lastChecked.Store(time.Now().Add(-2 * time.Hour).UnixNano())
tc.release(rp)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Errorf("served a copy older than stale_max_age:\n%s", out)
}
}
func TestRefusedRefreshIsNotServedStale(t *testing.T) {
for _, status := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusMovedPermanently} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
upstream.status.Store(int32(status))
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Errorf("served the cached copy after status %d:\n%s", status, out)
}
})
}
}
func TestSlowUpstreamServesCachedCopyAfterRefreshWait(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.RefreshWait = 100 * time.Millisecond })
old := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD")
head := upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
release := upstream.holdRequests()
defer release()
if got := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD"); got != old {
t.Errorf("HEAD = %s, want the cached %s", got, old)
}
release()
waitFor(t, func() bool { return strings.Contains(tc.cachedRefs(t), head) })
}
// TestSlowFetchAfterPushIsWaitedFor has the upstream answer the ref check at
// once and the fetch after a push still run when RefreshWait is over: a
// clone still gets the pushed commit.
func TestSlowFetchAfterPushIsWaitedFor(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
waitOver := make(chan time.Time)
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.refreshWaitOver = waitOver })
tc.clone(t, "repo")
head := upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
arrived, release := upstream.holdFetches()
defer release()
dir := filepath.Join(t.TempDir(), "clone")
cloned := make(chan error, 1)
go func() {
out, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), dir)
if err != nil {
err = fmt.Errorf("%w\n%s", err, out)
}
cloned <- err
}()
// The fetch starts only after the check found the push.
select {
case <-arrived:
case err := <-cloned:
t.Fatalf("clone finished before the fetch started: %v", err)
case <-time.After(30 * time.Second):
t.Fatal("the fetch after the push never reached the upstream")
}
close(waitOver)
release()
if err := <-cloned; err != nil {
t.Fatal(err)
}
if got := runGit(t, dir, "rev-parse", "HEAD"); got != head {
t.Errorf("HEAD = %s, want the pushed %s", got, head)
}
}
// TestStalledFetchAfterPushServesCachedCopy has the fetch after a push
// stall: once it is abandoned, the clone gets the cached copy.
func TestStalledFetchAfterPushServesCachedCopy(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) {
o.RefreshWait = 100 * time.Millisecond
o.lowSpeedTime = 1
})
old := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD")
upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
upstream.stallFetches.Store(true)
if got := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD"); got != old {
t.Errorf("HEAD = %s, want the cached %s", got, old)
}
}
func TestUpstreamCredentialsReachEveryUpstreamRequest(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.requireAuth = "Bearer secret"
upstream.newRepo("repo")
client := &http.Client{Transport: headerTransport{"Authorization", "Bearer secret"}}
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.Client = client })
tc.clone(t, "repo")
if got := upstream.unauthed.Load(); got != 0 {
t.Errorf("%d upstream requests lacked credentials", got)
}
if upstream.gitRequests.Load() == 0 {
t.Error("no fetch went through the relay")
}
}
type headerTransport struct{ name, value string }
func (h headerTransport) RoundTrip(r *http.Request) (*http.Response, error) {
r = r.Clone(r.Context())
r.Header.Set(h.name, h.value)
return http.DefaultTransport.RoundTrip(r)
}
// TestUpstreamAddressChecksApplyToGit uses a client that refuses loopback
// addresses, as the proxy's does by default. git must not reach the loopback
// upstream around it, whether the ref check or only git's fetch is refused.
func TestUpstreamAddressChecksApplyToGit(t *testing.T) {
blocked := safehttp.New(nil, safehttp.Options{})
for name, client := range map[string]*http.Client{
"every request": blocked,
"git's fetch": {Transport: gitOnlyTransport{blocked.Transport}},
} {
t.Run(name, func(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.Client = client })
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("clone through a blocked upstream succeeded:\n%s", out)
}
if got := upstream.gitRequests.Load(); got != 0 {
t.Errorf("git reached the upstream with %d requests", got)
}
})
}
}
// gitOnlyTransport sends the cache's own ref checks with the default
// transport, and git's relayed requests with next.
type gitOnlyTransport struct{ next http.RoundTripper }
func (g gitOnlyTransport) RoundTrip(r *http.Request) (*http.Response, error) {
if r.UserAgent() == gitUserAgent {
return http.DefaultTransport.RoundTrip(r)
}
return g.next.RoundTrip(r)
}
// TestRedirectIsNotFollowed redirects the ref check, then only git's fetch.
func TestRedirectIsNotFollowed(t *testing.T) {
for _, redirectRefCheck := range []bool{true, false} {
t.Run(fmt.Sprint("ref check redirected: ", redirectRefCheck), func(t *testing.T) {
target := newFakeUpstream(t)
target.newRepo("repo")
targetURL, _ := url.Parse(target.server.URL)
passThrough := httputil.NewSingleHostReverseProxy(targetURL)
redirect := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.UserAgent() == gitUserAgent && !redirectRefCheck {
passThrough.ServeHTTP(w, r)
return
}
http.Redirect(w, r, target.server.URL+r.URL.RequestURI(), http.StatusFound)
}))
defer redirect.Close()
tc := newTestCache(t, redirect.URL, nil)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("followed a redirect:\n%s", out)
}
if got := target.gitRequests.Load(); got != 0 {
t.Errorf("redirect target received %d requests from git", got)
}
})
}
}
func TestRepositoryOverMaxRepoSizeIsRefused(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
big := make([]byte, 256<<10)
_, _ = rand.Read(big)
if err := os.WriteFile(filepath.Join(work, "big.bin"), big, 0o600); err != nil {
t.Fatal(err)
}
runGit(t, work, "add", "big.bin")
runGit(t, work, "commit", "--quiet", "-m", "big")
runGit(t, work, "push", "--quiet", "origin", "main")
// max_size caps max_repo_size.
for name, configure := range map[string]func(*Options){
"max_repo_size": func(o *Options) { o.MaxRepoSize = 64 << 10 },
"max_size": func(o *Options) { o.MaxSize = 64 << 10 },
} {
t.Run(name, func(t *testing.T) {
tc := newTestCache(t, upstream.server.URL, configure)
resp, err := http.Get(tc.url("repo") + "/info/refs?service=git-upload-pack")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusInsufficientStorage {
t.Errorf("status %d, want 507", resp.StatusCode)
}
waitForRepos(t, tc.Cache, 0)
if entries, _ := os.ReadDir(tc.tmpDir()); len(entries) != 0 {
t.Errorf("left %d entries in the temporary directory", len(entries))
}
requests := upstream.requests.Load()
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Errorf("second attempt succeeded:\n%s", out)
}
if got := upstream.requests.Load(); got != requests {
t.Errorf("second attempt made %d upstream requests, want none", got-requests)
}
})
}
}
// TestEvictionRemovesLeastRecentlyUsedIdleRepositories makes the least
// recently used repository one a request is reading, so the next one is
// evicted instead.
func TestEvictionRemovesLeastRecentlyUsedIdleRepositories(t *testing.T) {
upstream := newFakeUpstream(t)
for _, name := range []string{"old", "busy", "new"} {
upstream.newRepo(name)
}
size := repoSize(t, upstream, "new")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.MaxSize = 2*size + size/2 })
tc.clone(t, "busy")
busy := tc.acquire("up/busy.git")
defer tc.release(busy)
busy.lastUsed.Store(0)
tc.clone(t, "old")
waitFor(t, func() bool { return tc.usersOf("up/old.git") == 0 })
tc.repo("up/old.git").lastUsed.Store(1)
// The third clone takes the cache past MaxSize.
tc.clone(t, "new")
waitFor(t, func() bool { return !fileExists(filepath.Join(tc.dir, "up", "old.git")) })
for _, name := range []string{"busy", "new"} {
if !fileExists(filepath.Join(tc.dir, "up", name+".git")) {
t.Errorf("%s was evicted", name)
}
}
tc.clone(t, "old")
}
// repoSize is the size a cache gives repository name once cloned.
func repoSize(t *testing.T, upstream *fakeUpstream, name string) int64 {
t.Helper()
probe := newTestCache(t, upstream.server.URL, nil)
probe.clone(t, name)
return probe.repo("up/" + name + ".git").size.Load()
}
func TestNewLoadsCachedRepositories(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
if err := os.MkdirAll(filepath.Join(tc.tmpDir(), "clone-leftover"), 0o750); err != nil {
t.Fatal(err)
}
if err := tc.Close(); err != nil {
t.Fatal(err)
}
reloaded, err := New(Options{
Upstreams: map[string]string{"up": "up"},
CacheDir: tc.dir,
StaleMaxAge: time.Hour,
Client: &http.Client{},
})
if err != nil {
t.Fatal(err)
}
defer func() { _ = reloaded.Close() }()
rp := reloaded.repos["up/repo.git"] // nothing else uses reloaded yet
if rp == nil || rp.size.Load() == 0 || rp.lastChecked.Load() == 0 {
t.Fatalf("repository not loaded: %+v", rp)
}
if entries, _ := os.ReadDir(reloaded.tmpDir()); len(entries) != 0 {
t.Errorf("temporary directory not cleared: %v", entries)
}
}
func waitForRepos(t *testing.T, c *Cache, n int) {
t.Helper()
waitFor(t, func() bool {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.repos) == n
})
}
func waitFor(t *testing.T, cond func() bool) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for !cond() {
select {
case <-ctx.Done():
t.Fatal("condition not met in time")
case <-time.After(20 * time.Millisecond):
}
}
}
// TestRewoundCacheIsBroughtBackInLine changes the cache behind the upstream's
// back, so it only matches again if the next request refreshes branches and
// tags and prunes what the upstream does not have.
func TestRewoundCacheIsBroughtBackInLine(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
cached := filepath.Join(tc.dir, "up", "repo.git")
runGit(t, cached, "update-ref", "refs/heads/main", "main~1")
runGit(t, cached, "update-ref", "refs/heads/extra", "main")
runGit(t, cached, "tag", "extra", "main")
want := runGit(t, "", "ls-remote", "--heads", "--tags", upstream.bare())
if got := runGit(t, "", "ls-remote", "--heads", "--tags", tc.url("repo")); got != want {
t.Errorf("ls-remote through the cache:\n%s\nwant:\n%s", got, want)
}
}
// TestConcurrentWarmClonesDuringRefresh serves clones from the cache while a
// fetch writes to it.
func TestConcurrentWarmClonesDuringRefresh(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.RefreshWait = time.Millisecond })
old := runGit(t, tc.clone(t, "repo"), "rev-parse", "HEAD")
head := upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
dir := filepath.Join(t.TempDir(), "clone")
if out, err := tryGit(t, "", "clone", "--quiet", tc.url("repo"), dir); err != nil {
t.Errorf("clone: %v\n%s", err, out)
return
}
if got := runGit(t, dir, "rev-parse", "HEAD"); got != old && got != head {
t.Errorf("HEAD = %s, want %s or %s", got, old, head)
}
runGit(t, dir, "fsck", "--no-dangling", "--no-progress")
})
}
wg.Wait()
waitFor(t, func() bool { return strings.Contains(tc.cachedRefs(t), head) })
}
// TestStalledRefCheckFailsInsteadOfHanging has the upstream send headers and
// then nothing, as a broken load balancer might.
func TestStalledRefCheckFailsInsteadOfHanging(t *testing.T) {
stall := make(chan struct{})
defer close(stall)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", advertisementType)
w.WriteHeader(http.StatusOK)
_ = http.NewResponseController(w).Flush()
select {
case <-stall:
case <-r.Context().Done():
}
}))
defer upstream.Close()
tc := newTestCache(t, upstream.URL, func(o *Options) { o.refCheckTimeout = 200 * time.Millisecond })
start := time.Now()
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("ls-remote succeeded:\n%s", out)
}
if elapsed := time.Since(start); elapsed > 10*time.Second {
t.Errorf("took %s to fail", elapsed)
}
}
// TestStalledFetchFailsInsteadOfHanging has the upstream answer the ref check
// but never git's fetch, as a connection that stalls mid-pack would.
func TestStalledFetchFailsInsteadOfHanging(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
upstream.stallFetches.Store(true)
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.lowSpeedTime = 1 })
start := time.Now()
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("ls-remote succeeded:\n%s", out)
}
if elapsed := time.Since(start); elapsed > 20*time.Second {
t.Errorf("took %s to fail", elapsed)
}
}
// TestFetchEvictsToMaxSize checks eviction runs on its own once a fetch has
// grown the cache past MaxSize.
func TestFetchEvictsToMaxSize(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("first")
upstream.newRepo("second")
size := repoSize(t, upstream, "first")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.MaxSize = size + size/2 })
tc.clone(t, "first")
waitIdle(t, tc.Cache)
tc.clone(t, "second")
waitFor(t, func() bool { return !fileExists(filepath.Join(tc.dir, "up", "first.git")) })
if !fileExists(filepath.Join(tc.dir, "up", "second.git")) {
t.Error("evicted the repository just fetched")
}
}
// TestReleaseEvictsWhatEvictionHadToSkip fills the cache past MaxSize while
// every repository is in use, so eviction after the fetch can remove none.
// The first release brings the cache back within MaxSize.
func TestReleaseEvictsWhatEvictionHadToSkip(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("first")
upstream.newRepo("second")
size := repoSize(t, upstream, "first")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.MaxSize = size + size/2 })
first := tc.acquire("up/first.git")
tc.clone(t, "first")
second := tc.acquire("up/second.git")
defer tc.release(second)
tc.clone(t, "second")
waitFor(t, func() bool {
tc.mu.Lock()
defer tc.mu.Unlock()
return tc.overBudget
})
tc.release(first)
if fileExists(first.dir) {
t.Error("first is still cached once released")
}
if !fileExists(second.dir) {
t.Error("evicted second while in use")
}
tc.clone(t, "second")
}
// TestNewRemovesRepointedUpstreams starts a cache whose directory holds an
// upstream's cache at its old URL, and one of an upstream no longer
// configured. Only the first is known to be the cache's to remove.
func TestNewRemovesRepointedUpstreams(t *testing.T) {
dir := t.TempDir()
current := UpstreamDir("up", "https://new.example")
repointed := UpstreamDir("up", "https://old.example")
unconfigured := UpstreamDir("gone", "https://gone.example")
// Named after a configured upstream's name, but another upstream's.
longer := UpstreamDir("up-api", "https://api.example")
for _, name := range []string{current, repointed, unconfigured, longer} {
runGit(t, "", "init", "--quiet", "--bare", filepath.Join(dir, name, "repo.git"))
}
c, err := New(Options{
CacheDir: dir,
StaleMaxAge: time.Hour,
Client: &http.Client{},
Upstreams: map[string]string{"up": current},
})
if err != nil {
t.Fatal(err)
}
defer func() { _ = c.Close() }()
for name, want := range map[string]bool{current: true, repointed: false, unconfigured: true, longer: true} {
if got := fileExists(filepath.Join(dir, name)); got != want {
t.Errorf("%s present = %v, want %v", name, got, want)
}
}
if keys := slices.Collect(maps.Keys(c.repos)); len(keys) != 1 || keys[0] != current+"/repo.git" {
t.Errorf("loaded %v, want only %s/repo.git", keys, current)
}
}
// TestCacheDirSharedWithOtherData points the cache at a directory that also
// holds an artifact store and a database, as git.cache_dir: ./cache would.
func TestCacheDirSharedWithOtherData(t *testing.T) {
dir := t.TempDir()
_ = os.MkdirAll(filepath.Join(dir, "artifacts", "npm"), 0o750)
_ = os.WriteFile(filepath.Join(dir, "artifacts", "npm", "pkg.tgz"), []byte("x"), 0o600)
_ = os.WriteFile(filepath.Join(dir, "proxy.db"), []byte("db"), 0o600)
runGit(t, "", "init", "--quiet", "--bare", filepath.Join(dir, "artifacts", "foo", "bar.git"))
c, err := New(Options{
CacheDir: dir,
Client: &http.Client{},
Upstreams: map[string]string{"github": UpstreamDir("github", "https://github.com")},
})
if err != nil {
t.Fatal(err)
}
defer func() { _ = c.Close() }()
for _, p := range []string{"artifacts/npm/pkg.tgz", "proxy.db", "artifacts/foo/bar.git"} {
if _, err := os.Stat(filepath.Join(dir, p)); err != nil {
t.Errorf("%s was deleted at startup: %v", p, err)
}
}
if len(c.repos) != 0 {
t.Errorf("loaded %v from outside the upstream directories", slices.Collect(maps.Keys(c.repos)))
}
}
// TestCloseWaitsForRefreshes closes the cache while a clone is held up
// upstream. When Close returns, the clone must have finished, temporary
// directory included, so nothing is left writing to the cache.
func TestCloseWaitsForRefreshes(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
upstream.stallFetches.Store(true)
tc := newTestCache(t, upstream.server.URL, nil)
clones := func() []string {
names, _ := filepath.Glob(filepath.Join(tc.tmpDir(), "clone-*"))
return names
}
go func() { _, _ = tryGit(t, "", "ls-remote", tc.url("repo")) }()
waitFor(t, func() bool { return len(clones()) > 0 })
if err := tc.Close(); err != nil {
t.Fatal(err)
}
if left := clones(); len(left) != 0 {
t.Errorf("Close returned with the clone still running: %v", left)
}
before := upstream.requests.Load()
if _, err := tc.Open(context.Background(), "up/other.git", upstream.server.URL+"/other", OpenOptions{}); !errors.Is(err, errClosed) {
t.Errorf("Open after Close: %v, want errClosed", err)
}
if got := upstream.requests.Load() - before; got != 0 {
t.Errorf("Open after Close made %d upstream requests", got)
}
}
// TestCacheDirInUse opens a second cache on a directory one already uses,
// as a second pod on a shared volume would, then again once the first has
// closed.
func TestCacheDirInUse(t *testing.T) {
dir := t.TempDir()
open := func() (*Cache, error) { return New(Options{CacheDir: dir, Client: &http.Client{}}) }
first, err := open()
if err != nil {
t.Fatal(err)
}
if second, err := open(); err == nil {
_ = second.Close()
t.Fatal("a second cache opened on the same directory")
} else if !strings.Contains(err.Error(), "in use by another process") {
t.Errorf("error = %v, want one naming the directory as in use", err)
}
if err := first.Close(); err != nil {
t.Fatal(err)
}
again, err := open()
if err != nil {
t.Fatalf("reopening after Close: %v", err)
}
_ = again.Close()
}
// TestCloseTwice checks closing again does nothing, as a cleanup that also
// closes the cache may.
func TestCloseTwice(t *testing.T) {
c, err := New(Options{CacheDir: t.TempDir(), Client: &http.Client{}})
if err != nil {
t.Fatal(err)
}
if err := errors.Join(c.Close(), c.Close()); err != nil {
t.Errorf("closing twice: %v", err)
}
}
// lsRefs sends a protocol v2 ls-refs request for "repo" straight to
// git-upload-pack, as a client may without asking for info/refs first, and
// returns the status.
func (tc *testCache) lsRefs(t *testing.T) int {
t.Helper()
body := pktLine("command=ls-refs\n") + pktDelim + pktLine("peel\n") + pktFlush
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tc.url("repo")+"/git-upload-pack", strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", requestType)
req.Header.Set("Git-Protocol", "version=2")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// TestUploadPackChecksCopyPastStaleMaxAge sends upload-pack requests without
// info/refs: a copy not checked within stale_max_age is checked first.
func TestUploadPackChecksCopyPastStaleMaxAge(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
if status := tc.lsRefs(t); status != http.StatusOK {
t.Fatalf("ls-refs on a fresh copy: status %d", status)
}
waitIdle(t, tc.Cache)
rp := tc.acquire("up/repo.git")
rp.lastChecked.Store(time.Now().Add(-2 * time.Hour).UnixNano())
tc.release(rp)
upstream.status.Store(http.StatusNotFound)
if status := tc.lsRefs(t); status == http.StatusOK {
t.Errorf("served a copy older than stale_max_age to ls-refs")
}
}
// TestUploadPackRefusedAfterUpstreamRefusal has the upstream refuse a
// refresh: upload-pack requests are refused too until a check succeeds.
func TestUploadPackRefusedAfterUpstreamRefusal(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
upstream.status.Store(http.StatusForbidden)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("served the cached copy after 403:\n%s", out)
}
if status := tc.lsRefs(t); status == http.StatusOK {
t.Errorf("ls-refs served after the upstream refused the repository")
}
upstream.status.Store(0)
tc.clone(t, "repo")
if status := tc.lsRefs(t); status != http.StatusOK {
t.Errorf("ls-refs after a successful check: status %d", status)
}
}
// TestCloneChecksUpstreamOnce clones with a stale_max_age of 0: the
// upload-pack requests after info/refs do not check the upstream again.
func TestCloneChecksUpstreamOnce(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.StaleMaxAge = 0 })
tc.clone(t, "repo")
waitIdle(t, tc.Cache)
checks := func() int32 { return upstream.requests.Load() - upstream.gitRequests.Load() }
before := checks()
resp, err := http.Get(tc.url("repo") + "/info/refs?service=git-upload-pack")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
perCheck := checks() - before
before = checks()
tc.clone(t, "repo")
if got := checks() - before; got != perCheck {
t.Errorf("clone made %d upstream check requests, want %d", got, perCheck)
}
}
// TestInfoRefsRefusedWhileRefusalStands checks that once the upstream
// refused a repository, a later check that runs past RefreshWait, which
// would otherwise serve the cached copy, doesn't advertise its refs.
func TestInfoRefsRefusedWhileRefusalStands(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.RefreshWait = 100 * time.Millisecond })
tc.clone(t, "repo")
upstream.status.Store(http.StatusForbidden)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("served the cached copy after 403:\n%s", out)
}
upstream.status.Store(0)
release := upstream.holdRequests()
defer release()
// Protocol v0 advertises the refs in the info/refs response itself.
if out, err := tryGit(t, "", "-c", "protocol.version=0", "ls-remote", tc.url("repo")); err == nil {
t.Errorf("advertised refs while the refusal stands:\n%s", out)
}
}
// refuse has the upstream refuse "repo" with a 403 and checks that the
// cached copy is no longer served.
func (tc *testCache) refuse(t *testing.T, upstream *fakeUpstream) {
t.Helper()
upstream.status.Store(http.StatusForbidden)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Fatalf("served the cached copy after 403:\n%s", out)
}
upstream.status.Store(0)
}
// TestUploadPackClearsRefusal has a client that only sends upload-pack
// requests: once the upstream allows the repository again, it is served.
func TestUploadPackClearsRefusal(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
tc.refuse(t, upstream)
if status := tc.lsRefs(t); status != http.StatusOK {
t.Errorf("ls-refs after the upstream allowed the repository again: status %d", status)
}
}
// TestRefCheckClearsRefusal has the upstream list the refs again after a
// refusal but fail the fetch that follows: the check alone clears the
// refusal, and the cached copy is served.
func TestRefCheckClearsRefusal(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
tc.refuse(t, upstream)
upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
upstream.fetchStatus.Store(http.StatusInternalServerError)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err != nil {
t.Errorf("cached copy refused after the upstream listed its refs: %v\n%s", err, out)
}
if status := tc.lsRefs(t); status != http.StatusOK {
t.Errorf("ls-refs after the upstream listed its refs: status %d", status)
}
}
// TestHugeStaleMaxAgeUploadPackChecksNothing sets stale_max_age near the
// largest duration: upload-pack requests on a fresh copy do not check the
// upstream.
func TestHugeStaleMaxAgeUploadPackChecksNothing(t *testing.T) {
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, func(o *Options) { o.StaleMaxAge = math.MaxInt64 - time.Second })
tc.clone(t, "repo")
waitIdle(t, tc.Cache)
before := upstream.requests.Load()
if status := tc.lsRefs(t); status != http.StatusOK {
t.Fatalf("ls-refs: status %d", status)
}
waitIdle(t, tc.Cache)
if got := upstream.requests.Load() - before; got != 0 {
t.Errorf("ls-refs made %d upstream requests, want 0", got)
}
}
// TestFetchRedirectRefusesRepository has the upstream list the refs but
// redirect the fetch that follows: the repository is refused, as when the
// ref listing is redirected.
func TestFetchRedirectRefusesRepository(t *testing.T) {
upstream := newFakeUpstream(t)
work := upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
upstream.commit(work, "second")
runGit(t, work, "push", "--quiet", "origin", "main")
upstream.fetchStatus.Store(http.StatusFound)
if out, err := tryGit(t, "", "ls-remote", tc.url("repo")); err == nil {
t.Errorf("served the cached copy after the fetch was redirected:\n%s", out)
}
if status := tc.lsRefs(t); status == http.StatusOK {
t.Errorf("ls-refs served after the fetch was redirected")
}
}
// TestCaseOnlyDifferentKeysShareOneRepository checks that, where the file
// system ignores case, keys that differ only in case are one repository, so
// a user of one spelling keeps the directory from being evicted under it.
func TestCaseOnlyDifferentKeysShareOneRepository(t *testing.T) {
if !caseInsensitive(t.TempDir()) {
t.Skip("file system is case-sensitive")
}
upstream := newFakeUpstream(t)
upstream.newRepo("repo")
tc := newTestCache(t, upstream.server.URL, nil)
tc.clone(t, "repo")
waitIdle(t, tc.Cache)
rp := tc.acquire("up/REPO.git")
defer tc.release(rp)
if other := tc.repo("up/repo.git"); other != rp {
t.Error("up/REPO.git and up/repo.git are separate repositories")
}
tc.mu.Lock()
tc.opts.MaxSize = 1
tc.mu.Unlock()
tc.evict()
if !rp.exists() {
t.Error("repository in use as up/REPO.git was evicted")
}
}
// TestFoldCaseKeysShareOneEntry checks key folding on any file system by
// setting foldCase directly.
func TestFoldCaseKeysShareOneEntry(t *testing.T) {
tc := newTestCache(t, newFakeUpstream(t).server.URL, nil)
tc.foldCase = true
upper := tc.acquire("up/REPO.git")
defer tc.release(upper)
lower := tc.acquire("up/repo.git")
defer tc.release(lower)
if upper != lower {
t.Error("up/REPO.git and up/repo.git are separate repositories")
}
tc.mu.Lock()
n := len(tc.repos)
tc.mu.Unlock()
if n != 1 {
t.Errorf("%d repository entries, want 1", n)
}
}