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/conda_shards.go
2026-11-08 10:10:43 +01:00

457 lines
13 KiB
Go

package handler
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"sync"
"time"
"github.com/klauspost/compress/zstd"
"github.com/vmihailenco/msgpack/v5"
)
// Sharded repodata (CEP 16) maps each package name to the sha256 of its
// shard. With cooldown the proxy filters shards, which changes their bytes,
// but clients cache shards by hash and never fetch the same hash again. So
// the rewritten index XORs every hash with a mask that changes each hour:
// filtered shards get hashes of their own, and clients fetch them again once
// versions have left cooldown.
const (
condaShardIndexFile = "repodata_shards.msgpack.zst"
condaShardSuffix = ".msgpack.zst"
condaCooldownShardDir = "cooldown-shards"
condaShardsSubdir = "shards/"
condaShardBucket = time.Hour
condaShardIndexFormat = 1
condaShardHashLen = sha256.Size
// maxCondaShardSize bounds a decompressed index or shard. conda-forge's
// largest index is a few MB.
maxCondaShardSize = 256 << 20
)
var (
errCondaShardLayout = errors.New("unsupported sharded repodata layout")
condaZstd = sync.OnceValues(func() (*zstd.Decoder, error) {
return zstd.NewReader(nil, zstd.WithDecoderMaxMemory(maxCondaShardSize), zstd.WithDecoderConcurrency(0))
})
condaZstdEncoder = sync.OnceValues(func() (*zstd.Encoder, error) {
return zstd.NewWriter(nil)
})
)
func (h *CondaHandler) cooldownEnabled() bool {
return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
}
// handleShardIndex serves repodata_shards.msgpack.zst.
func (h *CondaHandler) handleShardIndex(w http.ResponseWriter, r *http.Request) {
channel, arch := r.PathValue("channel"), r.PathValue("arch")
upstreamURL := h.upstreamURL + r.URL.Path
cacheKey := h.metadataCacheKey(r.URL.Path)
body, contentType, contentEncoding, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", cacheKey, upstreamURL, "identity", nil, "*/*")
if err != nil {
h.proxy.writeMetadataFetchError(w, err)
return
}
if !h.cooldownEnabled() {
index, err := decodeCondaShardIndex(body)
if err == nil {
_, err = index.shardsLayout(upstreamURL)
}
if err != nil {
h.proxy.Logger.Info("serving no sharded repodata", "url", upstreamURL, "error", err)
http.NotFound(w, r)
return
}
h.proxy.writeMetadataCachedResponseWithEncoding(w, r, "conda", cacheKey, body, contentType, contentEncoding)
return
}
now := time.Now()
bucket := strconv.FormatInt(now.Unix()/int64(condaShardBucket/time.Second), 10)
rewritten, err := rewriteCondaShardIndex(body, upstreamURL, condaShardMask(channel, arch, bucket), bucket)
if err != nil {
h.proxy.Logger.Info("serving no sharded repodata", "url", upstreamURL, "error", err)
http.NotFound(w, r)
return
}
next := now.Truncate(condaShardBucket).Add(condaShardBucket)
maxAge := max(int(next.Sub(now)/time.Second), 1)
w.Header().Set(headerContentType, "application/octet-stream")
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
_, _ = w.Write(rewritten)
}
// handleShard serves a shard under its upstream hash. While cooldown is on,
// the index never points here, and the unfiltered shard is refused.
func (h *CondaHandler) handleShard(w http.ResponseWriter, r *http.Request) {
if h.cooldownEnabled() || !isCondaShardFile(path.Base(r.URL.Path)) {
http.NotFound(w, r)
return
}
h.proxyCached(w, r)
}
// handleCooldownShard serves a shard named in a rewritten index, filtered by
// cooldown.
func (h *CondaHandler) handleCooldownShard(w http.ResponseWriter, r *http.Request) {
channel, arch, bucket := r.PathValue("channel"), r.PathValue("arch"), r.PathValue("bucket")
dir, file := path.Split(r.PathValue("rest"))
if _, err := strconv.ParseUint(bucket, 10, 64); err != nil || (dir != "" && dir != condaShardsSubdir) || !isCondaShardFile(file) {
http.NotFound(w, r)
return
}
masked, _ := hex.DecodeString(strings.TrimSuffix(file, condaShardSuffix))
mask := condaShardMask(channel, arch, bucket)
for i := range masked {
masked[i] ^= mask[i]
}
upstreamPath := "/" + channel + "/" + arch + "/" + dir + hex.EncodeToString(masked) + condaShardSuffix
body, _, _, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", h.metadataCacheKey(upstreamPath), h.upstreamURL+upstreamPath, "identity", nil, "*/*")
if err != nil {
h.proxy.writeMetadataFetchError(w, err)
return
}
if h.cooldownEnabled() {
body, err = h.filterCondaShard(body)
if err != nil {
h.proxy.Logger.Error("failed to filter conda shard", "path", upstreamPath, "error", err)
http.Error(w, "failed to filter shard", http.StatusBadGateway)
return
}
}
w.Header().Set(headerContentType, "application/octet-stream")
_, _ = w.Write(body)
}
func isCondaShardFile(name string) bool {
hash, ok := strings.CutSuffix(name, condaShardSuffix)
if !ok || len(hash) != 2*condaShardHashLen {
return false
}
_, err := hex.DecodeString(hash)
return err == nil && strings.ToLower(hash) == hash
}
// condaShardMask is XORed with shard hashes in a rewritten index. It keeps
// filtered shards apart from upstream ones in client caches, which are keyed
// by hash alone.
func condaShardMask(channel, arch, bucket string) [condaShardHashLen]byte {
return sha256.Sum256([]byte("git-pkgs proxy conda cooldown shard\x00" + channel + "\x00" + arch + "\x00" + bucket))
}
// msgpackEntry is one member of a msgpack map, kept in its upstream order
// with its value undecoded.
type msgpackEntry struct {
key string
value msgpack.RawMessage
}
func decodeMsgpackMap(b []byte) ([]msgpackEntry, error) {
dec := msgpack.NewDecoder(bytes.NewReader(b))
n, err := dec.DecodeMapLen()
if err != nil {
return nil, err
}
if n < 0 {
return nil, errors.New("expected a msgpack map")
}
entries := make([]msgpackEntry, 0, n)
for range n {
key, err := dec.DecodeString()
if err != nil {
return nil, err
}
value, err := dec.DecodeRaw()
if err != nil {
return nil, err
}
entries = append(entries, msgpackEntry{key: key, value: value})
}
return entries, nil
}
func encodeMsgpackMap(entries []msgpackEntry) ([]byte, error) {
var buf bytes.Buffer
enc := msgpack.NewEncoder(&buf)
if err := enc.EncodeMapLen(len(entries)); err != nil {
return nil, err
}
for _, e := range entries {
if err := enc.EncodeString(e.key); err != nil {
return nil, err
}
if err := enc.Encode(e.value); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
func lookupMsgpackEntry(entries []msgpackEntry, key string) (int, bool) {
for i, e := range entries {
if e.key == key {
return i, true
}
}
return -1, false
}
func decompressCondaShard(body []byte) ([]byte, error) {
dec, err := condaZstd()
if err != nil {
return nil, err
}
return dec.DecodeAll(body, nil)
}
func compressCondaShard(body []byte) ([]byte, error) {
enc, err := condaZstdEncoder()
if err != nil {
return nil, err
}
return enc.EncodeAll(body, nil), nil
}
// condaShardIndex is a decoded repodata_shards.msgpack.zst.
type condaShardIndex struct {
top []msgpackEntry
info []msgpackEntry
}
func decodeCondaShardIndex(body []byte) (*condaShardIndex, error) {
raw, err := decompressCondaShard(body)
if err != nil {
return nil, err
}
top, err := decodeMsgpackMap(raw)
if err != nil {
return nil, err
}
i, ok := lookupMsgpackEntry(top, "version")
if !ok {
return nil, errors.New("sharded index has no version")
}
var version int
if err := msgpack.Unmarshal(top[i].value, &version); err != nil || version != condaShardIndexFormat {
return nil, fmt.Errorf("unsupported sharded index version %v", top[i].value)
}
i, ok = lookupMsgpackEntry(top, "info")
if !ok {
return nil, errors.New("sharded index has no info")
}
info, err := decodeMsgpackMap(top[i].value)
if err != nil {
return nil, err
}
return &condaShardIndex{top: top, info: info}, nil
}
func (idx *condaShardIndex) infoString(key string) (string, error) {
i, ok := lookupMsgpackEntry(idx.info, key)
if !ok {
return "", nil
}
var s string
err := msgpack.Unmarshal(idx.info[i].value, &s)
return s, err
}
// shardsLayout checks that packages and shards sit where the proxy routes
// them, below the subdir of indexURL, and returns the shards directory
// relative to the subdir: "" or "shards/". Clients resolve base_url and
// shards_base_url against the proxy's URL, so only relative paths stay on
// the proxy: a scheme or host leads clients around it, a leading / outside
// its mount.
func (idx *condaShardIndex) shardsLayout(indexURL string) (string, error) {
base, err := url.Parse(indexURL)
if err != nil {
return "", err
}
subdir := base.ResolveReference(&url.URL{Path: "./"}).String()
resolve := func(ref string) (string, error) {
if ref == "" {
return subdir, nil
}
u, err := url.Parse(ref)
if err != nil {
return "", err
}
if u.Scheme != "" || u.Host != "" || strings.HasPrefix(u.Path, "/") {
return "", errCondaShardLayout
}
return base.ResolveReference(u).String(), nil
}
baseURL, err := idx.infoString("base_url")
if err != nil {
return "", err
}
if resolved, err := resolve(baseURL); err != nil || resolved != subdir {
return "", fmt.Errorf("%w: base_url %q", errCondaShardLayout, baseURL)
}
shardsBaseURL, err := idx.infoString("shards_base_url")
if err != nil {
return "", err
}
resolved, err := resolve(shardsBaseURL)
rel, ok := strings.CutPrefix(resolved, subdir)
if err != nil || !ok || (rel != "" && rel != condaShardsSubdir) {
return "", fmt.Errorf("%w: shards_base_url %q", errCondaShardLayout, shardsBaseURL)
}
return rel, nil
}
// rewriteCondaShardIndex masks every shard hash and points shards_base_url
// at the cooldown shard route for bucket.
func rewriteCondaShardIndex(body []byte, indexURL string, mask [condaShardHashLen]byte, bucket string) ([]byte, error) {
idx, err := decodeCondaShardIndex(body)
if err != nil {
return nil, err
}
layout, err := idx.shardsLayout(indexURL)
if err != nil {
return nil, err
}
shardsBase, err := msgpack.Marshal("./" + condaCooldownShardDir + "/" + bucket + "/" + layout)
if err != nil {
return nil, err
}
if i, ok := lookupMsgpackEntry(idx.info, "shards_base_url"); ok {
idx.info[i].value = shardsBase
} else {
idx.info = append(idx.info, msgpackEntry{key: "shards_base_url", value: shardsBase})
}
info, err := encodeMsgpackMap(idx.info)
if err != nil {
return nil, err
}
i, _ := lookupMsgpackEntry(idx.top, "info")
idx.top[i].value = info
i, ok := lookupMsgpackEntry(idx.top, "shards")
if !ok {
return nil, errors.New("sharded index has no shards")
}
shards, err := decodeMsgpackMap(idx.top[i].value)
if err != nil {
return nil, err
}
for j := range shards {
var hash []byte
if err := msgpack.Unmarshal(shards[j].value, &hash); err != nil || len(hash) != condaShardHashLen {
return nil, fmt.Errorf("shard hash for %q is not a sha256", shards[j].key)
}
for k := range hash {
hash[k] ^= mask[k]
}
if shards[j].value, err = msgpack.Marshal(hash); err != nil {
return nil, err
}
}
if idx.top[i].value, err = encodeMsgpackMap(shards); err != nil {
return nil, err
}
raw, err := encodeMsgpackMap(idx.top)
if err != nil {
return nil, err
}
return compressCondaShard(raw)
}
// condaShardRecord holds the fields cooldown needs from a shard record.
// Timestamps are an integer or a float, in seconds or milliseconds as
// condaTime reads them.
type condaShardRecord struct {
Name string `msgpack:"name"`
Version string `msgpack:"version"`
Timestamp any `msgpack:"timestamp"`
}
// filterCondaShard leaves out records in packages and packages.conda that
// are still inside their cooldown. Kept records keep their upstream bytes.
func (h *CondaHandler) filterCondaShard(body []byte) ([]byte, error) {
raw, err := decompressCondaShard(body)
if err != nil {
return nil, err
}
top, err := decodeMsgpackMap(raw)
if err != nil {
return nil, err
}
for i := range top {
if top[i].key != "packages" && top[i].key != "packages.conda" {
continue
}
records, err := decodeMsgpackMap(top[i].value)
if err != nil {
return nil, err
}
kept := records[:0]
for _, rec := range records {
var fields condaShardRecord
if err := msgpack.Unmarshal(rec.value, &fields); err != nil {
return nil, fmt.Errorf("record %q: %w", rec.key, err)
}
if h.recordInCooldown(rec.key, fields.Name, fields.Version, msgpackNumber(fields.Timestamp)) {
continue
}
kept = append(kept, rec)
}
if top[i].value, err = encodeMsgpackMap(kept); err != nil {
return nil, err
}
}
out, err := encodeMsgpackMap(top)
if err != nil {
return nil, err
}
return compressCondaShard(out)
}
// msgpackNumber returns a decoded msgpack integer or float as float64, and 0
// for anything else.
func msgpackNumber(v any) float64 {
switch n := v.(type) {
case int8:
return float64(n)
case int16:
return float64(n)
case int32:
return float64(n)
case int64:
return float64(n)
case uint8:
return float64(n)
case uint16:
return float64(n)
case uint32:
return float64(n)
case uint64:
return float64(n)
case float32:
return float64(n)
case float64:
return n
default:
return 0
}
}