Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/handler.go
Andrew Nesbitt 7e35cfc4dd
Merge pull request #483 from gilesw/feat/upstream-retry-after
Hold requests to an upstream that rate limits the proxy
2026-10-15 16:27:51 +01:00

2047 lines
78 KiB
Go

// Package handler provides HTTP protocol handlers for package manager proxying.
package handler
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/git-pkgs/artifacts"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/denylist"
"github.com/git-pkgs/proxy/internal/httpclient"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/packageurl"
"github.com/git-pkgs/proxy/internal/scanner"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
"github.com/opencontainers/go-digest"
)
// containsPathTraversal returns true if the path contains ".." segments
// that could be used to escape the intended directory. It checks the path
// as given and after URL-decoding, and treats backslashes as separators.
func containsPathTraversal(path string) bool {
if hasDotDotSegment(path) {
return true
}
if decoded, err := url.PathUnescape(path); err == nil && decoded != path {
return hasDotDotSegment(decoded)
}
return false
}
func hasDotDotSegment(path string) bool {
path = strings.ReplaceAll(path, "\\", "/")
for segment := range strings.SplitSeq(path, "/") {
if segment == ".." {
return true
}
}
return false
}
func configuredUpstreamURL(value, defaultValue string) string {
if value == "" {
value = defaultValue
}
return strings.TrimRight(value, "/")
}
const defaultHTTPTimeout = 30 * time.Second
const artifactCopyBufferSize = 32 << 10
var artifactCopyBufferPool = sync.Pool{ //nolint:gochecknoglobals // shared across artifact responses
New: func() any {
buffer := make([]byte, artifactCopyBufferSize)
return &buffer
},
}
// canonicalPackagePURL returns a versionless PURL in canonical form so cooldown
// lookups match keys produced by config.CooldownConfig.NormalizedPackages.
func canonicalPackagePURL(ecosystem, name string) string {
return packageurl.MakeString(ecosystem, name, "")
}
// canonicalVersionPURL returns a versioned PURL in canonical form, matching
// the keys the artifact cache writes to the versions table.
func canonicalVersionPURL(ecosystem, name, version string) string {
return packageurl.MakeString(ecosystem, name, version)
}
var errUnsupportedPackageIdentity = errors.New("package identity cannot be represented as a PURL")
func packagePURLStrings(ecosystem, name, version string) (string, string, error) {
packagePURL := packageurl.MakeString(ecosystem, name, "")
versionPURL := packageurl.MakeString(ecosystem, name, version)
if packagePURL == "" || versionPURL == "" {
return "", "", fmt.Errorf("%w: %s %q", errUnsupportedPackageIdentity, ecosystem, name)
}
return packagePURL, versionPURL, nil
}
const contentTypeJSON = "application/json"
const (
headerAccept = "Accept"
headerAcceptEncoding = "Accept-Encoding"
headerContentType = "Content-Type"
headerContentLength = "Content-Length"
headerContentEncoding = "Content-Encoding"
headerETag = "ETag"
headerLastModified = "Last-Modified"
)
// ifNoneMatchHits reports whether the given If-None-Match header value
// matches the current entity tag using weak comparison, so "*" matches any
// tag, W/ prefixes are ignored on both sides, and a comma-separated list is
// scanned. An empty header or an empty stored tag never match.
func ifNoneMatchHits(header, etag string) bool {
if etag == "" || header == "" {
return false
}
if header == "*" {
return true
}
etag = strings.TrimPrefix(etag, "W/")
for value := range strings.SplitSeq(header, ",") {
if strings.TrimPrefix(strings.TrimSpace(value), "W/") == etag {
return true
}
}
return false
}
// defaultMetadataMaxSize is used when Proxy.MetadataMaxSize is unset.
const defaultMetadataMaxSize = 100 << 20
// ErrMetadataTooLarge is returned when upstream metadata exceeds the configured limit.
var ErrMetadataTooLarge = errors.New("metadata response exceeds size limit")
// ReadMetadata reads an upstream response body with a size limit to prevent OOM
// from unexpectedly large responses. Returns ErrMetadataTooLarge if the response
// is truncated by the limit.
func (p *Proxy) ReadMetadata(r io.Reader) ([]byte, error) {
limit := p.MetadataMaxSize
if limit <= 0 {
limit = defaultMetadataMaxSize
}
data, err := io.ReadAll(io.LimitReader(r, limit+1))
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, ErrMetadataTooLarge
}
return data, nil
}
// Proxy provides shared functionality for protocol handlers.
type Proxy struct {
DB *database.DB
Storage storage.Storage
Fetcher fetch.FetcherInterface
Resolver *fetch.Resolver
Logger *slog.Logger
Cooldown CooldownPolicy
Denylist *denylist.Policy
CacheMetadata bool
MetadataTTL time.Duration
MetadataMaxSize int64
GradleReadOnly bool
HostedEnabled bool
GradleMaxUploadSize int64
// NPMFullMetadata requests full npm packuments from upstream even when
// cooldown is disabled, so served metadata carries publish times.
NPMFullMetadata bool
DirectServe bool
DirectServeTTL time.Duration
// DirectServeBaseURL, if set, replaces the scheme and host of presigned
// URLs so clients receive a public address even when the proxy reaches
// storage at an internal one.
DirectServeBaseURL string
HTTPClient *http.Client
// RateLimits is the gate under the upstream clients. Artifact fetches
// check it before calling Fetcher, so held hosts never reach its breaker.
RateLimits *httpclient.RateLimitGate
// HostedNPMScopes and HostedComposerVendors name namespaces served from
// pushed packages alone, without asking the upstream.
HostedNPMScopes []string
HostedComposerVendors []string
// StreamArtifacts streams artifacts from upstream without storing them.
// Each request fetches its own copy: there is no cache to check and
// nothing for concurrent misses to share.
StreamArtifacts bool
// Scanners runs pre-cache artifact scanning (e.g. trivy, ClamAV, Wiz).
// Nil or disabled means artifacts are cached without scanning.
Scanners *scanner.Group
// ScanSigningKey authenticates pull requests to the internal
// /_internal/scan-fetch route used by scanners to retrieve staged
// artifacts, for every storage backend.
ScanSigningKey []byte
// ScanFetchBaseURL is the address scanners use to reach this proxy to
// pull staged artifacts.
ScanFetchBaseURL string
// inFlight coalesces concurrent cache misses for one artifact, so a single
// upstream fetch serves every waiting caller. Keyed by artifactCoalesceKey.
fetchMu sync.Mutex
inFlight map[string]*inflightFetch
// detachSlots caps the artifact fetches that keep running after their
// client went away (see DetachUpstreamFetch). Guarded by fetchMu while it
// is created.
detachSlots chan struct{}
// inFlightMeta does the same for metadata misses. Keyed by
// metadataCoalesceKey.
metaMu sync.Mutex
inFlightMeta map[string]*inflightMetadata
// rewrites caches metadata documents after their handler rewrites them.
// Nil leaves every request to rewrite its own copy.
rewrites *rewriteCache
// now is the clock rewritten metadata is dated by, and started the time
// the process started; see metadataDate.
now func() time.Time
started time.Time
}
// CooldownPolicy supplies version-age filtering and package-specific durations.
type CooldownPolicy interface {
IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool
Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision
For(ecosystem, packagePURL string) time.Duration
Enabled() bool
}
// NewProxy creates a new Proxy with the given dependencies.
func NewProxy(db *database.DB, store storage.Storage, fetcher fetch.FetcherInterface, resolver *fetch.Resolver, logger *slog.Logger) *Proxy {
if logger == nil {
logger = slog.Default()
}
return &Proxy{
DB: db,
Storage: store,
Fetcher: fetcher,
Resolver: resolver,
Logger: logger,
HTTPClient: &http.Client{
Timeout: defaultHTTPTimeout,
},
now: time.Now,
started: time.Now(),
}
}
// CacheResult contains information about a cached or fetched artifact.
type CacheResult struct {
Reader io.ReadCloser
RedirectURL string
Artifact artifacts.Artifact
Cached bool
storagePath string
}
// GetOrFetchArtifact retrieves an artifact from cache or fetches from upstream.
func (p *Proxy) GetOrFetchArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
// NuGet ids are case-insensitive and the NuGet handler stores them
// lowercased, so a mirrored Newtonsoft.Json must land on the same record
// clients are served.
if ecosystem == "nuget" {
name = strings.ToLower(name)
}
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return nil, err
}
// Deny before resolution as well as before reading the cache. Mirror callers
// do not supply a filename, and resolving it can contact an upstream registry.
if p.Denylist.Denied(versionPURL) {
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
}
var info *fetch.ArtifactInfo
if filename == "" {
info, err = p.resolveArtifact(ctx, ecosystem, name, version)
if err != nil {
return nil, err
}
filename = info.Filename
if filename == "" {
return nil, errors.New("resolved artifact has no filename")
}
}
if p.StreamArtifacts {
if info == nil {
if info, err = p.resolveArtifact(ctx, ecosystem, name, version); err != nil {
return nil, err
}
}
return p.streamFromUpstream(ctx, ecosystem, name, version, filename, versionPURL, info.URL, "", "",
func(fetchCtx context.Context) (*fetch.Artifact, error) {
return p.Fetcher.Fetch(fetchCtx, info.URL)
})
}
if cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename); err != nil {
return nil, err
} else if cached != nil {
return cached, nil
}
metrics.RecordCacheMiss(ecosystem)
key := artifactCoalesceKey(versionPURL, filename, "", "", "")
recheck := func() (artifacts.Artifact, string, bool) {
return p.cachedArtifactRecord(pkgPURL, versionPURL, filename, "")
}
return p.coalesceFetch(ctx, key, recheck, func(fetchCtx context.Context) (artifacts.Artifact, string, error) {
return p.fetchAndCache(fetchCtx, ecosystem, name, version, filename, pkgPURL, versionPURL, info)
})
}
// GetCachedArtifact retrieves an artifact from cache without contacting an upstream.
// It returns nil when no usable cache entry exists.
func (p *Proxy) GetCachedArtifact(ctx context.Context, ecosystem, name, version, filename string) (*CacheResult, error) {
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return nil, err
}
return p.checkCache(ctx, pkgPURL, versionPURL, filename)
}
// ClearCachedArtifact clears an artifact cache record after an external
// integrity check fails, and queues its stored bytes for deletion.
func (p *Proxy) ClearCachedArtifact(ecosystem, name, version, filename string) error {
if p.DB == nil || p.Storage == nil {
return nil
}
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return err
}
cached, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
if err != nil {
return fmt.Errorf("looking up cached artifact: %w", err)
}
if cached == nil {
return nil
}
return p.DB.DiscardArtifact(versionPURL, filename, cached.StoragePath)
}
// checkCache looks up an artifact in the cache. Returns nil if not cached.
// With StreamArtifacts set it always reports a miss, so entries stored before the
// mode was enabled are never served.
//
// A miss on a pushed artifact is an error rather than a miss: the caller would
// otherwise fetch the upstream copy and replace the pushed one with it.
func (p *Proxy) checkCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
result, err := p.lookupCache(ctx, pkgPURL, versionPURL, filename)
if err != nil || result != nil || p.StreamArtifacts {
return result, err
}
return nil, p.localArtifactMiss(versionPURL, filename)
}
// localArtifactMiss returns ErrHostedArtifactUnavailable when the artifact
// was pushed and is stored but couldn't be served, ErrHostedArtifactMissing
// when it was pushed and its stored object is gone, and nil otherwise.
func (p *Proxy) localArtifactMiss(versionPURL, filename string) error {
record, err := p.DB.GetArtifact(versionPURL, filename)
if err != nil {
return fmt.Errorf("checking artifact source: %w", err)
}
if record == nil || record.Source != database.SourceLocal {
return nil
}
if record.StoragePath.Valid {
return fmt.Errorf("%w: %s %s", ErrHostedArtifactUnavailable, versionPURL, filename)
}
return fmt.Errorf("%w: %s %s", ErrHostedArtifactMissing, versionPURL, filename)
}
// lookupCache looks up an artifact in the cache. Returns nil if not cached.
func (p *Proxy) lookupCache(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
if p.Denylist.Denied(versionPURL) {
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, versionPURL)
}
if p.StreamArtifacts {
return nil, nil
}
return p.readStored(ctx, pkgPURL, versionPURL, filename)
}
// readStored opens the stored object of an artifact, whether or not
// StreamArtifacts is set. Returns nil if no usable object is stored.
func (p *Proxy) readStored(ctx context.Context, pkgPURL, versionPURL, filename string) (*CacheResult, error) {
artifact, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
if err != nil {
return nil, fmt.Errorf("checking artifact cache: %w", err)
}
if artifact == nil {
return nil, nil
}
checks, err := newIntegrityChecks(artifact.Artifact.Digest.Encoded(), artifact.Integrity.String)
if err != nil {
p.rejectUnusableCacheRecord(artifact, versionPURL, filename, err)
return nil, nil
}
result := &CacheResult{
Artifact: artifact.Artifact,
Cached: true,
storagePath: artifact.StoragePath,
}
if p.DirectServe {
signed, err := p.Storage.SignedURL(ctx, artifact.StoragePath, p.DirectServeTTL)
if err == nil {
result.RedirectURL = rewriteSignedURLHost(signed, p.DirectServeBaseURL)
p.recordCacheHit(artifact.Ecosystem, versionPURL, filename)
return result, nil
}
if !errors.Is(err, storage.ErrSignedURLUnsupported) {
p.Logger.Warn("failed to sign storage URL, falling back to streaming",
"path", artifact.StoragePath, "error", err)
}
}
start := time.Now()
reader, err := p.Storage.Open(ctx, artifact.StoragePath)
metrics.RecordStorageOperation("read", time.Since(start))
if err != nil {
metrics.RecordStorageError("read", metrics.FailureDetail{
PURL: versionPURL, Filename: filename, Path: artifact.StoragePath, Err: err})
p.Logger.Warn("cached artifact missing from storage, will refetch",
"path", artifact.StoragePath, "error", err)
return nil, nil
}
result.Reader, err = checks.wrap(reader,
func(reason string) {
p.Logger.Error("cached artifact failed integrity check",
"purl", versionPURL, "filename", filename,
"path", artifact.StoragePath, "reason", reason)
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(artifact.Ecosystem), metrics.FailureDetail{
PURL: versionPURL, Filename: filename, Path: artifact.StoragePath, Err: errors.New(reason)})
if err := p.DB.DiscardArtifact(versionPURL, filename, artifact.StoragePath); err != nil {
p.Logger.Warn("failed to clear corrupt artifact from cache", "error", err)
}
})
if err != nil {
_ = reader.Close()
p.rejectUnusableCacheRecord(artifact, versionPURL, filename, err)
return nil, nil
}
p.recordCacheHit(artifact.Ecosystem, versionPURL, filename)
return result, nil
}
// rewriteSignedURLHost replaces the scheme and host of a signed URL with those
// from baseURL, preserving the path and query (which carry the signature).
// Returns signed unchanged if baseURL is empty or either URL fails to parse.
func rewriteSignedURLHost(signed, baseURL string) string {
if baseURL == "" {
return signed
}
s, err := url.Parse(signed)
if err != nil {
return signed
}
b, err := url.Parse(baseURL)
if err != nil || b.Scheme == "" || b.Host == "" {
return signed
}
s.Scheme = b.Scheme
s.Host = b.Host
return s.String()
}
func (p *Proxy) recordCacheHit(ecosystem, versionPURL, filename string) {
_ = p.DB.RecordArtifactHit(versionPURL, filename)
metrics.RecordCacheHit(ecosystem)
}
func (p *Proxy) rejectUnusableCacheRecord(artifact *database.CachedArtifact, versionPURL, filename string, cause error) {
p.Logger.Warn("cached artifact has unusable integrity metadata",
"purl", versionPURL, "filename", filename,
"path", artifact.StoragePath, "error", cause)
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(artifact.Ecosystem), metrics.FailureDetail{
PURL: versionPURL, Filename: filename, Path: artifact.StoragePath, Err: cause})
if err := p.DB.DiscardArtifact(versionPURL, filename, artifact.StoragePath); err != nil {
p.Logger.Warn("failed to clear unusable artifact from cache", "error", err)
}
}
func (p *Proxy) resolveArtifact(ctx context.Context, ecosystem, name, version string) (*fetch.ArtifactInfo, error) {
info, err := p.Resolver.Resolve(ctx, ecosystem, name, version)
if err != nil {
if errors.Is(err, fetch.ErrNotFound) {
return nil, ErrUpstreamNotFound
}
return nil, fmt.Errorf("resolving download URL: %w", err)
}
return info, nil
}
func (p *Proxy) fetchAndCache(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL string, info *fetch.ArtifactInfo) (artifacts.Artifact, string, error) {
// Reuse the resolution used for an unnamed cache lookup. Named requests
// still resolve only on a cache miss, inside the coalesced fetch.
if info == nil {
var err error
info, err = p.resolveArtifact(ctx, ecosystem, name, version)
if err != nil {
return artifacts.Artifact{}, "", err
}
}
if err := p.RateLimits.Held(info.URL); err != nil {
return artifacts.Artifact{}, "", err
}
p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", info.URL)
// Fetch from upstream with timing
fetchStart := time.Now()
artifact, err := p.Fetcher.Fetch(ctx, info.URL)
fetchDuration := time.Since(fetchStart)
if err != nil {
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
metrics.RecordUpstreamError(ecosystem, "fetch_failed", metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: info.URL, Err: err})
if errors.Is(err, fetch.ErrNotFound) {
return artifacts.Artifact{}, "", ErrUpstreamNotFound
}
return artifacts.Artifact{}, "", fmt.Errorf("fetching from upstream: %w", err)
}
metrics.RecordUpstreamFetch(ecosystem, fetchDuration)
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, info.URL, "", "", artifact)
}
// storeArtifact writes a fetched artifact to storage, verifies it against
// upstreamHash and declared, the integrity the registry's metadata declares,
// if they are non-empty, runs it through the scan gate if scanning is enabled,
// and commits it to the cache database, recording declared with the version
// so cache hits are verified against it too.
//
// The scan gate sits between Storage.Store and updateCacheDB: updateCacheDB
// is the only thing that makes an artifact visible to clients (checkCache
// looks up its row before touching Storage), so deferring it until after a
// verdict means a blocked artifact was never reachable by any client. On
// block, the just-stored bytes are deleted and ErrArtifactBlocked is
// returned; updateCacheDB is never called.
//
// It returns the artifact and its storage path, not a reader; callers get one
// from openStoredArtifact.
func (p *Proxy) storeArtifact(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, upstreamURL, upstreamHash, declared string, artifact *fetch.Artifact) (artifacts.Artifact, string, error) {
storagePath := storage.FetchPath(ecosystem, name, version, storage.NewFetchID(), filename)
body, verified, err := verifyIntegrity(artifact.Body, declared)
if err != nil {
_ = artifact.Body.Close()
return artifacts.Artifact{}, "", err
}
storeStart := time.Now()
size, hash, err := p.Storage.Store(ctx, storagePath, body)
_ = artifact.Body.Close()
metrics.RecordStorageOperation("write", time.Since(storeStart))
if err != nil {
metrics.RecordStorageError("write", metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: upstreamURL, Path: storagePath, Err: err})
return artifacts.Artifact{}, "", fmt.Errorf("storing artifact: %w", err)
}
if !artifactHashMatches(hash, upstreamHash) {
if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil {
p.Logger.Warn("failed to discard artifact with mismatched checksum", "path", storagePath, "error", delErr)
}
return artifacts.Artifact{}, "", fmt.Errorf("%w: upstream declared %s, got %s", ErrArtifactDigestMismatch, upstreamHash, hash)
}
if err := verified(); err != nil {
p.Logger.Error("fetched artifact failed integrity check",
"purl", versionPURL, "filename", filename, "url", upstreamURL, "error", err)
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(ecosystem), metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: err})
if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil {
p.Logger.Warn("failed to discard artifact with mismatched integrity", "path", storagePath, "error", delErr)
}
return artifacts.Artifact{}, "", err
}
if p.Scanners != nil && p.Scanners.Enabled() {
if err := p.runScan(ctx, ecosystem, name, version, filename, versionPURL, storagePath, size, artifact.ContentType); err != nil {
// Detached from ctx: a client disconnecting must not abort
// cleanup of a genuinely blocked artifact and leave its bytes
// orphaned in storage with no DB row pointing at them.
if delErr := p.Storage.Delete(context.WithoutCancel(ctx), storagePath); delErr != nil {
p.Logger.Warn("failed to delete blocked artifact from storage",
"path", storagePath, "error", delErr)
}
return artifacts.Artifact{}, "", err
}
}
sharedArtifact := artifacts.Artifact{
PURL: versionPURL,
Digest: digest.Digest("sha256:" + hash),
Size: size,
Filename: filename,
MediaType: artifact.ContentType,
}
// Update database
if err := p.updateCacheDB(ecosystem, name, pkgPURL, upstreamURL, storagePath, sharedArtifact, declared); err != nil {
p.Logger.Warn("failed to update cache database", "error", err)
// Continue anyway - we have the file. Queue it for deletion in case
// no record points at it; the queue skips it while one does.
if qErr := p.DB.QueuePendingDelete(storagePath); qErr != nil {
p.Logger.Warn("failed to queue unrecorded artifact for deletion", "path", storagePath, "error", qErr)
}
}
return sharedArtifact, storagePath, nil
}
// openStoredArtifact gives one caller its own reader over just-committed
// bytes. Callers sharing a fetch cannot share a handle: it has one read
// position, so they would consume each other's bytes and the first Close would
// break the rest.
func (p *Proxy) openStoredArtifact(ctx context.Context, artifact artifacts.Artifact, storagePath string) (*CacheResult, error) {
readStart := time.Now()
reader, err := p.Storage.Open(ctx, storagePath)
metrics.RecordStorageOperation("read", time.Since(readStart))
if err != nil {
metrics.RecordStorageError("read", metrics.FailureDetail{
PURL: artifact.PURL, Filename: artifact.Filename, Path: storagePath, Err: err})
return nil, fmt.Errorf("opening cached artifact: %w", err)
}
return &CacheResult{
Reader: reader,
Artifact: artifact,
Cached: false,
}, nil
}
// artifactCoalesceKey identifies one artifact fetch. downloadURL, upstreamHash
// and declared are included so callers expecting different bytes (multiple
// upstreams, or a re-published version) never share a fetch. The hash is
// lowercased because artifactHashMatches compares case-insensitively, so one
// digest in two casings describes one artifact and must not split the fetch.
func artifactCoalesceKey(versionPURL, filename, downloadURL, upstreamHash, declared string) string {
return strings.Join([]string{versionPURL, filename, downloadURL, strings.ToLower(upstreamHash), declared}, "\x00")
}
// cachedArtifactRecord reports an artifact already committed to the cache,
// without opening it. A caller checks the cache before it gets here, so a
// concurrent fetch can commit the same artifact in between; rechecking the
// record keeps that caller from fetching it a second time. A lookup error is
// reported as a miss, which costs a redundant fetch rather than a failure.
func (p *Proxy) cachedArtifactRecord(pkgPURL, versionPURL, filename, upstreamHash string) (artifacts.Artifact, string, bool) {
record, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
if err != nil || record == nil {
return artifacts.Artifact{}, "", false
}
if !artifactHashMatches(record.Artifact.Digest.Encoded(), upstreamHash) {
return artifacts.Artifact{}, "", false
}
return record.Artifact, record.StoragePath, true
}
// errSharedFetchAbandoned is what waiters see if the caller running a shared
// fetch panicked out of it.
var errSharedFetchAbandoned = errors.New("shared upstream fetch did not complete")
// inflightFetch is one upstream fetch that concurrent callers share. val and
// err are written before done closes and read only after, so the close is the
// handoff.
type inflightFetch struct {
done chan struct{}
val fetchedArtifact
err error
}
// coalesceFetch runs commit at most once for concurrent callers sharing key,
// then gives each its own reader over the stored bytes.
//
// The first caller in runs the fetch and the rest wait on it. Roles are
// decided under fetchMu rather than inferred afterwards, because the two need
// different cancellation behaviour: a waiter may leave when its own client goes
// away, while the caller running the fetch must see it through so
// storeArtifact's scan-on-disconnect handling still decides the outcome.
//
// Before fetching, that caller rechecks the cache through recheck: its own
// lookup happened before it took the key, so a fetch that committed in
// between would otherwise be repeated. A hit fills the shared value as a
// fetch would.
//
// commit runs on that caller's context, so cancellation behaves as it did
// uncoalesced and mirroring still relies on it aborting the fetch. If that
// caller goes away, everyone sharing the fetch gets its error and the key is
// released for a later retry. A context marked with DetachUpstreamFetch is the
// exception: there commit keeps running after the caller goes away, so the
// artifact still lands in the cache.
//
// Every sharing caller still records a cache miss, so the gap between
// proxy_cache_misses_total and upstream fetch observations is what coalescing
// saved.
func (p *Proxy) coalesceFetch(ctx context.Context, key string, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
p.fetchMu.Lock()
if p.inFlight == nil {
p.inFlight = make(map[string]*inflightFetch)
}
f, joined := p.inFlight[key]
if !joined {
f = &inflightFetch{done: make(chan struct{})}
p.inFlight[key] = f
}
p.fetchMu.Unlock()
if !joined {
return p.runSharedFetch(ctx, key, f, recheck, commit)
}
select {
case <-ctx.Done():
// This caller gave up; the fetch continues for everyone else.
return nil, ctx.Err()
case <-f.done:
}
if f.err != nil {
return nil, f.err
}
return p.openStoredArtifact(ctx, f.val.artifact, f.val.storagePath)
}
// runSharedFetch performs the fetch that joined callers are waiting on. It is
// never abandoned early, and always releases the key and wakes the waiters.
func (p *Proxy) runSharedFetch(ctx context.Context, key string, f *inflightFetch, recheck func() (artifacts.Artifact, string, bool), commit func(context.Context) (artifacts.Artifact, string, error)) (*CacheResult, error) {
// Set before running so a panicking commit leaves waiters with an error
// rather than a zero-valued artifact.
f.err = errSharedFetchAbandoned
defer func() {
p.fetchMu.Lock()
delete(p.inFlight, key)
p.fetchMu.Unlock()
close(f.done)
}()
// A caller checks the cache before reaching here, so a fetch that finished
// in between would otherwise be repeated. Serve that record only if its
// bytes are still present: a record can outlive them, and refetching is
// the same recovery the cache lookup makes.
if stored, path, ok := recheck(); ok {
if res, err := p.openStoredArtifact(ctx, stored, path); err == nil {
f.val, f.err = fetchedArtifact{artifact: stored, storagePath: path}, nil
return res, nil
}
}
commitCtx := ctx
if upstreamFetchDetached(ctx) && p.acquireDetachSlot() {
defer p.releaseDetachSlot()
commitCtx = context.WithoutCancel(ctx)
}
stored, path, err := commit(commitCtx)
f.val, f.err = fetchedArtifact{artifact: stored, storagePath: path}, err
if err != nil {
return nil, err
}
return p.openStoredArtifact(ctx, stored, path)
}
type detachUpstreamFetchKey struct{}
// DetachUpstreamFetch marks ctx so that an artifact fetch started under it
// runs to completion even when ctx is cancelled. The server sets it on client
// requests: a client that gives up on a slow cache miss (pacman aborts after
// 10 s without data) would otherwise cancel the fetch, and every retry would
// start again from zero, so a large artifact never reaches the cache. The
// fetch stays bounded by the artifact HTTP client's timeout, and at most
// maxDetachedFetches run detached at once. Mirror jobs leave it unset so
// cancelling a job still aborts its fetches.
func DetachUpstreamFetch(ctx context.Context) context.Context {
return context.WithValue(ctx, detachUpstreamFetchKey{}, true)
}
func upstreamFetchDetached(ctx context.Context) bool {
detached, _ := ctx.Value(detachUpstreamFetchKey{}).(bool)
return detached
}
// maxDetachedFetches caps the artifact fetches that may outlive their client.
// Without a cap a client could start any number of large uncached downloads
// and hang up straight away, leaving each to run to the artifact client's
// timeout. Fetches beyond the cap stay tied to their client, as before
// detaching existed.
const maxDetachedFetches = 64
func (p *Proxy) acquireDetachSlot() bool {
p.fetchMu.Lock()
if p.detachSlots == nil {
p.detachSlots = make(chan struct{}, maxDetachedFetches)
}
slots := p.detachSlots
p.fetchMu.Unlock()
select {
case slots <- struct{}{}:
return true
default:
return false
}
}
func (p *Proxy) releaseDetachSlot() {
<-p.detachSlots
}
// fetchedArtifact is what a shared fetch hands its callers: metadata and a
// storage path, neither holding reader state.
type fetchedArtifact struct {
artifact artifacts.Artifact
storagePath string
}
// runScan generates a signed fetch URL for the just-staged artifact and
// asks the configured scanners for a verdict. Returns a wrapped
// ErrArtifactBlocked if any scanner blocks, or a scan-infrastructure error.
//
// The scan call runs on a context detached from ctx's cancellation
// (context.WithoutCancel): ctx is the original client request's context, and
// a client disconnecting mid-scan must not be indistinguishable from a real
// scanner verdict. Group.Scan still bounds the call with its own configured
// timeout, so a detached context cannot hang forever.
func (p *Proxy) runScan(ctx context.Context, ecosystem, name, version, filename, purlStr, storagePath string, size int64, contentType string) error {
fetchURL := p.scanFetchURL(storagePath, p.Scanners.Timeout())
result := p.Scanners.Scan(context.WithoutCancel(ctx), scanner.Request{
Ecosystem: ecosystem,
Name: name,
Version: version,
Filename: filename,
PURL: purlStr,
FetchURL: fetchURL,
Size: size,
ContentType: contentType,
})
if !result.Allowed {
p.Logger.Warn("artifact blocked by security scan",
"ecosystem", ecosystem, "name", name, "version", version, "filename", filename,
"scanner", result.ScannerName, "reason", result.Reason, "infra_error", result.InfraError)
reason := result.Reason
if result.InfraError {
// result.Reason may contain raw scanner-infrastructure details
// (internal hostnames, ports, connection errors) that must not
// reach an untrusted client via the 403 response body.
reason = "scan could not be completed"
}
return fmt.Errorf("%w: %s", ErrArtifactBlocked, reason)
}
return nil
}
func (p *Proxy) updateCacheDB(ecosystem, name, pkgPURL, upstreamURL, storagePath string, artifact artifacts.Artifact, declared string) error {
now := time.Now()
// Upsert package
pkg := &database.Package{
PURL: pkgPURL,
Ecosystem: ecosystem,
Name: name,
RegistryURL: sql.NullString{String: upstreamURL, Valid: true},
EnrichedAt: sql.NullTime{Time: now, Valid: true},
}
if err := p.DB.UpsertPackage(pkg); err != nil {
return fmt.Errorf("upserting package: %w", err)
}
// Upsert version
ver := &database.Version{
PURL: artifact.PURL,
PackagePURL: pkgPURL,
Integrity: sql.NullString{String: declared, Valid: declared != ""},
EnrichedAt: sql.NullTime{Time: now, Valid: true},
}
if err := p.DB.UpsertVersion(ver); err != nil {
return fmt.Errorf("upserting version: %w", err)
}
// Upsert artifact
art := &database.Artifact{
VersionPURL: artifact.PURL,
Filename: artifact.Filename,
UpstreamURL: upstreamURL,
StoragePath: sql.NullString{String: storagePath, Valid: true},
ContentHash: sql.NullString{String: artifact.Digest.Encoded(), Valid: true},
Size: sql.NullInt64{Int64: artifact.Size, Valid: true},
ContentType: sql.NullString{String: artifact.MediaType, Valid: true},
FetchedAt: sql.NullTime{Time: now, Valid: true},
}
if err := p.DB.UpsertArtifact(art); err != nil {
return fmt.Errorf("upserting artifact: %w", err)
}
return nil
}
// ServeArtifact writes a CacheResult to an HTTP response.
func ServeArtifact(w http.ResponseWriter, result *CacheResult) {
serveArtifact(w, http.MethodGet, result)
}
// ServeArtifactRequest writes a CacheResult to an HTTP response using request
// headers such as Range and If-Range.
func ServeArtifactRequest(w http.ResponseWriter, r *http.Request, result *CacheResult) {
if r == nil {
ServeArtifact(w, result)
return
}
serveArtifactResponse(w, r.Method, r, result)
}
func serveArtifact(w http.ResponseWriter, method string, result *CacheResult) {
serveArtifactResponse(w, method, nil, result)
}
func serveArtifactResponse(w http.ResponseWriter, method string, request *http.Request, result *CacheResult) {
contentHash := ""
if result.Artifact.Digest != "" {
contentHash = result.Artifact.Digest.Encoded()
}
if result.RedirectURL != "" {
if contentHash != "" {
w.Header().Set(headerETag, `"`+contentHash+`"`)
}
w.Header().Set("Location", result.RedirectURL)
w.WriteHeader(http.StatusFound)
return
}
if result.Reader != nil {
defer func() { _ = result.Reader.Close() }()
}
if result.Artifact.MediaType != "" {
w.Header().Set(headerContentType, result.Artifact.MediaType)
}
if contentHash != "" {
w.Header().Set(headerETag, `"`+contentHash+`"`)
}
var seeker io.Seeker
if result.Reader != nil {
seeker, _ = result.Reader.(io.Seeker)
}
if seeker != nil && result.Artifact.Size >= 0 {
w.Header().Set("Accept-Ranges", "bytes")
if request != nil && serveArtifactRange(w, request, result, seeker) {
return
}
}
if result.Artifact.Size > 0 || (method == http.MethodHead && result.Artifact.Size == 0) {
w.Header().Set(headerContentLength, strconv.FormatInt(result.Artifact.Size, 10))
}
w.WriteHeader(http.StatusOK)
if method != http.MethodHead && result.Reader != nil {
copyArtifactBody(w, result.Reader, result.Artifact.Size, false)
}
}
type parsedByteRange struct {
start int64
end int64
}
func serveArtifactRange(w http.ResponseWriter, request *http.Request, result *CacheResult, seeker io.Seeker) bool {
if request.Method != http.MethodGet {
return false
}
ranges := request.Header.Values("Range")
if len(ranges) != 1 || !ifRangeMatches(request, w.Header().Get(headerETag)) {
return false
}
byteRange, valid, satisfiable := parseByteRange(ranges[0], result.Artifact.Size)
if !valid {
return false
}
if !satisfiable {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", result.Artifact.Size))
w.Header().Set(headerContentLength, "0")
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
return true
}
if _, err := seeker.Seek(byteRange.start, io.SeekStart); err != nil {
w.Header().Del(headerContentType)
w.Header().Del(headerContentLength)
w.Header().Del(headerETag)
w.Header().Del("Accept-Ranges")
http.Error(w, "failed to seek cached artifact", http.StatusInternalServerError)
return true
}
length := byteRange.end - byteRange.start + 1
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", byteRange.start, byteRange.end, result.Artifact.Size))
w.Header().Set(headerContentLength, strconv.FormatInt(length, 10))
w.WriteHeader(http.StatusPartialContent)
copyArtifactBody(w, result.Reader, length, true)
return true
}
func ifRangeMatches(request *http.Request, etag string) bool {
values := request.Header.Values("If-Range")
if len(values) == 0 {
return true
}
return len(values) == 1 && etag != "" && strings.TrimSpace(values[0]) == etag
}
func parseByteRange(value string, size int64) (parsedByteRange, bool, bool) {
unit, spec, ok := strings.Cut(strings.TrimSpace(value), "=")
if !ok || !strings.EqualFold(strings.TrimSpace(unit), "bytes") {
return parsedByteRange{}, false, false
}
spec = strings.TrimSpace(spec)
if spec == "" || strings.Contains(spec, ",") {
return parsedByteRange{}, false, false
}
first, last, ok := strings.Cut(spec, "-")
if !ok {
return parsedByteRange{}, false, false
}
if first == "" {
suffixLength, ok := parseRangeNumber(last)
if !ok {
return parsedByteRange{}, false, false
}
if size == 0 || suffixLength == 0 {
return parsedByteRange{}, true, false
}
if suffixLength >= size {
return parsedByteRange{start: 0, end: size - 1}, true, true
}
return parsedByteRange{start: size - suffixLength, end: size - 1}, true, true
}
start, ok := parseRangeNumber(first)
if !ok {
return parsedByteRange{}, false, false
}
if last == "" {
if size == 0 || start >= size {
return parsedByteRange{}, true, false
}
return parsedByteRange{start: start, end: size - 1}, true, true
}
end, ok := parseRangeNumber(last)
if !ok || start > end {
return parsedByteRange{}, false, false
}
if size == 0 || start >= size {
return parsedByteRange{}, true, false
}
if end >= size {
end = size - 1
}
return parsedByteRange{start: start, end: end}, true, true
}
func parseRangeNumber(value string) (int64, bool) {
if value == "" {
return 0, false
}
for _, digit := range value {
if digit < '0' || digit > '9' {
return 0, false
}
}
number, err := strconv.ParseInt(value, 10, 64)
return number, err == nil
}
func copyArtifactBody(w http.ResponseWriter, reader io.Reader, expectedSize int64, bounded bool) {
buffer := artifactCopyBufferPool.Get().(*[]byte)
defer artifactCopyBufferPool.Put(buffer)
source := reader
if bounded {
source = io.LimitReader(reader, expectedSize)
}
// Hide optional ReaderFrom methods so io.CopyBuffer uses the pooled buffer.
written, err := io.CopyBuffer(struct{ io.Writer }{w}, source, *buffer)
if err != nil || (bounded && written != expectedSize) || (!bounded && expectedSize > 0 && written != expectedSize) {
// Headers are already committed, so an error status is no longer
// possible. Aborting leaves the response unterminated so clients discard
// a truncated or unverified artifact.
// net/http recovers ErrAbortHandler for this request and keeps the server
// running; on HTTP/1.x it may close this connection as well.
panic(http.ErrAbortHandler)
}
}
// ProxyUpstream forwards a request to an upstream URL without caching.
// It copies the request, forwards specified headers, and streams the response back.
// forwardHeaders controls the request headers sent upstream. End-to-end response
// headers and trailers are relayed independently of that list.
func (p *Proxy) ProxyUpstream(w http.ResponseWriter, r *http.Request, upstreamURL string, forwardHeaders []string) {
p.Logger.Debug("proxying to upstream", "url", upstreamURL)
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
// Copy request headers that affect content negotiation / caching
for _, header := range forwardHeaders {
if v := r.Header.Get(header); v != "" {
req.Header.Set(header, v)
}
}
resp, err := p.HTTPClient.Do(req)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
p.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
p.relayResponse(w, r, resp, nil)
}
// ProxyFile forwards a file request, relaying end-to-end headers and trailers.
func (p *Proxy) ProxyFile(w http.ResponseWriter, r *http.Request, upstreamURL string) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
resp, err := p.HTTPClient.Do(req)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
p.relayResponse(w, r, resp, nil)
}
// JSONError writes a JSON error response.
func JSONError(w http.ResponseWriter, status int, message string) {
w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(status)
_, _ = fmt.Fprintf(w, `{"error":%q}`, message)
}
// ErrUpstreamNotFound indicates the upstream returned 404.
var ErrUpstreamNotFound = fmt.Errorf("upstream: %w", fetch.ErrNotFound)
// upstreamNotFoundBodyLimit caps the part of an upstream 404 body kept in
// upstreamNotFoundError.
const upstreamNotFoundBodyLimit = 4 << 10
// upstreamNotFoundError is ErrUpstreamNotFound with the start of the
// upstream's 404 body, for handlers that relay the reason to clients.
type upstreamNotFoundError struct {
body []byte
}
func (e *upstreamNotFoundError) Error() string { return ErrUpstreamNotFound.Error() }
func (e *upstreamNotFoundError) Unwrap() error { return ErrUpstreamNotFound }
// ErrArtifactBlocked indicates a pre-cache security scan blocked the artifact.
var ErrArtifactBlocked = errors.New("artifact blocked by security scan")
// ErrVersionDenied indicates an operator explicitly denied this package version.
var ErrVersionDenied = errors.New("package version blocked by denylist")
// ErrVersionNotFound indicates the requested version is not available through
// the proxy, including when cooldown or other filtering removed it.
var ErrVersionNotFound = errors.New("version not found")
// ErrPackageNotFound indicates the requested package metadata is not available
// through the proxy or upstream registry.
var ErrPackageNotFound = errors.New("package not found")
// serveArtifactError writes response for a failed fetch:
// 404 when upstream reports artifact missing, 403 when a security scan or
// denylist blocked the artifact, 502 otherwise.
func (p *Proxy) serveArtifactError(w http.ResponseWriter, err error, clientMsg string) {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if errors.Is(err, ErrArtifactBlocked) || errors.Is(err, ErrVersionDenied) {
JSONError(w, http.StatusForbidden, err.Error())
return
}
if errors.Is(err, ErrHostedArtifactMissing) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if errors.Is(err, ErrHostedArtifactUnavailable) {
p.Logger.Error("failed to read pushed artifact", "error", err)
http.Error(w, "pushed package is temporarily unavailable", http.StatusServiceUnavailable)
return
}
if writeUpstreamHeld(w, err) {
return
}
p.Logger.Error("failed to get artifact", "error", err)
http.Error(w, clientMsg, http.StatusBadGateway)
}
// errStale304 is returned when upstream sends 304 but the cached file is missing.
var errStale304 = fmt.Errorf("upstream returned 304 but cached file is missing")
// metadataStoragePath builds a storage path for cached metadata.
func metadataStoragePath(ecosystem, cacheKey string) string {
return "_metadata/" + ecosystem + "/" + cacheKey + "/metadata"
}
// FetchOrCacheMetadata fetches metadata from upstream with caching.
// On success it returns the raw response bytes and content type.
// If upstream fails and a cached copy exists, the cached version is returned.
// cacheKey is typically the package name but can include subpath components.
// Optional acceptHeaders specify the Accept header(s) to send; defaults to application/json.
func (p *Proxy) FetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL string, acceptHeaders ...string) ([]byte, string, error) {
body, contentType, _, err := p.fetchOrCacheMetadata(ctx, ecosystem, cacheKey, upstreamURL, "", nil, acceptHeaders...)
return body, contentType, err
}
// fetchOrCacheMetadata implements FetchOrCacheMetadata. acceptEncoding controls
// the upstream Accept-Encoding: an empty string leaves it unset so Go
// transparently decompresses (for direct callers that parse or rewrite the
// body); any non-empty value is sent verbatim, which disables Go's
// decompression so the wire bytes and their Content-Encoding are stored and
// replayed as sent. The ProxyCached path uses "identity" for signed indexes and
// "gzip" where both hops should stay compressed.
// validate, when supplied, runs before caching or serving a document. Validation
// failures follow the same stale-cache fallback path as upstream failures. It
// runs for every caller, including one that shares another caller's fetch, so
// it can also decode the document into request-local state; a caller that
// joined a fetch gets its own validation error directly. It must not modify the
// body, which joined callers share.
func (p *Proxy) fetchOrCacheMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, acceptEncoding string, validate func([]byte) error, acceptHeaders ...string) ([]byte, string, string, error) {
accept := contentTypeJSON
if len(acceptHeaders) > 0 && acceptHeaders[0] != "" {
accept = acceptHeaders[0]
}
res := p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate)
return res.body, res.contentType, res.contentEncoding, res.err
}
// fetchMetadataDocument is FetchOrCacheMetadata for callers that rewrite the
// document and need to know which one they got: the result carries its
// digest along with the body.
func (p *Proxy) fetchMetadataDocument(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept string) metadataResult {
return p.lookupMetadata(ctx, ecosystem, cacheKey, upstreamURL, accept, "", nil)
}
// lookupMetadata implements fetchOrCacheMetadata and fetchMetadataDocument.
func (p *Proxy) lookupMetadata(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error) metadataResult {
if containsPathTraversal(cacheKey) {
return metadataResult{err: fmt.Errorf("invalid cache key: %q", cacheKey)}
}
// Serve from cache if within TTL (skip upstream entirely)
if _, hit := p.cachedMetadataState(ctx, ecosystem, cacheKey, validate); hit != nil {
metrics.RecordCacheHit(ecosystem)
return *hit
}
p.recordMetadataCacheMiss(ecosystem)
return p.coalescedMetadataMiss(ctx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate)
}
// coalescedMetadataMiss handles a metadata cache miss, sharing one upstream
// fetch among concurrent callers with the same key.
func (p *Proxy) coalescedMetadataMiss(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error) metadataResult {
key := metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate != nil)
res, shared := p.coalesceMetadata(ctx, key, func(fetchCtx context.Context) metadataResult {
// The caller's lookup ran before it took the key, so a fetch that
// finished in between has already refreshed the row. Recheck it
// rather than fetching again, and revalidate against the row as it
// is now.
entry, hit := p.cachedMetadataState(fetchCtx, ecosystem, cacheKey, validate)
if hit != nil {
return *hit
}
return p.fetchMetadataFromUpstream(fetchCtx, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, validate, entry)
})
// The fetch ran the first caller's validate. A caller that joined it runs
// its own on the shared bytes, since validate may also decode them for it.
if shared && res.err == nil && validate != nil {
if err := validate(res.body); err != nil {
return metadataResult{err: err}
}
}
return res
}
// cachedMetadataState reads the cache row for a metadata lookup. hit is set
// when the row is within TTL and its bytes are usable. Otherwise entry is the
// row to revalidate against with its ETag: nil when there is none, or when
// validate rejected the cached body, since revalidating an unusable body would
// keep it.
func (p *Proxy) cachedMetadataState(ctx context.Context, ecosystem, cacheKey string, validate func([]byte) error) (*database.MetadataCacheEntry, *metadataResult) {
if !p.CacheMetadata || p.DB == nil {
return nil, nil
}
entry, _ := p.DB.GetMetadataCache(ecosystem, cacheKey)
if entry != nil && p.MetadataTTL > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) < p.MetadataTTL {
data, ct, err := p.readCachedMetadata(ctx, entry, validate)
if err == nil {
hit := metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String}.describedBy(entry)
return entry, &hit
}
if validate != nil {
return nil, nil
}
// Cache file missing/unreadable, fall through to upstream
}
return entry, nil
}
// fetchMetadataFromUpstream fetches metadata after a cache miss, caches it,
// and falls back to the cached copy if upstream fails. entry is the row to
// revalidate against, or nil.
func (p *Proxy) fetchMetadataFromUpstream(ctx context.Context, ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validate func([]byte) error, entry *database.MetadataCacheEntry) metadataResult {
meta, err := p.fetchUpstreamMetadata(ctx, upstreamURL, entry, accept, acceptEncoding)
if errors.Is(err, errStale304) {
// 304 but cached file is gone; retry without ETag
meta, err = p.fetchUpstreamMetadata(ctx, upstreamURL, nil, accept, acceptEncoding)
}
if err == nil && validate != nil {
err = validate(meta.body)
}
if err == nil {
var digest string
if p.CacheMetadata {
digest = p.cacheMetadataBlob(ctx, ecosystem, cacheKey, metadataStoragePath(ecosystem, cacheKey), meta)
}
return metadataResult{body: meta.body, contentType: meta.contentType, contentEncoding: meta.contentEncoding, digest: digest}
}
// Upstream failed -- fall back to cache if available
if !p.CacheMetadata || entry == nil {
return metadataResult{err: fmt.Errorf("upstream failed and no cached metadata: %w", err)}
}
p.Logger.Warn("upstream metadata fetch failed, checking cache",
"ecosystem", ecosystem, "key", cacheKey, "error", err)
// Re-read the row so the encoding describes the blob as it is now: a
// concurrent refetch may have replaced both since entry was read above
// (an identity blob swapped for a gzip one during rollout).
entry = p.currentMetadataEntry(ecosystem, cacheKey, entry)
data, ct, readErr := p.readCachedMetadata(ctx, entry, validate)
if readErr != nil {
return metadataResult{err: fmt.Errorf("upstream failed and cached metadata unusable (%v): %w", readErr, err)}
}
p.Logger.Info("serving metadata from cache",
"ecosystem", ecosystem, "key", cacheKey)
return metadataResult{body: data, contentType: ct, contentEncoding: entry.ContentEncoding.String, stale: true}.describedBy(entry)
}
// metadataResult is what a metadata lookup hands back. A shared fetch gives
// every waiter the same body, so callers must treat it as read-only.
type metadataResult struct {
body []byte
contentType string
contentEncoding string
// digest identifies body when the metadata cache stored it, and is
// empty otherwise.
digest string
err error
// stale is set when the body is the cached copy served because upstream
// failed.
stale bool
}
// describedBy returns res with the digest that entry, the cache row its body
// was read for, records.
func (res metadataResult) describedBy(entry *database.MetadataCacheEntry) metadataResult {
if entry.ContentDigest.Valid {
res.digest = entry.ContentDigest.String
}
return res
}
// inflightMetadata is one metadata fetch that concurrent callers share. res is
// written before done closes and read only after, so the close is the handoff.
type inflightMetadata struct {
done chan struct{}
res metadataResult
// waiters counts callers that joined the fetch, guarded by metaMu. Tests
// read it to know every caller has joined before the fetch finishes.
waiters int
}
// metadataCoalesceKey identifies metadata requests that can share one fetch.
// Accept and Accept-Encoding are part of it because they change the bytes
// upstream returns: npm serves an abbreviated or a full document for the same
// package, and the cached-proxy paths ask for identity or gzip. Whether the
// caller validates is part of it so an unvalidated caller is never handed a
// result that skipped validation, or the reverse.
func metadataCoalesceKey(ecosystem, cacheKey, upstreamURL, accept, acceptEncoding string, validated bool) string {
return strings.Join([]string{ecosystem, cacheKey, upstreamURL, accept, acceptEncoding, strconv.FormatBool(validated)}, "\x00")
}
// coalesceMetadata runs fetch at most once for concurrent callers sharing key
// and gives each the result, reporting whether this caller joined another's
// fetch rather than running it. It follows coalesceFetch with one difference:
// fetch runs on a context detached from the first caller's cancellation. A
// metadata fetch has no scan or mirror that depends on the caller aborting it,
// and CI jobs asking for the same Composer or npm metadata would otherwise all
// fail when the first of them disconnects. It stays bounded by the HTTP
// client's timeout. Waiters still leave when their own context ends.
func (p *Proxy) coalesceMetadata(ctx context.Context, key string, fetch func(context.Context) metadataResult) (metadataResult, bool) {
p.metaMu.Lock()
if p.inFlightMeta == nil {
p.inFlightMeta = make(map[string]*inflightMetadata)
}
f, joined := p.inFlightMeta[key]
if joined {
f.waiters++
} else {
f = &inflightMetadata{done: make(chan struct{})}
p.inFlightMeta[key] = f
}
p.metaMu.Unlock()
if joined {
select {
case <-ctx.Done():
return metadataResult{err: ctx.Err()}, true
case <-f.done:
return f.res, true
}
}
// Set before running so a panicking fetch leaves waiters with an error
// rather than an empty body.
f.res = metadataResult{err: errSharedFetchAbandoned}
defer func() {
p.metaMu.Lock()
delete(p.inFlightMeta, key)
p.metaMu.Unlock()
close(f.done)
}()
f.res = fetch(context.WithoutCancel(ctx))
return f.res, false
}
func (p *Proxy) readCachedMetadata(ctx context.Context, entry *database.MetadataCacheEntry, validate func([]byte) error) ([]byte, string, error) {
cached, err := p.Storage.Open(ctx, entry.StoragePath)
if err != nil {
return nil, "", err
}
defer func() { _ = cached.Close() }()
data, err := p.ReadMetadata(cached)
if err == nil && validate != nil {
err = validate(data)
}
if err != nil {
return nil, "", err
}
ct := contentTypeJSON
if entry.ContentType.Valid {
ct = entry.ContentType.String
}
return data, ct, nil
}
func (p *Proxy) recordMetadataCacheMiss(ecosystem string) {
if p.CacheMetadata {
metrics.RecordCacheMiss(ecosystem)
}
}
// upstreamMetadata holds a fetched metadata response in upstream byte form.
type upstreamMetadata struct {
body []byte
contentType string
contentEncoding string
etag string
lastModified time.Time
}
// fetchUpstreamMetadata fetches metadata from upstream, using ETag for conditional revalidation.
// When acceptEncoding is non-empty it is sent as the Accept-Encoding header, which disables Go's
// transparent decompression (it only applies when the transport adds the header itself), so the
// returned bytes are exactly what the upstream sent and any Content-Encoding it applied is reported
// alongside for the caller to store and replay. An empty acceptEncoding leaves Go to negotiate and
// decompress transparently.
func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, entry *database.MetadataCacheEntry, accept, acceptEncoding string) (*upstreamMetadata, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, upstreamURL, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Accept", accept)
if acceptEncoding != "" {
req.Header.Set(headerAcceptEncoding, acceptEncoding)
}
if entry != nil && entry.ETag.Valid {
req.Header.Set("If-None-Match", entry.ETag.String)
} else if entry != nil && entry.LastModified.Valid {
req.Header.Set("If-Modified-Since", entry.LastModified.Time.UTC().Format(http.TimeFormat))
}
resp, err := p.HTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching metadata: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// 304 Not Modified -- our cached copy is still good
if resp.StatusCode == http.StatusNotModified && entry != nil {
cached, readErr := p.Storage.Open(ctx, entry.StoragePath)
if readErr != nil {
return nil, errStale304
}
defer func() { _ = cached.Close() }()
data, readErr := p.ReadMetadata(cached)
if readErr != nil {
return nil, errStale304
}
meta := &upstreamMetadata{body: data, contentType: contentTypeJSON, etag: entry.ETag.String}
if entry.ContentType.Valid {
meta.contentType = entry.ContentType.String
}
if entry.ContentEncoding.Valid {
meta.contentEncoding = entry.ContentEncoding.String
}
if entry.LastModified.Valid {
meta.lastModified = entry.LastModified.Time
}
return meta, nil
}
if resp.StatusCode == http.StatusNotFound {
body, _ := io.ReadAll(io.LimitReader(resp.Body, upstreamNotFoundBodyLimit))
return nil, &upstreamNotFoundError{body: body}
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("upstream returned %d", resp.StatusCode)
}
body, err := p.ReadMetadata(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
meta := &upstreamMetadata{
body: body,
contentType: resp.Header.Get(headerContentType),
contentEncoding: resp.Header.Get(headerContentEncoding),
etag: resp.Header.Get(headerETag),
}
if meta.contentType == "" {
meta.contentType = contentTypeJSON
}
if lm := resp.Header.Get(headerLastModified); lm != "" {
meta.lastModified, _ = http.ParseTime(lm)
}
return meta, nil
}
// cacheMetadataBlob stores metadata bytes in storage and updates the
// database. It returns the digest it recorded for the bytes, or "" when they
// were not cached.
func (p *Proxy) cacheMetadataBlob(ctx context.Context, ecosystem, cacheKey, storagePath string, meta *upstreamMetadata) string {
if p.DB == nil || p.Storage == nil {
return ""
}
size, hash, err := p.Storage.Store(ctx, storagePath, bytes.NewReader(meta.body))
if err != nil {
p.Logger.Warn("failed to cache metadata", "ecosystem", ecosystem, "key", cacheKey, "error", err)
return ""
}
var digest string
if hash != "" {
digest = "sha256:" + hash
}
err = p.DB.UpsertMetadataCache(&database.MetadataCacheEntry{
Ecosystem: ecosystem,
Name: cacheKey,
StoragePath: storagePath,
ETag: sql.NullString{String: meta.etag, Valid: meta.etag != ""},
ContentType: sql.NullString{String: meta.contentType, Valid: meta.contentType != ""},
ContentEncoding: sql.NullString{String: meta.contentEncoding, Valid: meta.contentEncoding != ""},
// The digest identifies the stored bytes, so a rewrite cached for them
// can be found without reading them back (see storedRewrite).
ContentDigest: sql.NullString{String: digest, Valid: digest != ""},
Size: sql.NullInt64{Int64: size, Valid: true},
LastModified: sql.NullTime{Time: meta.lastModified, Valid: !meta.lastModified.IsZero()},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
})
if err != nil {
// The blob is written but the row describing it is not, so a later
// TTL hit or stale fallback would serve these bytes with the previous
// row's encoding. Drop the blob so row and bytes can never disagree;
// the next request refetches instead.
p.Logger.Warn("failed to record cached metadata, discarding blob", "ecosystem", ecosystem, "key", cacheKey, "error", err)
if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil {
p.Logger.Warn("failed to discard metadata blob", "ecosystem", ecosystem, "key", cacheKey, "error", delErr)
}
return ""
}
return digest
}
// currentMetadataEntry re-reads the metadata cache row and returns it, or
// fallback when the row cannot be read. Used before serving a stored blob so
// its encoding comes from the row as it is now rather than from a snapshot
// taken before the upstream fetch.
func (p *Proxy) currentMetadataEntry(ecosystem, cacheKey string, fallback *database.MetadataCacheEntry) *database.MetadataCacheEntry {
if fresh, err := p.DB.GetMetadataCache(ecosystem, cacheKey); err == nil && fresh != nil {
return fresh
}
return fallback
}
// cachedMeta holds cache validators and freshness state from a metadata cache entry.
type cachedMeta struct {
etag string
lastModified time.Time
stale bool
}
// lookupCachedMeta retrieves cache validators for a metadata entry.
func (p *Proxy) lookupCachedMeta(ecosystem, cacheKey string) cachedMeta {
if p.DB == nil {
return cachedMeta{}
}
entry, err := p.DB.GetMetadataCache(ecosystem, cacheKey)
if err != nil || entry == nil {
return cachedMeta{}
}
var cm cachedMeta
if entry.ETag.Valid {
cm.etag = entry.ETag.String
}
if entry.LastModified.Valid {
cm.lastModified = entry.LastModified.Time
}
// If FetchedAt is older than TTL, upstream must have failed and
// we served from stale cache (successful fetches update FetchedAt).
if p.MetadataTTL > 0 && entry.FetchedAt.Valid && time.Since(entry.FetchedAt.Time) > p.MetadataTTL {
cm.stale = true
}
return cm
}
// ProxyCached fetches metadata from upstream (with optional caching for offline fallback)
// and writes it to the response. Optional acceptHeaders specify the Accept header to send.
// When metadata caching is disabled, the response is streamed directly to avoid buffering
// large metadata responses (e.g. npm packages with many versions) in memory.
func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey string, acceptHeaders ...string) {
p.proxyCachedWithEncoding(w, r, upstreamURL, ecosystem, cacheKey, "identity", acceptHeaders...)
}
// proxyCachedWithEncoding is ProxyCached with an explicit upstream Accept-Encoding.
// "identity" preserves signed index bytes (the default); "gzip" keeps both hops
// compressed for large, non-hash-pinned metadata whose clients decode gzip
// (Homebrew API and Conda repodata). The stored bytes and Content-Encoding are
// replayed verbatim either way.
func (p *Proxy) proxyCachedWithEncoding(w http.ResponseWriter, r *http.Request, upstreamURL, ecosystem, cacheKey, acceptEncoding string, acceptHeaders ...string) {
if !p.CacheMetadata {
// Stream directly without buffering when caching is off.
p.proxyMetadataStream(w, r, upstreamURL, acceptEncoding, acceptHeaders...)
return
}
body, contentType, contentEncoding, err := p.fetchOrCacheMetadata(r.Context(), ecosystem, cacheKey, upstreamURL, acceptEncoding, nil, acceptHeaders...)
if err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if writeUpstreamHeld(w, err) {
return
}
p.Logger.Error("metadata fetch failed", "error", err)
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
p.writeMetadataCachedResponseWithEncoding(w, r, ecosystem, cacheKey, body, contentType, contentEncoding)
}
// writeMetadataCachedResponse writes a cached metadata response and handles
// conditional request headers using metadata cache validators.
func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
p.writeMetadataCachedResponseWithEncoding(w, r, ecosystem, cacheKey, body, contentType, "")
}
// writeMetadataCachedResponseWithEncoding is writeMetadataCachedResponse with
// an explicit Content-Encoding. contentEncoding must describe the body being
// written; it is passed in rather than re-read from the cache row, which is
// missing or stale when the metadata cache write failed and would otherwise
// mislabel the bytes.
func (p *Proxy) writeMetadataCachedResponseWithEncoding(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType, contentEncoding string) {
cm := p.lookupCachedMeta(ecosystem, cacheKey)
if cm.etag != "" {
w.Header().Set(headerETag, cm.etag)
}
if !cm.lastModified.IsZero() {
w.Header().Set(headerLastModified, cm.lastModified.UTC().Format(http.TimeFormat))
}
if ifNoneMatchHits(r.Header.Get("If-None-Match"), cm.etag) {
w.WriteHeader(http.StatusNotModified)
return
}
if !cm.lastModified.IsZero() {
if ims := r.Header.Get("If-Modified-Since"); ims != "" {
if t, err := http.ParseTime(ims); err == nil && !cm.lastModified.After(t) {
w.WriteHeader(http.StatusNotModified)
return
}
}
}
w.Header().Set(headerContentType, contentType)
w.Header().Set(headerContentLength, strconv.Itoa(len(body)))
if contentEncoding != "" {
w.Header().Set(headerContentEncoding, contentEncoding)
}
if cm.stale {
w.Header().Set("Warning", `110 - "Response is Stale"`)
}
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
_, _ = w.Write(body)
}
}
// proxyMetadataStream forwards an upstream metadata response by streaming it to the client
// without buffering the full body in memory.
func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upstreamURL, acceptEncoding string, acceptHeaders ...string) {
req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
accept := contentTypeJSON
if len(acceptHeaders) > 0 && acceptHeaders[0] != "" {
accept = acceptHeaders[0]
}
req.Header.Set("Accept", accept)
// Set Accept-Encoding explicitly (identity, or gzip for compressible
// verbatim metadata) so Go does not transparently decompress and strip the
// Content-Encoding of the bytes we forward, regardless of what the client
// negotiated. An empty value leaves the header unset, as in
// fetchUpstreamMetadata.
if acceptEncoding != "" {
req.Header.Set(headerAcceptEncoding, acceptEncoding)
}
for _, header := range []string{"If-Modified-Since", "If-None-Match"} {
if v := r.Header.Get(header); v != "" {
req.Header.Set(header, v)
}
}
resp, err := p.HTTPClient.Do(req)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
p.relayResponse(w, r, resp, func(dst, src http.Header) {
for _, header := range []string{headerContentType, headerContentLength, headerContentEncoding, headerLastModified, headerETag} {
if v := src.Get(header); v != "" {
dst.Set(header, v)
}
}
})
}
// GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
// This is useful for registries where download URLs are determined from metadata.
func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) {
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, "", "")
}
// GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
// with additional request-specific HTTP headers.
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) {
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, headers, "", "")
}
// GetOrFetchArtifactFromURLWithDigest retrieves an artifact and verifies its
// SHA-256 digest before adding a newly fetched response to the cache.
// Non-sha256 digests are proxied without verification.
func (p *Proxy) GetOrFetchArtifactFromURLWithDigest(ctx context.Context, ecosystem, name, version, filename, downloadURL, digest string) (*CacheResult, error) {
upstreamHash, _ := strings.CutPrefix(digest, "sha256:")
if upstreamHash == digest {
upstreamHash = ""
}
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, upstreamHash, "")
}
// GetOrFetchArtifactFromURLWithIntegrity retrieves an artifact and verifies a
// newly fetched response against declared, the integrity the registry's
// metadata declares, before serving or caching it: a Subresource Integrity
// string, or a single algorithm-prefixed hex checksum such as "sha1-<hex>".
// declared is recorded with the cached version, so cache hits are verified
// against it as well. An empty declared fetches without verification.
func (p *Proxy) GetOrFetchArtifactFromURLWithIntegrity(ctx context.Context, ecosystem, name, version, filename, downloadURL, declared string) (*CacheResult, error) {
return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, "", declared)
}
func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, upstreamHash, declared string) (*CacheResult, error) {
pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version)
if err != nil {
return nil, err
}
return p.getOrFetchArtifactFromURLWithCachePURLs(
ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash, declared,
)
}
func (p *Proxy) getOrFetchArtifactFromURLWithCachePURLs(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash, declared string) (*CacheResult, error) {
// Some protocols use qualified cache keys. Check their package identity too.
if p.versionDenied(ecosystem, name, version) {
return nil, fmt.Errorf("%w: %s", ErrVersionDenied, canonicalVersionPURL(ecosystem, name, version))
}
if p.StreamArtifacts {
return p.streamFromUpstream(ctx, ecosystem, name, version, filename, versionPURL, downloadURL, upstreamHash, declared,
func(fetchCtx context.Context) (*fetch.Artifact, error) {
return p.Fetcher.FetchWithHeaders(fetchCtx, downloadURL, headers)
})
}
if cached, err := p.getCachedArtifactWithUpstreamHash(ctx, pkgPURL, versionPURL, filename, upstreamHash); err != nil {
return nil, err
} else if cached != nil {
return cached, nil
}
metrics.RecordCacheMiss(ecosystem)
return p.coalescedFetchFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash, declared)
}
// coalescedFetchFromURL fetches an artifact the cache could not serve, sharing
// the fetch with concurrent callers. The caller running it discards a stale
// entry under the key, where it cannot delete a fetch that just replaced it.
func (p *Proxy) coalescedFetchFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash, declared string) (*CacheResult, error) {
key := artifactCoalesceKey(versionPURL, filename, downloadURL, upstreamHash, declared)
recheck := func() (artifacts.Artifact, string, bool) {
return p.cachedArtifactRecord(pkgPURL, versionPURL, filename, upstreamHash)
}
return p.coalesceFetch(ctx, key, recheck, func(fetchCtx context.Context) (artifacts.Artifact, string, error) {
p.discardStaleArtifact(pkgPURL, versionPURL, filename, upstreamHash)
return p.fetchAndCacheFromURL(fetchCtx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, upstreamHash, declared)
})
}
// getCachedArtifactWithUpstreamHash returns a cached artifact whose recorded
// content hash matches the checksum the upstream currently declares for it.
// This detects an upstream re-publishing under the same version, which the
// stream integrity check in checkCache cannot: that check only verifies the
// stored blob against the hash recorded when it was cached. A stale entry is
// a miss and is left in place: the fetch that replaces it discards it under
// the coalescing key.
func (p *Proxy) getCachedArtifactWithUpstreamHash(ctx context.Context, pkgPURL, versionPURL, filename, upstreamHash string) (*CacheResult, error) {
cached, err := p.checkCache(ctx, pkgPURL, versionPURL, filename)
if err != nil || cached == nil {
return cached, err
}
if artifactHashMatches(cached.Artifact.Digest.Encoded(), upstreamHash) {
return cached, nil
}
if cached.Reader != nil {
_ = cached.Reader.Close()
}
return nil, nil
}
// discardStaleArtifact clears the cached entry when its digest disagrees with
// upstreamHash, queueing its object for deletion. It runs under the coalescing
// key, after the recheck, so an entry a previous fetch refreshed is kept.
func (p *Proxy) discardStaleArtifact(pkgPURL, versionPURL, filename, upstreamHash string) {
record, err := p.DB.GetCachedArtifact(pkgPURL, versionPURL, filename)
if err != nil {
p.Logger.Warn("failed to read cache record before refetch",
"purl", versionPURL, "filename", filename, "error", err)
return
}
if record == nil || artifactHashMatches(record.Artifact.Digest.Encoded(), upstreamHash) {
return
}
p.Logger.Warn("cached artifact hash disagrees with upstream metadata, discarding",
"purl", versionPURL, "filename", filename, "cached", record.Artifact.Digest.Encoded(), "upstream", upstreamHash)
if err := p.DB.DiscardArtifact(versionPURL, filename, record.StoragePath); err != nil {
p.Logger.Warn("failed to clear artifact cache record", "purl", versionPURL, "filename", filename, "error", err)
}
}
func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, upstreamHash, declared string) (artifacts.Artifact, string, error) {
if err := p.RateLimits.Held(downloadURL); err != nil {
return artifacts.Artifact{}, "", err
}
p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL)
fetchStart := time.Now()
artifact, err := p.Fetcher.FetchWithHeaders(ctx, downloadURL, headers)
metrics.RecordUpstreamFetch(ecosystem, time.Since(fetchStart))
if err != nil {
metrics.RecordUpstreamError(ecosystem, "fetch_failed", metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: downloadURL, Err: err})
if errors.Is(err, fetch.ErrNotFound) {
return artifacts.Artifact{}, "", ErrUpstreamNotFound
}
return artifacts.Artifact{}, "", fmt.Errorf("fetching from upstream: %w", err)
}
return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, upstreamHash, declared, artifact)
}
// streamFromUpstream fetches an artifact when StreamArtifacts is set and hands its
// body to the caller without storing it.
//
// With upstreamHash or the declared integrity set the body is verified as it
// streams. The size is left
// unknown so the response goes out chunked: a mismatch only shows at EOF,
// after the bytes have been sent, and an unterminated chunked response is the
// only way left to make the client reject them (see serveArtifact).
func (p *Proxy) streamFromUpstream(ctx context.Context, ecosystem, name, version, filename, versionPURL, upstreamURL, upstreamHash, declared string, fetchArtifact func(context.Context) (*fetch.Artifact, error)) (*CacheResult, error) {
if err := p.RateLimits.Held(upstreamURL); err != nil {
return nil, err
}
p.Logger.Info("streaming from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", upstreamURL)
fetchStart := time.Now()
artifact, err := fetchArtifact(ctx)
metrics.RecordUpstreamFetch(ecosystem, time.Since(fetchStart))
if err != nil {
metrics.RecordUpstreamError(ecosystem, "fetch_failed", metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: err})
if errors.Is(err, fetch.ErrNotFound) {
return nil, ErrUpstreamNotFound
}
return nil, fmt.Errorf("fetching from upstream: %w", err)
}
body := &streamErrorLogger{
ReadCloser: artifact.Body,
onError: func(read int64, err error) {
p.Logger.Warn("streaming artifact from upstream failed",
"purl", versionPURL, "filename", filename, "url", upstreamURL, "bytes", read, "error", err)
metrics.RecordUpstreamError(ecosystem, "stream_failed", metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: err})
},
}
result := &CacheResult{
Reader: body,
Artifact: artifacts.Artifact{
PURL: versionPURL,
Size: artifact.Size,
Filename: filename,
MediaType: artifact.ContentType,
},
}
if upstreamHash == "" && declared == "" {
return result, nil
}
hash := strings.ToLower(upstreamHash)
checks, err := newIntegrityChecks(hash, declared)
if err != nil {
_ = artifact.Body.Close()
return nil, fmt.Errorf("parsing upstream digest: %w", err)
}
result.Reader, err = checks.wrapFailOnMismatch(body, func(reason string) {
p.Logger.Error("streamed artifact failed integrity check",
"purl", versionPURL, "filename", filename, "url", upstreamURL, "reason", reason)
metrics.RecordIntegrityFailure(purl.NormalizeEcosystem(ecosystem), metrics.FailureDetail{
PURL: versionPURL, Filename: filename, URL: upstreamURL, Err: errors.New(reason)})
})
if err != nil {
_ = artifact.Body.Close()
return nil, err
}
if hash != "" {
result.Artifact.Digest = digest.Digest("sha256:" + hash)
}
result.Artifact.Size = -1
return result, nil
}
// streamErrorLogger reports the first read error of a streamed upstream body.
// The error itself still reaches serveArtifact, which aborts the response.
type streamErrorLogger struct {
io.ReadCloser
onError func(read int64, err error)
read int64
logged bool
}
func (r *streamErrorLogger) Read(p []byte) (int, error) {
n, err := r.ReadCloser.Read(p)
r.read += int64(n)
if err != nil && err != io.EOF && !r.logged {
r.logged = true
r.onError(r.read, err)
}
return n, err
}
// ErrArtifactDigestMismatch indicates that fetched bytes did not match the
// checksum the upstream declared and were not recorded in the cache database.
var ErrArtifactDigestMismatch = errors.New("artifact digest mismatch")
func artifactHashMatches(got, expected string) bool {
return expected == "" || strings.EqualFold(got, expected)
}