mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
Clients resolve base_url and shards_base_url against the proxy's URL. A scheme or host sends them around the proxy, and a leading slash leaves its mount, so neither layout is served.
289 lines
9.8 KiB
Go
289 lines
9.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
"github.com/vmihailenco/msgpack/v5"
|
|
)
|
|
|
|
func zstdMsgpack(t *testing.T, v any) []byte {
|
|
t.Helper()
|
|
raw, err := msgpack.Marshal(v)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := compressCondaShard(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decodeZstdMsgpack(t *testing.T, body []byte, v any) {
|
|
t.Helper()
|
|
raw, err := decompressCondaShard(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := msgpack.Unmarshal(raw, v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
type shardFixture struct {
|
|
upstream *httptest.Server
|
|
shardHash string
|
|
requests []string
|
|
}
|
|
|
|
// newShardUpstream serves a sharded index for conda-forge/noarch with one
|
|
// shard for testpkg, holding an old and a recent build.
|
|
func newShardUpstream(t *testing.T, info map[string]any) *shardFixture {
|
|
t.Helper()
|
|
now := time.Now()
|
|
shard := zstdMsgpack(t, map[string]any{
|
|
"packages": map[string]any{
|
|
"testpkg-1.0.0-0.tar.bz2": map[string]any{
|
|
"name": "testpkg", "version": "1.0.0", "timestamp": now.Add(-7 * 24 * time.Hour).UnixMilli(),
|
|
"sha256": bytes.Repeat([]byte{1}, 32),
|
|
},
|
|
},
|
|
"packages.conda": map[string]any{
|
|
"testpkg-1.0.0-0.conda": map[string]any{
|
|
"name": "testpkg", "version": "1.0.0", "timestamp": now.Add(-7 * 24 * time.Hour).UnixMilli(),
|
|
},
|
|
"testpkg-2.0.0-0.conda": map[string]any{
|
|
"name": "testpkg", "version": "2.0.0", "timestamp": now.Add(-time.Hour).UnixMilli(),
|
|
},
|
|
},
|
|
"removed": []string{"testpkg-0.1.0-0.tar.bz2"},
|
|
})
|
|
sum := sha256.Sum256(shard)
|
|
f := &shardFixture{shardHash: hex.EncodeToString(sum[:])}
|
|
|
|
layout := ""
|
|
if s, _ := info["shards_base_url"].(string); strings.Contains(s, "shards/") {
|
|
layout = "shards/"
|
|
}
|
|
var index []byte
|
|
f.upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
f.requests = append(f.requests, r.URL.Path)
|
|
switch r.URL.Path {
|
|
case "/conda-forge/noarch/" + condaShardIndexFile:
|
|
_, _ = w.Write(index)
|
|
case "/conda-forge/noarch/" + layout + f.shardHash + condaShardSuffix:
|
|
_, _ = w.Write(shard)
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
t.Cleanup(f.upstream.Close)
|
|
|
|
// {upstream} in an info value stands for the upstream's URL, {host} for
|
|
// its host.
|
|
placeholders := strings.NewReplacer("{upstream}", f.upstream.URL, "{host}", strings.TrimPrefix(f.upstream.URL, "http://"))
|
|
for k, v := range info {
|
|
if s, ok := v.(string); ok {
|
|
info[k] = placeholders.Replace(s)
|
|
}
|
|
}
|
|
index = zstdMsgpack(t, map[string]any{
|
|
"version": 1,
|
|
"info": info,
|
|
"shards": map[string]any{"testpkg": sum[:]},
|
|
})
|
|
return f
|
|
}
|
|
|
|
func shardTestHandler(f *shardFixture, cooldownDefault string) http.Handler {
|
|
proxy := testProxy()
|
|
if cooldownDefault != "" {
|
|
proxy.Cooldown = &cooldown.Config{Default: cooldownDefault}
|
|
}
|
|
return NewCondaHandlerWithUpstream(proxy, "http://proxy.local", f.upstream.URL).Routes()
|
|
}
|
|
|
|
func serveShardRequest(h http.Handler, path string) *httptest.ResponseRecorder {
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
return w
|
|
}
|
|
|
|
type shardRecords struct {
|
|
Packages map[string]map[string]any `msgpack:"packages"`
|
|
PackagesConda map[string]map[string]any `msgpack:"packages.conda"`
|
|
Removed []string `msgpack:"removed"`
|
|
}
|
|
|
|
func (s shardRecords) filenames() []string {
|
|
var names []string
|
|
for k := range s.Packages {
|
|
names = append(names, k)
|
|
}
|
|
for k := range s.PackagesConda {
|
|
names = append(names, k)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
type shardIndex struct {
|
|
Version int `msgpack:"version"`
|
|
Info map[string]any `msgpack:"info"`
|
|
Shards map[string][]byte `msgpack:"shards"`
|
|
}
|
|
|
|
func TestCondaShardsWithoutCooldown(t *testing.T) {
|
|
for _, shardsBaseURL := range []string{"", "./shards/"} {
|
|
t.Run(shardsBaseURL, func(t *testing.T) {
|
|
f := newShardUpstream(t, map[string]any{"base_url": "", "shards_base_url": shardsBaseURL, "subdir": "noarch"})
|
|
h := shardTestHandler(f, "")
|
|
|
|
w := serveShardRequest(h, "/conda-forge/noarch/"+condaShardIndexFile)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("index status = %d", w.Code)
|
|
}
|
|
var index shardIndex
|
|
decodeZstdMsgpack(t, w.Body.Bytes(), &index)
|
|
if got := hex.EncodeToString(index.Shards["testpkg"]); got != f.shardHash {
|
|
t.Fatalf("index hash = %s, want upstream %s", got, f.shardHash)
|
|
}
|
|
if index.Info["shards_base_url"] != shardsBaseURL {
|
|
t.Fatalf("shards_base_url = %v, want %q", index.Info["shards_base_url"], shardsBaseURL)
|
|
}
|
|
|
|
dir := strings.TrimPrefix(shardsBaseURL, "./")
|
|
w = serveShardRequest(h, "/conda-forge/noarch/"+dir+f.shardHash+condaShardSuffix)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("shard status = %d", w.Code)
|
|
}
|
|
var shard shardRecords
|
|
decodeZstdMsgpack(t, w.Body.Bytes(), &shard)
|
|
if len(shard.filenames()) != 3 {
|
|
t.Fatalf("records = %v, want all 3", shard.filenames())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCondaShardsWithCooldown(t *testing.T) {
|
|
f := newShardUpstream(t, map[string]any{"base_url": "", "shards_base_url": "./shards/", "subdir": "noarch", "created_at": "2026-01-01T00:00:00Z"})
|
|
h := shardTestHandler(f, "3d")
|
|
|
|
w := serveShardRequest(h, "/conda-forge/noarch/"+condaShardIndexFile)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("index status = %d", w.Code)
|
|
}
|
|
if !strings.HasPrefix(w.Header().Get("Cache-Control"), "public, max-age=") {
|
|
t.Errorf("Cache-Control = %q", w.Header().Get("Cache-Control"))
|
|
}
|
|
var index shardIndex
|
|
decodeZstdMsgpack(t, w.Body.Bytes(), &index)
|
|
masked := hex.EncodeToString(index.Shards["testpkg"])
|
|
if masked == f.shardHash || len(masked) != 64 {
|
|
t.Fatalf("index hash = %s, want a masked hash", masked)
|
|
}
|
|
if index.Info["created_at"] != "2026-01-01T00:00:00Z" || index.Info["subdir"] != "noarch" {
|
|
t.Errorf("info lost fields: %v", index.Info)
|
|
}
|
|
shardsBaseURL, _ := index.Info["shards_base_url"].(string)
|
|
if !strings.HasPrefix(shardsBaseURL, "./"+condaCooldownShardDir+"/") || !strings.HasSuffix(shardsBaseURL, "/shards/") {
|
|
t.Fatalf("shards_base_url = %q", shardsBaseURL)
|
|
}
|
|
|
|
w = serveShardRequest(h, "/conda-forge/noarch/"+strings.TrimPrefix(shardsBaseURL, "./")+masked+condaShardSuffix)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("shard status = %d: %s", w.Code, w.Body)
|
|
}
|
|
var shard shardRecords
|
|
decodeZstdMsgpack(t, w.Body.Bytes(), &shard)
|
|
want := []string{"testpkg-1.0.0-0.conda", "testpkg-1.0.0-0.tar.bz2"}
|
|
if got := shard.filenames(); strings.Join(got, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("records = %v, want %v", got, want)
|
|
}
|
|
if len(shard.Removed) != 1 {
|
|
t.Errorf("removed = %v, want it kept", shard.Removed)
|
|
}
|
|
if _, ok := shard.Packages["testpkg-1.0.0-0.tar.bz2"]["sha256"].([]byte); !ok {
|
|
t.Errorf("sha256 is no longer binary: %T", shard.Packages["testpkg-1.0.0-0.tar.bz2"]["sha256"])
|
|
}
|
|
|
|
// The unfiltered shard stays out of reach while cooldown is on.
|
|
w = serveShardRequest(h, "/conda-forge/noarch/shards/"+f.shardHash+condaShardSuffix)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("plain shard status = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCondaCooldownShardRejectsBadPaths(t *testing.T) {
|
|
f := newShardUpstream(t, map[string]any{"base_url": "", "shards_base_url": ""})
|
|
h := shardTestHandler(f, "3d")
|
|
hash := strings.Repeat("a", 64) + condaShardSuffix
|
|
for _, p := range []string{
|
|
"/conda-forge/noarch/cooldown-shards/abc/" + hash,
|
|
"/conda-forge/noarch/cooldown-shards/1/other/" + hash,
|
|
"/conda-forge/noarch/cooldown-shards/1/" + strings.Repeat("A", 64) + condaShardSuffix,
|
|
"/conda-forge/noarch/cooldown-shards/1/x.msgpack.zst",
|
|
} {
|
|
if w := serveShardRequest(h, p); w.Code != http.StatusNotFound {
|
|
t.Errorf("%s: status = %d, want 404", p, w.Code)
|
|
}
|
|
}
|
|
if len(f.requests) != 0 {
|
|
t.Errorf("upstream was asked for %v", f.requests)
|
|
}
|
|
}
|
|
|
|
func TestCondaShardIndexUnsupportedLayout(t *testing.T) {
|
|
for name, info := range map[string]map[string]any{
|
|
"packages elsewhere": {"base_url": "https://cdn.example.com/conda-forge/noarch/", "shards_base_url": ""},
|
|
"shards elsewhere": {"base_url": "", "shards_base_url": "https://cdn.example.com/shards/"},
|
|
"nested shards": {"base_url": "", "shards_base_url": "./a/b/"},
|
|
"packages upstream": {"base_url": "{upstream}/conda-forge/noarch/", "shards_base_url": ""},
|
|
"shards upstream": {"base_url": "", "shards_base_url": "{upstream}/conda-forge/noarch/"},
|
|
"no scheme upstream": {"base_url": "//{host}/conda-forge/noarch/", "shards_base_url": ""},
|
|
"root-relative": {"base_url": "/conda-forge/noarch/", "shards_base_url": ""},
|
|
"root-relative shards": {"base_url": "", "shards_base_url": "/conda-forge/noarch/shards/"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := newShardUpstream(t, info)
|
|
for _, cd := range []string{"", "3d"} {
|
|
w := serveShardRequest(shardTestHandler(f, cd), "/conda-forge/noarch/"+condaShardIndexFile)
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("cooldown %q: status = %d, want 404 so clients use repodata.json", cd, w.Code)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCondaShardIndexUnknownVersion(t *testing.T) {
|
|
index := zstdMsgpack(t, map[string]any{"version": 2, "info": map[string]any{}, "shards": map[string]any{}})
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write(index)
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
proxy := testProxy()
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
h := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL).Routes()
|
|
if w := serveShardRequest(h, "/conda-forge/noarch/"+condaShardIndexFile); w.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestCondaShardMaskDependsOnBucket(t *testing.T) {
|
|
a := condaShardMask("conda-forge", "noarch", "1")
|
|
if a == condaShardMask("conda-forge", "noarch", "2") || a == condaShardMask("conda-forge", "linux-64", "1") {
|
|
t.Fatal("mask must change with bucket and subdir")
|
|
}
|
|
}
|