mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
163 lines
4.9 KiB
Go
163 lines
4.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
)
|
|
|
|
// These tests cover fetches of one artifact that do not share a coalescing
|
|
// key because their URLs differ. Each must keep its own stored object.
|
|
|
|
const (
|
|
fetchPathURLA = "https://mirror-a.example/pkg-1.0.0.tgz"
|
|
fetchPathURLB = "https://mirror-b.example/pkg-1.0.0.tgz"
|
|
)
|
|
|
|
// barrierFetcher serves a body per URL and holds each fetch until both have
|
|
// started, so both requests are past the cache check before either commits.
|
|
type barrierFetcher struct {
|
|
bodies map[string]string
|
|
arrived sync.WaitGroup
|
|
}
|
|
|
|
func newBarrierFetcher(bodies map[string]string) *barrierFetcher {
|
|
f := &barrierFetcher{bodies: bodies}
|
|
f.arrived.Add(len(bodies))
|
|
return f
|
|
}
|
|
|
|
func (f *barrierFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
|
|
return f.FetchWithHeaders(ctx, url, nil)
|
|
}
|
|
|
|
func (f *barrierFetcher) FetchWithHeaders(_ context.Context, url string, _ http.Header) (*fetch.Artifact, error) {
|
|
f.arrived.Done()
|
|
f.arrived.Wait()
|
|
return artifactBody(f.bodies[url]), nil
|
|
}
|
|
|
|
func (f *barrierFetcher) Head(_ context.Context, _ string) (int64, string, error) {
|
|
return 0, "", nil
|
|
}
|
|
|
|
// gatedStorage holds the first Open until ready reports true, to order it
|
|
// after the other request's store or delete.
|
|
type gatedStorage struct {
|
|
*mockStorage
|
|
ready func(stores, deletes int32) bool
|
|
stores atomic.Int32
|
|
deletes atomic.Int32
|
|
opened atomic.Bool
|
|
release chan struct{}
|
|
releaseMu sync.Once
|
|
}
|
|
|
|
func newGatedStorage(store *mockStorage, ready func(stores, deletes int32) bool) *gatedStorage {
|
|
return &gatedStorage{mockStorage: store, ready: ready, release: make(chan struct{})}
|
|
}
|
|
|
|
func (g *gatedStorage) check() {
|
|
if g.ready(g.stores.Load(), g.deletes.Load()) {
|
|
g.releaseMu.Do(func() { close(g.release) })
|
|
}
|
|
}
|
|
|
|
func (g *gatedStorage) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
|
|
size, hash, err := g.mockStorage.Store(ctx, path, r)
|
|
g.stores.Add(1)
|
|
g.check()
|
|
return size, hash, err
|
|
}
|
|
|
|
func (g *gatedStorage) Delete(ctx context.Context, path string) error {
|
|
err := g.mockStorage.Delete(ctx, path)
|
|
g.deletes.Add(1)
|
|
g.check()
|
|
return err
|
|
}
|
|
|
|
func (g *gatedStorage) Open(ctx context.Context, path string) (io.ReadCloser, error) {
|
|
if g.opened.CompareAndSwap(false, true) {
|
|
select {
|
|
case <-g.release:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
return g.mockStorage.Open(ctx, path)
|
|
}
|
|
|
|
func readResult(res *CacheResult) string {
|
|
b, _ := io.ReadAll(res.Reader)
|
|
_ = res.Reader.Close()
|
|
return string(b)
|
|
}
|
|
|
|
// TestFetchesWithDifferentURLsServeTheirOwnBytes has both fetches store before
|
|
// either opens. Sharing one object, one of them would serve the other's bytes.
|
|
func TestFetchesWithDifferentURLsServeTheirOwnBytes(t *testing.T) {
|
|
proxy, _, store, _ := setupTestProxy(t)
|
|
bodies := map[string]string{fetchPathURLA: "bytes from a", fetchPathURLB: "bytes from b"}
|
|
proxy.Fetcher = newBarrierFetcher(bodies)
|
|
proxy.Storage = newGatedStorage(store, func(stores, _ int32) bool { return stores == 2 })
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
for url, want := range bodies {
|
|
wg.Go(func() {
|
|
res, err := proxy.GetOrFetchArtifactFromURL(ctx, "npm", "pkg", "1.0.0", staleFilename, url)
|
|
if err != nil {
|
|
t.Errorf("fetch of %s: %v", url, err)
|
|
return
|
|
}
|
|
if got := readResult(res); got != want {
|
|
t.Errorf("fetch of %s served %q, want %q", url, got, want)
|
|
}
|
|
})
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// TestDigestMismatchLeavesAnotherFetchsObject has one fetch discard bytes that
|
|
// fail the declared digest before another fetch, which stored good bytes,
|
|
// opens them. Sharing one object, the discard would delete the good bytes.
|
|
func TestDigestMismatchLeavesAnotherFetchsObject(t *testing.T) {
|
|
proxy, _, store, _ := setupTestProxy(t)
|
|
proxy.Fetcher = newBarrierFetcher(map[string]string{fetchPathURLA: "good bytes", fetchPathURLB: "tampered bytes"})
|
|
proxy.Storage = newGatedStorage(store, func(_, deletes int32) bool { return deletes == 1 })
|
|
declared := "sha256:" + sha256Hex("good bytes")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Go(func() {
|
|
res, err := proxy.GetOrFetchArtifactFromURLWithDigest(ctx, "npm", "pkg", "1.0.0", staleFilename, fetchPathURLA, declared)
|
|
if err != nil {
|
|
t.Errorf("good fetch: %v", err)
|
|
return
|
|
}
|
|
if got := readResult(res); got != "good bytes" {
|
|
t.Errorf("good fetch served %q", got)
|
|
}
|
|
})
|
|
wg.Go(func() {
|
|
_, err := proxy.GetOrFetchArtifactFromURLWithDigest(ctx, "npm", "pkg", "1.0.0", staleFilename, fetchPathURLB, declared)
|
|
if !errors.Is(err, ErrArtifactDigestMismatch) {
|
|
t.Errorf("tampered fetch: got %v, want a digest mismatch", err)
|
|
}
|
|
})
|
|
wg.Wait()
|
|
|
|
if _, ok := store.files[recordedStoragePath(t, proxy.DB, staleVersionPURL, staleFilename)]; !ok {
|
|
t.Error("the recorded object was deleted")
|
|
}
|
|
}
|