Reject bare * and unknown remotes on evict, serialise with fetch lock
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

- return 400 for a bare * and 404 for an unknown remote
- take the per-path fetch lock for single-path evicts
- pass the evictor to Routes so local routes no longer carry it
- test If-None-Match revalidation, ForgetPath/ForgetPrefix glob escaping
This commit is contained in:
2026-10-04 15:56:30 +11:00
parent 5802fd2e2c
commit c1c5ff987c
8 changed files with 218 additions and 32 deletions
+31 -1
View File
@@ -16,6 +16,8 @@ import (
"sync/atomic"
"time"
"github.com/jackc/pgx/v5"
"git.unkin.net/unkin/artifactapi/internal/cache"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
@@ -210,10 +212,21 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
// Evict drops path from every cache layer (artifact row, index object, Redis
// freshness and ETag keys) so the next request refetches from upstream. A
// trailing "*" evicts every path under that prefix.
// trailing "*" evicts every path under that prefix; a bare "*" is rejected.
func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
prefix, wildcard := strings.CutSuffix(path, "*")
if wildcard && prefix == "" {
return &ProxyError{Status: http.StatusBadRequest, Message: "refusing to evict an entire remote"}
}
if _, err := e.db.GetRemote(ctx, remoteName); errors.Is(err, pgx.ErrNoRows) {
return &ProxyError{Status: http.StatusNotFound, Message: fmt.Sprintf("remote %q not found", remoteName)}
} else if err != nil {
return fmt.Errorf("get remote: %w", err)
}
if !wildcard {
if e.waitForLock(ctx, remoteName, path) {
defer e.cache.ReleaseLock(ctx, remoteName, path)
}
if err := e.db.DeleteArtifact(ctx, remoteName, path); err != nil {
return fmt.Errorf("delete artifact: %w", err)
}
@@ -231,6 +244,23 @@ func (e *Engine) Evict(ctx context.Context, remoteName, path string) error {
return e.cache.ForgetPrefix(ctx, remoteName, prefix)
}
// waitForLock takes the per-path fetch lock so an in-flight Fetch cannot
// re-set TTL/ETag keys after an evict. It gives up once the lock would have
// expired anyway, or when ctx ends.
func (e *Engine) waitForLock(ctx context.Context, remoteName, path string) bool {
deadline := time.Now().Add(fetchLockTTL)
for {
if ok, err := e.cache.AcquireLock(ctx, remoteName, path, fetchLockTTL); ok || err != nil || time.Now().After(deadline) {
return ok
}
select {
case <-ctx.Done():
return false
case <-time.After(50 * time.Millisecond):
}
}
}
// HeadResult carries artifact metadata for a HEAD request. There is no body.
type HeadResult struct {
ContentType string
+68 -6
View File
@@ -2,28 +2,40 @@ package proxy
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
_ "git.unkin.net/unkin/artifactapi/internal/provider/rpm"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// changingUpstream serves every path with the current revision, as a mirror
// does after a sync replaces its repodata.
func changingUpstream(t *testing.T) (*httptest.Server, *atomic.Value) {
// does after a sync replaces its repodata. It answers 304 to a matching
// If-None-Match and counts conditional requests.
func changingUpstream(t *testing.T) (*httptest.Server, *atomic.Value, *atomic.Int32) {
t.Helper()
var rev atomic.Value
var conditional atomic.Int32
rev.Store("rev1")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v := rev.Load().(string)
w.Header().Set("ETag", `"`+v+`"`)
etag := `"` + v + `"`
w.Header().Set("ETag", etag)
if inm := r.Header.Get("If-None-Match"); inm != "" {
conditional.Add(1)
if inm == etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
_, _ = w.Write([]byte(v + ":" + r.URL.Path))
}))
t.Cleanup(srv.Close)
return srv, &rev
return srv, &rev, &conditional
}
func fetchBody(t *testing.T, r models.Remote, path string) string {
@@ -41,7 +53,7 @@ func rpmRemote(t *testing.T, name, baseURL string) models.Remote {
func TestEvictMutableIndexRefetches(t *testing.T) {
requireStack(t)
srv, rev := changingUpstream(t)
srv, rev, conditional := changingUpstream(t)
r := rpmRemote(t, "evict-idx", srv.URL)
const path = "8/Everything/x86_64/repodata/repomd.xml"
@@ -55,14 +67,64 @@ func TestEvictMutableIndexRefetches(t *testing.T) {
if err := testEngine.Evict(context.Background(), r.Name, path); err != nil {
t.Fatalf("evict: %v", err)
}
conditional.Store(0)
if got := fetchBody(t, r, path); got != "rev2:/"+path {
t.Fatalf("after evict = %q, want rev2", got)
}
if n := conditional.Load(); n != 0 {
t.Errorf("post-evict fetch revalidated with the evicted ETag (%d conditional requests)", n)
}
}
func TestEvictRejectsBareWildcard(t *testing.T) {
requireStack(t)
srv, _, _ := changingUpstream(t)
r := rpmRemote(t, "evict-bare", srv.URL)
const path = "8/Everything/x86_64/repodata/repomd.xml"
fetchBody(t, r, path)
var pe *ProxyError
if err := testEngine.Evict(context.Background(), r.Name, "*"); !errors.As(err, &pe) || pe.Status != http.StatusBadRequest {
t.Fatalf("evict * = %v, want 400", err)
}
if fresh, _ := testEngine.cache.CheckTTL(context.Background(), r.Name, path); !fresh {
t.Error("bare * evicted cached keys")
}
}
func TestEvictUnknownRemote(t *testing.T) {
requireStack(t)
var pe *ProxyError
if err := testEngine.Evict(context.Background(), "evict-no-such-remote", "a/b"); !errors.As(err, &pe) || pe.Status != http.StatusNotFound {
t.Fatalf("evict unknown remote = %v, want 404", err)
}
}
func TestEvictWaitsForFetchLock(t *testing.T) {
requireStack(t)
srv, _, _ := changingUpstream(t)
r := rpmRemote(t, "evict-lock", srv.URL)
ctx := context.Background()
const path = "8/Everything/x86_64/repodata/repomd.xml"
if ok, err := testEngine.cache.AcquireLock(ctx, r.Name, path, time.Minute); !ok || err != nil {
t.Fatalf("acquire: %v %v", ok, err)
}
done := make(chan error, 1)
go func() { done <- testEngine.Evict(ctx, r.Name, path) }()
select {
case err := <-done:
t.Fatalf("evict returned while fetch lock held: %v", err)
case <-time.After(200 * time.Millisecond):
}
_ = testEngine.cache.ReleaseLock(ctx, r.Name, path)
if err := <-done; err != nil {
t.Fatalf("evict: %v", err)
}
}
func TestEvictWildcardClearsPrefixOnly(t *testing.T) {
requireStack(t)
srv, rev := changingUpstream(t)
srv, rev, _ := changingUpstream(t)
r := rpmRemote(t, "evict-wild", srv.URL)
const (
repomd = "8/Everything/x86_64/repodata/repomd.xml"