Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions internal/database/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -1290,6 +1290,35 @@ func (db *DB) RefreshMetadataCache(ecosystem, name, digest string, fetchedAt tim
return n > 0, err
}

// MoveMetadataCache points the metadata cache row for ecosystem and name at
// to, provided it is still at from and records digest, and reports whether
// it did. The caller has copied the document from from to to.
func (db *DB) MoveMetadataCache(ecosystem, name, from, to, digest string) (bool, error) {
res, err := db.Exec(db.Rebind(`
UPDATE metadata_cache SET storage_path = ?, updated_at = ?
WHERE ecosystem = ? AND name = ? AND storage_path = ? AND content_digest = ?
`), to, time.Now(), ecosystem, name, from, digest)
if err != nil {
return false, err
}
n, err := res.RowsAffected()
return n > 0, err
}

// ForgetMetadataDocument points the metadata cache row for ecosystem and name
// at to, provided it is still at from, and clears what described the document
// at from: its digest, validators and fetch time. The row stays, so the next
// lookup fetches the document again rather than serving or revalidating it.
func (db *DB) ForgetMetadataDocument(ecosystem, name, from, to string) error {
_, err := db.Exec(db.Rebind(`
UPDATE metadata_cache
SET storage_path = ?, content_digest = NULL, etag = NULL, last_modified = NULL,
fetched_at = NULL, updated_at = ?
WHERE ecosystem = ? AND name = ? AND storage_path = ?
`), to, time.Now(), ecosystem, name, from)
return err
}

// UpsertMetadataCache writes entry and marks it accessed now, since a
// document is stored because a request asked for it.
func (db *DB) UpsertMetadataCache(entry *MetadataCacheEntry) error {
Expand Down
6 changes: 1 addition & 5 deletions internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -1296,11 +1296,6 @@ func (p *Proxy) serveArtifactError(w http.ResponseWriter, err error, clientMsg s
// 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.
Expand Down Expand Up @@ -1392,6 +1387,7 @@ func (p *Proxy) cachedMetadataState(ctx context.Context, ecosystem, cacheKey str
return nil, nil
}
entry, _ := p.DB.GetMetadataCache(ecosystem, cacheKey)
entry = p.migrateLegacyMetadataEntry(ctx, ecosystem, cacheKey, entry)
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 {
Expand Down
134 changes: 134 additions & 0 deletions internal/handler/metadata_path.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package handler

import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"

"github.com/git-pkgs/proxy/internal/database"
)

// metadataStoragePath builds the storage path for cached metadata. Cache
// keys are case-sensitive, but on a case-insensitive filesystem (APFS, NTFS)
// keys that differ only in case, such as Go's github.com/Sirupsen/logrus and
// github.com/sirupsen/logrus, would share one file. Keys may also hold names
// Windows reserves or end in a dot. So the path carries a hash of the exact
// key. Keys that already are lowercase SHA-256 hex digests are used as they
// are, so their existing entries stay valid.
func metadataStoragePath(ecosystem, cacheKey string) string {
if !isSHA256Hex(cacheKey) {
digest := sha256.Sum256([]byte(cacheKey))
cacheKey = hex.EncodeToString(digest[:])
}
return legacyMetadataStoragePath(ecosystem, cacheKey)
}

// legacyMetadataStoragePath is where metadata was stored before paths were
// hashed: under the cache key itself.
func legacyMetadataStoragePath(ecosystem, cacheKey string) string {
return "_metadata/" + ecosystem + "/" + cacheKey + "/metadata"
}

func isSHA256Hex(s string) bool {
if len(s) != 2*sha256.Size {
return false
}
for i := range len(s) {
c := s[i]
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}

// migrateLegacyMetadataEntry handles a cache row whose blob is still at its
// legacy, unhashed path. On a case-insensitive filesystem keys differing only
// in case shared that blob, so it is served only if it matches the row's digest.
func (p *Proxy) migrateLegacyMetadataEntry(ctx context.Context, ecosystem, cacheKey string, entry *database.MetadataCacheEntry) *database.MetadataCacheEntry {
if entry == nil || p.Storage == nil {
return entry
}
legacy := legacyMetadataStoragePath(ecosystem, cacheKey)
hashed := metadataStoragePath(ecosystem, cacheKey)
if entry.StoragePath != legacy || legacy == hashed {
return entry
}
var matches bool
if entry.ContentDigest.Valid {
digest, err := p.storedDigest(ctx, legacy)
if err != nil {
p.Logger.Warn("failed to read legacy metadata blob", "ecosystem", ecosystem, "key", cacheKey, "error", err)
return nil
}
matches = digest == entry.ContentDigest.String
}
if !matches {
if err := p.DB.ForgetMetadataDocument(ecosystem, cacheKey, legacy, hashed); err != nil {
p.Logger.Warn("failed to drop legacy metadata row", "ecosystem", ecosystem, "key", cacheKey, "error", err)
return nil
}
p.queueLegacyMetadataBlob(ecosystem, cacheKey, legacy)
return nil
}
if !p.moveLegacyMetadataBlob(ctx, entry, legacy, hashed) {
return entry
}
p.queueLegacyMetadataBlob(ecosystem, cacheKey, legacy)
moved := *entry
moved.StoragePath = hashed
return &moved
}

// storedDigest hashes the blob at path as cacheMetadataBlob records it.
func (p *Proxy) storedDigest(ctx context.Context, path string) (string, error) {
r, err := p.Storage.Open(ctx, path)
if err != nil {
return "", err
}
defer func() { _ = r.Close() }()
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}

// moveLegacyMetadataBlob copies entry's verified blob from legacy to hashed
// and points the row there, reporting whether it did.
func (p *Proxy) moveLegacyMetadataBlob(ctx context.Context, entry *database.MetadataCacheEntry, legacy, hashed string) bool {
logFailure := func(err error) {
p.Logger.Warn("failed to move legacy metadata blob", "ecosystem", entry.Ecosystem, "key", entry.Name, "error", err)
}
r, err := p.Storage.Open(ctx, legacy)
if err != nil {
logFailure(err)
return false
}
defer func() { _ = r.Close() }()
_, hash, err := p.Storage.Store(ctx, hashed, r)
if err != nil {
logFailure(err)
return false
}
if "sha256:"+hash != entry.ContentDigest.String {
logFailure(fmt.Errorf("blob changed while being copied"))
return false
}
moved, err := p.DB.MoveMetadataCache(entry.Ecosystem, entry.Name, legacy, hashed, entry.ContentDigest.String)
if err != nil {
logFailure(err)
return false
}
return moved
}

// queueLegacyMetadataBlob queues a row's legacy blob for deletion once the row
// no longer reads it. Reclaim skips it while another row still points there.
func (p *Proxy) queueLegacyMetadataBlob(ecosystem, cacheKey, legacy string) {
if err := p.DB.QueuePendingDelete(legacy); err != nil {
p.Logger.Warn("failed to queue legacy metadata blob for deletion", "ecosystem", ecosystem, "key", cacheKey, "error", err)
}
}
Loading
Loading