Apply cooldown to Go modules
With cooldown on, @v/list leaves out versions whose .info time is
inside the window, and @latest falls back to the newest release out of
cooldown when upstream's answer is too new. Each listed version's
.info is read once and its time is stored in the versions table, as
npm does, except for modules whose path has uppercase letters, since
golang PURLs are lowercased.
A .info, .mod or .zip for a version in cooldown, and a query such as a
branch name that resolves to one, gets 403. The go command moves on to
the next GOPROXY entry after 404 or 410, so a 404 would let
GOPROXY=proxy,direct fetch the version directly.
Go has no publish time: .info carries the commit time of the revision,
so a version tagged on an old commit counts as old. The docs say so.
Part of #132
2026-10-23 12:40:19 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type goCooldownUpstream struct {
|
|
|
|
|
server *httptest.Server
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
hits map[string]int
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (u *goCooldownUpstream) count(path string) int {
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
defer u.mu.Unlock()
|
|
|
|
|
return u.hits[path]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// newGoCooldownUpstream serves example.com/mod with an old v1.0.0, a
|
|
|
|
|
// recent v1.1.0 and a recent v1.2.0-rc.1, and @latest pointing at v1.1.0.
|
|
|
|
|
func newGoCooldownUpstream(t *testing.T, latest string) *goCooldownUpstream {
|
|
|
|
|
t.Helper()
|
|
|
|
|
now := time.Now()
|
|
|
|
|
times := map[string]time.Time{
|
|
|
|
|
"v1.0.0": now.Add(-10 * 24 * time.Hour),
|
|
|
|
|
"v1.1.0": now.Add(-time.Hour),
|
|
|
|
|
"v1.2.0-rc.1": now.Add(-2 * time.Hour),
|
|
|
|
|
}
|
|
|
|
|
u := &goCooldownUpstream{hits: map[string]int{}}
|
|
|
|
|
u.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
u.mu.Lock()
|
|
|
|
|
u.hits[r.URL.Path]++
|
|
|
|
|
u.mu.Unlock()
|
|
|
|
|
rest, ok := strings.CutPrefix(r.URL.Path, "/example.com/mod/")
|
|
|
|
|
if !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeInfo := func(v string) {
|
|
|
|
|
_ = json.NewEncoder(w).Encode(goInfo{Version: v, Time: times[v]})
|
|
|
|
|
}
|
|
|
|
|
switch {
|
|
|
|
|
case rest == "@v/list":
|
|
|
|
|
_, _ = w.Write([]byte("v1.0.0\nv1.1.0\nv1.2.0-rc.1\n"))
|
|
|
|
|
case rest == "@latest":
|
|
|
|
|
writeInfo(latest)
|
|
|
|
|
case rest == "@v/main.info":
|
|
|
|
|
writeInfo("v1.1.0")
|
|
|
|
|
case strings.HasSuffix(rest, ".info"):
|
|
|
|
|
v := strings.TrimSuffix(strings.TrimPrefix(rest, "@v/"), ".info")
|
|
|
|
|
if _, ok := times[v]; !ok {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeInfo(v)
|
|
|
|
|
case strings.HasSuffix(rest, ".mod"):
|
|
|
|
|
_, _ = w.Write([]byte("module example.com/mod\n"))
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(u.server.Close)
|
|
|
|
|
return u
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func goCooldownHandler(t *testing.T, u *goCooldownUpstream) (http.Handler, *mockFetcher, *Proxy) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
return NewGoHandlerWithUpstream(proxy, "http://localhost:8080", u.server.URL).Routes(), fetcher, proxy
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func goGet(h http.Handler, path string) *httptest.ResponseRecorder {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
|
|
|
return w
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoCooldownFiltersList(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.1.0")
|
|
|
|
|
h, _, _ := goCooldownHandler(t, u)
|
|
|
|
|
|
|
|
|
|
w := goGet(h, "/example.com/mod/@v/list")
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != "v1.0.0\n" {
|
|
|
|
|
t.Fatalf("list = %q, want only v1.0.0", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Publish times are stored, so a second list needs no .info requests.
|
|
|
|
|
_ = goGet(h, "/example.com/mod/@v/list")
|
|
|
|
|
if n := u.count("/example.com/mod/@v/v1.0.0.info"); n != 1 {
|
|
|
|
|
t.Errorf("v1.0.0.info fetched %d times, want 1", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoCooldownRefusesVersions(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.1.0")
|
|
|
|
|
h, fetcher, _ := goCooldownHandler(t, u)
|
|
|
|
|
|
|
|
|
|
for _, p := range []string{
|
|
|
|
|
"/example.com/mod/@v/v1.1.0.info",
|
|
|
|
|
"/example.com/mod/@v/v1.1.0.mod",
|
|
|
|
|
"/example.com/mod/@v/v1.1.0.zip",
|
|
|
|
|
"/example.com/mod/@v/main.info",
|
|
|
|
|
} {
|
|
|
|
|
w := goGet(h, p)
|
|
|
|
|
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), errGoCooldown) {
|
|
|
|
|
t.Errorf("%s: status = %d body %q, want 403 cooldown", p, w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fetcher.fetchCalled {
|
|
|
|
|
t.Error("zip in cooldown was fetched")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, p := range []string{"/example.com/mod/@v/v1.0.0.info", "/example.com/mod/@v/v1.0.0.mod"} {
|
|
|
|
|
if w := goGet(h, p); w.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("%s: status = %d, want 200", p, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoCooldownLatest(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.1.0")
|
|
|
|
|
h, _, _ := goCooldownHandler(t, u)
|
|
|
|
|
|
|
|
|
|
w := goGet(h, "/example.com/mod/@latest")
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d: %s", w.Code, w.Body)
|
|
|
|
|
}
|
|
|
|
|
var info goInfo
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if info.Version != "v1.0.0" {
|
|
|
|
|
t.Fatalf("@latest = %s, want v1.0.0", info.Version)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoCooldownLatestAllowed(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.0.0")
|
|
|
|
|
h, _, _ := goCooldownHandler(t, u)
|
|
|
|
|
|
|
|
|
|
w := goGet(h, "/example.com/mod/@latest")
|
|
|
|
|
var info goInfo
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil || info.Version != "v1.0.0" {
|
|
|
|
|
t.Fatalf("@latest = %d %s, want v1.0.0", w.Code, w.Body)
|
|
|
|
|
}
|
|
|
|
|
if n := u.count("/example.com/mod/@v/list"); n != 0 {
|
|
|
|
|
t.Errorf("list fetched %d times for an allowed @latest", n)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoCooldownDisabledPassesThrough(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.1.0")
|
|
|
|
|
h, _, proxy := goCooldownHandler(t, u)
|
|
|
|
|
proxy.Cooldown = nil
|
|
|
|
|
|
|
|
|
|
if w := goGet(h, "/example.com/mod/@v/list"); w.Body.String() != "v1.0.0\nv1.1.0\nv1.2.0-rc.1\n" {
|
|
|
|
|
t.Fatalf("list = %q", w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if w := goGet(h, "/example.com/mod/@v/v1.1.0.info"); w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("info status = %d", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNewestGoVersion(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
versions []string
|
|
|
|
|
want string
|
|
|
|
|
}{
|
|
|
|
|
{[]string{"v1.0.0", "v1.10.0", "v1.9.0", "v2.0.0-rc.1"}, "v1.10.0"},
|
|
|
|
|
{[]string{"v1.0.0-alpha", "v1.0.0-beta"}, "v1.0.0-beta"},
|
|
|
|
|
{[]string{"v2.0.0+incompatible", "v1.5.0"}, "v2.0.0+incompatible"},
|
|
|
|
|
{nil, ""},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
if got := newestGoVersion(tt.versions); got != tt.want {
|
|
|
|
|
t.Errorf("newestGoVersion(%v) = %q, want %q", tt.versions, got, tt.want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-23 12:46:39 +02:00
|
|
|
|
|
|
|
|
func TestGoCooldownFailsClosedOnUpstreamErrors(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, "/@v/list"):
|
|
|
|
|
_, _ = w.Write([]byte("v1.0.0\n"))
|
|
|
|
|
case strings.HasSuffix(r.URL.Path, ".info"):
|
|
|
|
|
http.Error(w, "slow down", http.StatusTooManyRequests)
|
|
|
|
|
default:
|
|
|
|
|
_, _ = w.Write([]byte("module example.com/mod\n"))
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, fetcher := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
h := NewGoHandlerWithUpstream(proxy, "http://localhost:8080", upstream.URL).Routes()
|
|
|
|
|
|
|
|
|
|
for _, p := range []string{
|
|
|
|
|
"/example.com/mod/@v/list",
|
|
|
|
|
"/example.com/mod/@v/v1.0.0.mod",
|
|
|
|
|
"/example.com/mod/@v/v1.0.0.zip",
|
|
|
|
|
} {
|
|
|
|
|
if w := goGet(h, p); w.Code != http.StatusBadGateway {
|
|
|
|
|
t.Errorf("%s: status = %d, want 502", p, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fetcher.fetchCalled {
|
|
|
|
|
t.Error("zip was fetched without a known time")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoCooldownStoresTimesPerExactModule(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.1.0")
|
|
|
|
|
proxy, db, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "3d"}
|
|
|
|
|
h := NewGoHandlerWithUpstream(proxy, "http://localhost:8080", u.server.URL).Routes()
|
|
|
|
|
|
|
|
|
|
_ = goGet(h, "/example.com/mod/@v/list")
|
|
|
|
|
ver, err := db.GetVersionByPURL(canonicalVersionPURL("golang", "example.com/mod", "v1.0.0"))
|
|
|
|
|
if err != nil || ver == nil || !ver.PublishedAt.Valid {
|
|
|
|
|
t.Fatalf("time of v1.0.0 not stored: %v %v", ver, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
gh := &GoHandler{proxy: proxy}
|
|
|
|
|
upper, _, ok := gh.goTimeKey("github.com/Azure/sdk", "v1.0.0")
|
|
|
|
|
lower, _, _ := gh.goTimeKey("github.com/azure/sdk", "v1.0.0")
|
|
|
|
|
if !ok || upper == lower {
|
|
|
|
|
t.Errorf("modules differing in case share a key: %q %q", upper, lower)
|
|
|
|
|
}
|
|
|
|
|
for _, query := range []string{"master", "v1.2", "v1.0.0+meta"} {
|
|
|
|
|
if _, _, ok := gh.goTimeKey("example.com/mod", query); ok {
|
|
|
|
|
t.Errorf("time of non-canonical %q would be stored", query)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGoRejectsBadlyEscapedVersions(t *testing.T) {
|
|
|
|
|
u := newGoCooldownUpstream(t, "v1.1.0")
|
|
|
|
|
h, fetcher, _ := goCooldownHandler(t, u)
|
|
|
|
|
for _, p := range []string{"/example.com/mod/@v/V1.0.0.zip", "/example.com/mod/@v/V1.0.0.mod"} {
|
|
|
|
|
if w := goGet(h, p); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("%s: status = %d, want 404", p, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if fetcher.fetchCalled || u.count("/example.com/mod/@v/V1.0.0.mod") != 0 {
|
|
|
|
|
t.Error("badly escaped version reached upstream")
|
|
|
|
|
}
|
|
|
|
|
}
|