package handler import ( "bytes" "crypto/rand" "errors" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" "golang.org/x/mod/sumdb" "golang.org/x/mod/sumdb/note" "golang.org/x/mod/sumdb/tlog" ) const ( testSumDBName = "sum.example" // testSumDBTree looks like a signed tree as far as the proxy checks. testSumDBTree = "go.sum database tree\n1\nhash\n\n— sum.example signature\n" // testSumDBRecord looks like a lookup answer as far as the proxy checks. testSumDBRecord = "1\ngolang.org/x/text v0.14.0 h1:hash=\ngolang.org/x/text v0.14.0/go.mod h1:hash=\n\n" + testSumDBTree ) // hashTile returns a hash tile body of the given width. func hashTile(width int, fill string) string { return strings.Repeat(fill, width*tlog.HashSize) } // dataTile returns a data tile body holding width records, laid out the way // sum.golang.org serves it: a blank line between records, none after the // last. func dataTile(width int) string { records := make([]string, width) for i := range records { records[i] = fmt.Sprintf("example.com/m%d v1.0.0 h1:hash=\nexample.com/m%d v1.0.0/go.mod h1:hash=\n", i, i) } return strings.Join(records, "\n") } // fakeSumDB is a checksum database upstream serving fixed files. Unknown // paths get 404 with the body from notFound, or a default text. type fakeSumDB struct { server *httptest.Server mu sync.Mutex files map[string]string notFound map[string]string hits map[string]int down atomic.Bool } func newFakeSumDB(t *testing.T, files map[string]string) *fakeSumDB { t.Helper() if files == nil { files = map[string]string{} } db := &fakeSumDB{files: files, notFound: map[string]string{}, hits: map[string]int{}} db.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { db.mu.Lock() db.hits[r.URL.Path]++ body, ok := db.files[r.URL.Path] text, custom := db.notFound[r.URL.Path] db.mu.Unlock() if db.down.Load() { http.Error(w, "unavailable", http.StatusInternalServerError) return } if !ok { if !custom { text = "not found: " + r.URL.Path } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusNotFound) _, _ = io.WriteString(w, text) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, body) })) t.Cleanup(db.server.Close) return db } func (db *fakeSumDB) setFile(path, body string) { db.mu.Lock() defer db.mu.Unlock() db.files[path] = body } func (db *fakeSumDB) setNotFound(path, text string) { db.mu.Lock() defer db.mu.Unlock() db.notFound[path] = text } func (db *fakeSumDB) requests(path string) int { db.mu.Lock() defer db.mu.Unlock() return db.hits[path] } func (db *fakeSumDB) totalRequests() int { db.mu.Lock() defer db.mu.Unlock() total := 0 for _, n := range db.hits { total += n } return total } func newSumDBTestHandler(t *testing.T, sumDBURL string, cacheMetadata bool, ttl time.Duration) *GoHandler { t.Helper() proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = http.DefaultClient proxy.CacheMetadata = cacheMetadata proxy.MetadataTTL = ttl h := NewGoHandler(proxy, "http://proxy.example") h.SetSumDBs(map[string]string{testSumDBName: sumDBURL}, false) return h } func serveGoRequest(h *GoHandler, target string) *httptest.ResponseRecorder { w := httptest.NewRecorder() h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) return w } func TestGoSumDB_DefaultsToSumGolangOrg(t *testing.T) { h := NewGoHandler(nil, "http://proxy.example") if got := h.sumDBs[defaultGoSumDBName]; got != defaultGoSumDBURL { t.Errorf("default checksum database = %q, want %q", got, defaultGoSumDBURL) } h.SetSumDBs(nil, false) if got := h.sumDBs[defaultGoSumDBName]; got != defaultGoSumDBURL || len(h.sumDBs) != 1 { t.Errorf("SetSumDBs(nil) = %v, want only the default", h.sumDBs) } } func TestGoSumDB_SupportedProbe(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) for range 2 { for target, want := range map[string]int{ "/sumdb/sum.example/supported": http.StatusOK, "/sumdb/sum.golang.org/supported": http.StatusNotFound, "/sumdb/other.example/supported": http.StatusNotFound, } { if w := serveGoRequest(h, target); w.Code != want { t.Errorf("%s: status = %d, want %d", target, w.Code, want) } } } if n := db.totalRequests(); n != 1 { t.Errorf("upstream requests = %d, want 1: the probe fetches latest once, then uses the cache", n) } if h.sumDBProbes.recentlyFailed(testSumDBName) { t.Error("a probe served from the fresh cache was recorded as a failure") } } // TestGoSumDB_SupportedFallsBackWhenUnreachable covers the go command's only // way back to the database: once supported answers 200 it never contacts the // database directly, so a database the proxy cannot reach must get 404. func TestGoSumDB_SupportedFallsBackWhenUnreachable(t *testing.T) { for _, cacheMetadata := range []bool{true, false} { t.Run(fmt.Sprintf("cache_metadata=%v", cacheMetadata), func(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) db.down.Store(true) h := newSumDBTestHandler(t, db.server.URL, cacheMetadata, time.Hour) for i := range 2 { if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusNotFound { t.Fatalf("request %d: status = %d, want 404", i, w.Code) } } if n := db.requests("/latest"); n != 1 { t.Errorf("probes = %d, want 1: a failed probe is not repeated within %s", n, goSumDBProbeBackoff) } }) } } // TestGoSumDB_SupportedWithCachedTreeWhileUnreachable keeps the offline case: // a proxy that has served the database before can still serve it from cache. func TestGoSumDB_SupportedWithCachedTreeWhileUnreachable(t *testing.T) { t.Run("fresh tree", func(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) if w := serveGoRequest(h, "/sumdb/sum.example/latest"); w.Code != http.StatusOK { t.Fatalf("latest: status = %d, want 200", w.Code) } db.down.Store(true) if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Errorf("supported with a fresh cached tree: status = %d, want 200", w.Code) } }) // A stale tree only counts with go_sumdbs_offline. Without it, clients // that can reach the database themselves should go there. for _, offline := range []bool{true, false} { t.Run(fmt.Sprintf("stale tree offline=%v", offline), func(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) h.sumDBOffline = offline if w := serveGoRequest(h, "/sumdb/sum.example/latest"); w.Code != http.StatusOK { t.Fatalf("latest: status = %d, want 200", w.Code) } db.down.Store(true) want := http.StatusNotFound if offline { want = http.StatusOK } for i := range 2 { // second probe hits the remembered failure if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != want { t.Errorf("probe %d: status = %d, want %d", i, w.Code, want) } } if n := db.requests("/latest"); n != 2 { t.Errorf("upstream requests = %d, want 2: the failure is remembered", n) } }) } } // TestGoSumDB_SupportedGivesUpOnSilentDatabase: a database that accepts the // connection but never answers (a firewall dropping packets) must not hold // the go command, which waits for supported without a timeout of its own. func TestGoSumDB_SupportedGivesUpOnSilentDatabase(t *testing.T) { release := make(chan struct{}) db := newFakeSumDB(t, nil) t.Cleanup(func() { close(release) }) // runs before the server closes db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) h.sumDBProbes.timeout = 50 * time.Millisecond answered := make(chan *httptest.ResponseRecorder, 1) go func() { answered <- serveGoRequest(h, "/sumdb/sum.example/supported") }() select { case w := <-answered: if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } case <-time.After(time.Second): t.Fatal("supported still waiting for the database after a second") } } // TestGoSumDB_SupportedWithStaleTreeWhileSilent: the fetch only falls back // to a stale cached tree once upstream has failed, which a silent database // delays past the probe timeout. The proxy can still serve from cache, so // supported must say so. func TestGoSumDB_SupportedWithStaleTreeWhileSilent(t *testing.T) { release := make(chan struct{}) db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) t.Cleanup(func() { close(release) }) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) h.sumDBOffline = true h.sumDBProbes.timeout = 50 * time.Millisecond if w := serveGoRequest(h, "/sumdb/sum.example/latest"); w.Code != http.StatusOK { t.Fatalf("latest: status = %d, want 200", w.Code) } db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }) for i := range 2 { // second probe hits the remembered failure answered := make(chan *httptest.ResponseRecorder, 1) go func() { answered <- serveGoRequest(h, "/sumdb/sum.example/supported") }() select { case w := <-answered: if w.Code != http.StatusOK { t.Fatalf("probe %d: status = %d, want 200 from the cached tree", i, w.Code) } case <-time.After(time.Second): t.Fatalf("probe %d: supported still waiting after a second", i) } } } // TestGoSumDB_SlowProbeSuccessClearsFailure: a database that answers after // the probe timeout gets a 404 the first time, but once its answer is in // the cache, supported says yes without waiting for the next backoff. func TestGoSumDB_SlowProbeSuccessClearsFailure(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) inner := db.server.Config.Handler db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) inner.ServeHTTP(w, r) }) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) h.sumDBProbes.timeout = 10 * time.Millisecond if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusNotFound { t.Fatalf("first probe: status = %d, want 404", w.Code) } deadline := time.Now().Add(5 * time.Second) for h.sumDBProbes.recentlyFailed(testSumDBName) { if time.Now().After(deadline) { t.Fatal("the late fetch never cleared the failure") } time.Sleep(10 * time.Millisecond) } if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Errorf("after the late answer: status = %d, want 200", w.Code) } if n := db.requests("/latest"); n != 1 { t.Errorf("upstream requests = %d, want 1: the second probe is a cache hit", n) } } // TestGoSumDB_StaleFallbackIsNotAnAnswer: the fetch reports no error when // it falls back to the cached tree after the database failed. That must // not count as the database answering, or the failure would be forgotten // and every probe would wait for the timeout again. func TestGoSumDB_StaleFallbackIsNotAnAnswer(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) h.sumDBOffline = true h.sumDBProbes.timeout = 10 * time.Millisecond if w := serveGoRequest(h, "/sumdb/sum.example/latest"); w.Code != http.StatusOK { t.Fatalf("latest: status = %d, want 200", w.Code) } inner := db.server.Config.Handler db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) inner.ServeHTTP(w, r) }) db.down.Store(true) if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Fatalf("status = %d, want 200 from the cached tree", w.Code) } time.Sleep(300 * time.Millisecond) // the fetch has failed and fallen back by now if !h.sumDBProbes.recentlyFailed(testSumDBName) { t.Error("the stale fallback cleared the remembered failure") } } // TestGoSumDB_AnswerCountsWhenCacheWriteFails: an answer from the database // is an answer even when it could not be cached afterwards, whether or not // a stale tree was cached before. func TestGoSumDB_AnswerCountsWhenCacheWriteFails(t *testing.T) { for _, staleTree := range []bool{false, true} { t.Run(fmt.Sprintf("stale tree cached=%v", staleTree), func(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) if staleTree { if w := serveGoRequest(h, "/sumdb/sum.example/latest"); w.Code != http.StatusOK { t.Fatalf("latest: status = %d, want 200", w.Code) } } h.proxy.Storage.(*mockStorage).storeErr = errors.New("disk full") if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Errorf("status = %d, want 200: the database answered", w.Code) } if h.sumDBProbes.recentlyFailed(testSumDBName) { t.Error("an answered probe was recorded as a failure") } }) } } // TestGoSumDB_OfflineServesCacheWithoutWaiting: in offline mode, once the // database is known to be unreachable, stale lookups must come from the // cache at once instead of waiting for the upstream timeout each time. func TestGoSumDB_OfflineServesCacheWithoutWaiting(t *testing.T) { const lookup = "/lookup/golang.org/x/text@v0.14.0" release := make(chan struct{}) db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree, lookup: testSumDBRecord}) t.Cleanup(func() { close(release) }) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) h.sumDBOffline = true h.sumDBProbes.timeout = 50 * time.Millisecond for _, path := range []string{"/latest", lookup} { if w := serveGoRequest(h, "/sumdb/sum.example"+path); w.Code != http.StatusOK { t.Fatalf("%s: status = %d, want 200", path, w.Code) } } db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-release }) for _, path := range []string{"/supported", "/latest", lookup} { answered := make(chan *httptest.ResponseRecorder, 1) go func() { answered <- serveGoRequest(h, "/sumdb/sum.example"+path) }() select { case w := <-answered: if w.Code != http.StatusOK { t.Errorf("%s: status = %d, want 200 from the cache", path, w.Code) } case <-time.After(time.Second): t.Fatalf("%s: still waiting for the database after a second", path) } } } // TestGoSumDB_StaleLookupRemembersFailure: the go command asks supported // only once per process. A lookup that falls back to the cache must renew // the remembered failure itself, or offline mode would wait for the upstream // timeout on every lookup a minute into a long build. func TestGoSumDB_StaleLookupRemembersFailure(t *testing.T) { const lookup = "/lookup/golang.org/x/text@v0.14.0" db := newFakeSumDB(t, map[string]string{lookup: testSumDBRecord}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) h.sumDBOffline = true if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK { t.Fatalf("online: status = %d, want 200", w.Code) } if h.sumDBProbes.recentlyFailed(testSumDBName) { t.Fatal("an answered lookup was recorded as a failure") } db.down.Store(true) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK || w.Body.String() != testSumDBRecord { t.Fatalf("outage: status = %d, body = %q, want the cached record", w.Code, w.Body.String()) } if !h.sumDBProbes.recentlyFailed(testSumDBName) { t.Error("a lookup served from the cache after the database failed did not remember the failure") } } // TestGoSumDB_LateStaleLookupKeepsNewerSuccess: a lookup sent while the // database was failing, which falls back to the cache only after a probe has // reached the database again, must not mark it unreachable. func TestGoSumDB_LateStaleLookupKeepsNewerSuccess(t *testing.T) { const lookup = "/lookup/golang.org/x/text@v0.14.0" db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree, lookup: testSumDBRecord}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK { t.Fatalf("lookup: status = %d, want 200", w.Code) } var sentOnce sync.Once sent := make(chan struct{}) release := make(chan struct{}) inner := db.server.Config.Handler db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == lookup { sentOnce.Do(func() { close(sent) }) <-release http.Error(w, "unavailable", http.StatusServiceUnavailable) return } inner.ServeHTTP(w, r) }) done := make(chan *httptest.ResponseRecorder, 1) go func() { done <- serveGoRequest(h, "/sumdb/sum.example"+lookup) }() <-sent if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Fatalf("supported: status = %d, want 200", w.Code) } close(release) if w := <-done; w.Code != http.StatusOK || w.Body.String() != testSumDBRecord { t.Fatalf("lookup: status = %d, body = %q, want the cached record", w.Code, w.Body.String()) } if h.sumDBProbes.recentlyFailed(testSumDBName) { t.Error("a lookup that fell back after the probe succeeded marked the database unreachable") } } // TestGoSumDB_ShortcutNeedsOfflineMode: without go_sumdbs_offline, a // remembered failure does not keep cached lookups from asking the database, // which may well be back. func TestGoSumDB_ShortcutNeedsOfflineMode(t *testing.T) { const lookup = "/lookup/golang.org/x/text@v0.14.0" db := newFakeSumDB(t, map[string]string{lookup: testSumDBRecord}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK { t.Fatalf("lookup: status = %d, want 200", w.Code) } h.sumDBProbes.record(testSumDBName, false) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK { t.Fatalf("lookup with a remembered failure: status = %d, want 200", w.Code) } if n := db.requests(lookup); n != 2 { t.Errorf("database requests = %d, want 2: the cached lookup was served without asking", n) } } // TestGoSumDB_FreshTreeIsNotAnAnswer: a supported check answered from a // fresh cached tree did not reach the database, so it must not cancel a // failure that a lookup started earlier reports afterwards. func TestGoSumDB_FreshTreeIsNotAnAnswer(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": testSumDBTree}) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Fatalf("supported: status = %d, want 200", w.Code) } // Coarse clocks (Windows) can give the first probe's success and start // the same time; start must come after it. h.sumDBProbes.mu.Lock() succeeded := h.sumDBProbes.succeeded[testSumDBName] h.sumDBProbes.mu.Unlock() start := time.Now() for !start.After(succeeded) { start = time.Now() } db.down.Store(true) if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusOK { t.Fatalf("supported with a fresh cached tree: status = %d, want 200", w.Code) } h.sumDBProbes.recordFailure(testSumDBName, start) if !h.sumDBProbes.recentlyFailed(testSumDBName) { t.Error("a supported check answered from the cache counted as reaching the database") } } func TestGoSumDBProbes_TimeoutDoesNotOverwriteLaterSuccess(t *testing.T) { var probes goSumDBProbes start := time.Now() probes.record(testSumDBName, true) probes.recordFailure(testSumDBName, start) if probes.recentlyFailed(testSumDBName) { t.Error("a success recorded after the probe started was overwritten by its timeout") } probes.recordFailure(testSumDBName, time.Now().Add(time.Second)) if !probes.recentlyFailed(testSumDBName) { t.Error("a timeout with no later success was not recorded") } } // TestGoSumDB_SupportedRefusesBlockPage: egress proxies answer blocked hosts // with a 200 page. That is not a reachable database, and must not be cached // as its signed tree either. func TestGoSumDB_SupportedRefusesBlockPage(t *testing.T) { db := newFakeSumDB(t, map[string]string{"/latest": "access to this site is blocked"}) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) if w := serveGoRequest(h, "/sumdb/sum.example/supported"); w.Code != http.StatusNotFound { t.Errorf("supported: status = %d, want 404", w.Code) } if w := serveGoRequest(h, "/sumdb/sum.example/latest"); w.Code != http.StatusBadGateway { t.Errorf("latest: status = %d, want 502 for a block page", w.Code) } // The page was not cached: once the database answers, its tree is served. db.setFile("/latest", testSumDBTree) w := serveGoRequest(h, "/sumdb/sum.example/latest") if w.Code != http.StatusOK || w.Body.String() != testSumDBTree { t.Errorf("latest after recovery: status = %d, body = %q, want the signed tree", w.Code, w.Body.String()) } } // TestGoSumDB_LookupRefusesBlockPage: a block page in place of a lookup must // not be cached, where it would replace the copy served during an outage. // That includes a page that happens to start like a record. func TestGoSumDB_LookupRefusesBlockPage(t *testing.T) { const lookup = "/lookup/golang.org/x/text@v0.14.0" pages := map[string]string{ "html": "access to this site is blocked", "record without tree": "1\ngolang.org/x/text v0.14.0 h1:hash=\n\naccess to this site is blocked", } for name, page := range pages { t.Run(name, func(t *testing.T) { db := newFakeSumDB(t, map[string]string{lookup: testSumDBRecord}) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK { t.Fatalf("lookup: status = %d, want 200", w.Code) } db.setFile(lookup, page) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK || w.Body.String() != testSumDBRecord { t.Errorf("behind a block page: status = %d, body = %q, want the cached record", w.Code, w.Body.String()) } // The page did not replace the record: it is still there in an outage. db.down.Store(true) if w := serveGoRequest(h, "/sumdb/sum.example"+lookup); w.Code != http.StatusOK || w.Body.String() != testSumDBRecord { t.Errorf("in an outage: status = %d, body = %q, want the cached record", w.Code, w.Body.String()) } }) } } func TestGoSumDB_RequestsGoToTheDatabase(t *testing.T) { var moduleUpstreamRequests atomic.Int32 moduleUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { moduleUpstreamRequests.Add(1) http.NotFound(w, r) })) defer moduleUpstream.Close() const lookup = "/lookup/golang.org/x/text@v0.14.0" db := newFakeSumDB(t, map[string]string{lookup: testSumDBRecord}) proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = http.DefaultClient h := NewGoHandlerWithUpstream(proxy, "http://proxy.example", moduleUpstream.URL) h.SetSumDBs(map[string]string{testSumDBName: db.server.URL + "/"}, false) w := serveGoRequest(h, "/sumdb/sum.example"+lookup) if w.Code != http.StatusOK || w.Body.String() != testSumDBRecord { t.Fatalf("status = %d, body = %q; want 200 with the database's response", w.Code, w.Body.String()) } if n := db.requests(lookup); n != 1 { t.Errorf("database requests for %s = %d, want 1", lookup, n) } if n := moduleUpstreamRequests.Load(); n != 0 { t.Errorf("module upstream requests = %d, want 0", n) } } func TestGoSumDB_LookupCachedWithinTTL(t *testing.T) { const lookup = "/lookup/golang.org/x/text@v0.14.0" db := newFakeSumDB(t, map[string]string{lookup: testSumDBRecord}) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) for i := range 2 { w := serveGoRequest(h, "/sumdb/sum.example"+lookup) if w.Code != http.StatusOK || w.Body.String() != testSumDBRecord { t.Fatalf("request %d: status = %d, body = %q", i, w.Code, w.Body.String()) } } if n := db.requests(lookup); n != 1 { t.Errorf("database requests = %d, want 1: the second lookup is a cache hit", n) } } func TestGoSumDB_CompleteTilesSkipRevalidation(t *testing.T) { files := map[string]string{ "/tile/8/0/000": hashTile(256, "a"), "/tile/8/0/001.p/5": hashTile(5, "b"), "/latest": testSumDBTree, } wantRequests := map[string]int{ "/tile/8/0/000": 1, "/tile/8/0/001.p/5": 2, "/latest": 2, } db := newFakeSumDB(t, files) // Every cached entry is past its TTL by the second request. h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) for range 2 { for path, want := range files { w := serveGoRequest(h, "/sumdb/sum.example"+path) if w.Code != http.StatusOK || w.Body.String() != want { t.Fatalf("%s: status = %d, body = %q, want %q", path, w.Code, w.Body.String(), want) } } } for path, want := range wantRequests { if n := db.requests(path); n != want { t.Errorf("%s: database requests = %d, want %d", path, n, want) } } } // TestGoSumDB_DataTilesAreNotStored: the go command never fetches data // tiles, and stored they would add up to the whole log. func TestGoSumDB_DataTilesAreNotStored(t *testing.T) { files := map[string]string{ "/tile/8/data/000": dataTile(256), "/tile/8/data/001.p/5": dataTile(5), } db := newFakeSumDB(t, files) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) for range 2 { for path, want := range files { w := serveGoRequest(h, "/sumdb/sum.example"+path) if w.Code != http.StatusOK || w.Body.String() != want { t.Fatalf("%s: status = %d, body length %d, want the %d-byte tile", path, w.Code, w.Body.Len(), len(want)) } } } for path := range files { if n := db.requests(path); n != 2 { t.Errorf("%s: database requests = %d, want 2", path, n) } key := goSumDBCacheKey(testSumDBName, db.server.URL, strings.TrimPrefix(path, "/")) if entry, _ := h.proxy.DB.GetMetadataCache(goEcosystem, key); entry != nil { t.Errorf("%s: stored in the metadata cache", path) } } } // TestGoSumDB_BadTilesAreNotCached guards complete tiles, which are never // revalidated once cached: a response of the wrong size must not be stored. func TestGoSumDB_BadTilesAreNotCached(t *testing.T) { const tile = "/tile/8/0/000" db := newFakeSumDB(t, map[string]string{ tile: "please log in", }) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) if w := serveGoRequest(h, "/sumdb/sum.example"+tile); w.Code != http.StatusBadGateway { t.Fatalf("wrong-size tile: status = %d, want 502", w.Code) } want := hashTile(256, "a") db.setFile(tile, want) w := serveGoRequest(h, "/sumdb/sum.example"+tile) if w.Code != http.StatusOK || w.Body.String() != want { t.Fatalf("after upstream recovered: status = %d, body length %d, want the %d-byte tile", w.Code, w.Body.Len(), len(want)) } if n := db.requests(tile); n != 2 { t.Errorf("upstream requests = %d, want 2: the bad response was not cached", n) } } // TestGoSumDB_DamagedCachedTileIsFetchedAgain guards the check on the cached // copy: a complete tile is never revalidated, so a damaged blob would // otherwise be served for good. The database sends an ETag, so past the TTL // a request that asks whether the tile changed would get 304 and the damaged // copy back. func TestGoSumDB_DamagedCachedTileIsFetchedAgain(t *testing.T) { const tile = "/tile/8/0/000" want := hashTile(256, "a") for _, ttl := range []time.Duration{time.Hour, time.Nanosecond} { t.Run(fmt.Sprintf("ttl=%s", ttl), func(t *testing.T) { db := newFakeSumDB(t, map[string]string{tile: want}) inner := db.server.Config.Handler db.server.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("ETag", `"tile"`) if r.Header.Get("If-None-Match") == `"tile"` { w.WriteHeader(http.StatusNotModified) return } inner.ServeHTTP(w, r) }) h := newSumDBTestHandler(t, db.server.URL, true, ttl) if w := serveGoRequest(h, "/sumdb/sum.example"+tile); w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } store := h.proxy.Storage.(*mockStorage) blob := metadataStoragePath(goEcosystem, goSumDBCacheKey(testSumDBName, db.server.URL, "tile/8/0/000")) store.mu.Lock() if _, ok := store.files[blob]; !ok { store.mu.Unlock() t.Fatalf("tile not stored at %s", blob) } store.files[blob] = []byte(want[:100]) store.mu.Unlock() w := serveGoRequest(h, "/sumdb/sum.example"+tile) if w.Code != http.StatusOK || w.Body.String() != want { t.Fatalf("after damage: status = %d, body length %d, want the %d-byte tile", w.Code, w.Body.Len(), len(want)) } if n := db.requests(tile); n != 2 { t.Errorf("upstream requests = %d, want 2: the damaged copy was fetched again", n) } }) } } func TestGoSumDB_CompleteTilesNotStoredWithoutMetadataCache(t *testing.T) { const tile = "/tile/8/0/000" db := newFakeSumDB(t, map[string]string{tile: hashTile(256, "a")}) h := newSumDBTestHandler(t, db.server.URL, false, time.Hour) for range 2 { if w := serveGoRequest(h, "/sumdb/sum.example"+tile); w.Code != http.StatusOK { t.Fatalf("status = %d, want 200", w.Code) } } if n := db.requests(tile); n != 2 { t.Errorf("database requests = %d, want 2 with cache_metadata off", n) } } func TestGoSumDB_OfflineFallback(t *testing.T) { files := map[string]string{ "/lookup/golang.org/x/text@v0.14.0": testSumDBRecord, "/latest": testSumDBTree, "/tile/8/0/000": hashTile(256, "a"), "/tile/8/0/001.p/5": hashTile(5, "b"), } db := newFakeSumDB(t, files) h := newSumDBTestHandler(t, db.server.URL, true, time.Nanosecond) for path := range files { if w := serveGoRequest(h, "/sumdb/sum.example"+path); w.Code != http.StatusOK { t.Fatalf("%s online: status = %d, want 200", path, w.Code) } } db.down.Store(true) for path, want := range files { w := serveGoRequest(h, "/sumdb/sum.example"+path) if w.Code != http.StatusOK || w.Body.String() != want { t.Errorf("%s offline: status = %d, body = %q, want 200 with %q", path, w.Code, w.Body.String(), want) } } } func TestGoSumDB_NotFoundRelaysUpstreamText(t *testing.T) { const lookup = "/lookup/github.com/google/uuid@v9.9.9" const reason = "not found: github.com/google/uuid@v9.9.9: invalid version: unknown revision v9.9.9" for _, cacheMetadata := range []bool{true, false} { t.Run(fmt.Sprintf("cache_metadata=%v", cacheMetadata), func(t *testing.T) { db := newFakeSumDB(t, nil) db.setNotFound(lookup, reason) h := newSumDBTestHandler(t, db.server.URL, cacheMetadata, time.Hour) w := serveGoRequest(h, "/sumdb/sum.example"+lookup) if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } if got := strings.TrimSpace(w.Body.String()); got != reason { t.Errorf("body = %q, want the database's text %q", got, reason) } }) } t.Run("long text is cut", func(t *testing.T) { db := newFakeSumDB(t, nil) db.setNotFound(lookup, strings.Repeat("x", upstreamNotFoundBodyLimit+1000)) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) w := serveGoRequest(h, "/sumdb/sum.example"+lookup) if w.Code != http.StatusNotFound { t.Fatalf("status = %d, want 404", w.Code) } if got := w.Body.Len(); got != upstreamNotFoundBodyLimit { t.Errorf("body length = %d, want %d", got, upstreamNotFoundBodyLimit) } }) } func TestGoSumDB_InvalidPathsNeverReachUpstream(t *testing.T) { db := newFakeSumDB(t, nil) h := newSumDBTestHandler(t, db.server.URL, true, time.Hour) for _, target := range []string{ "/sumdb/sum.example", "/sumdb/sum.example/", "/sumdb/sum.example/other", "/sumdb/sum.example/latest/extra", "/sumdb/sum.example/lookup/", "/sumdb/sum.example/lookup/golang.org/x/text", "/sumdb/sum.example/lookup/golang.org/x/text@", "/sumdb/sum.example/lookup/golang.org/x/text@master", "/sumdb/sum.example/lookup/github.com/Google/uuid@v1.6.0", "/sumdb/sum.example/lookup/../../etc/passwd@v1.0.0", "/sumdb/sum.example/tile/x/0/000", "/sumdb/sum.example/tile/8/0/000.p/0", "/sumdb/sum.example/tile/8/0/000.p/256", "/sumdb/sum.example/tile/8/0/../../../latest", "/sumdb/unknown.example/latest", } { if w := serveGoRequest(h, target); w.Code != http.StatusNotFound { t.Errorf("%s: status = %d, want 404", target, w.Code) } } if n := db.totalRequests(); n != 0 { t.Errorf("upstream requests = %d, want 0", n) } } // sumDBClientOps lets the x/mod checksum database client read through the // proxy, keeping its configuration and cache in memory. type sumDBClientOps struct { t *testing.T remote string key string mu sync.Mutex config map[string][]byte cache map[string][]byte } func newSumDBClientOps(t *testing.T, remote, key string) *sumDBClientOps { return &sumDBClientOps{t: t, remote: remote, key: key, config: map[string][]byte{}, cache: map[string][]byte{}} } func (o *sumDBClientOps) ReadRemote(path string) ([]byte, error) { resp, err := http.Get(o.remote + path) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("GET %s: %s: %s", path, resp.Status, body) } return body, nil } func (o *sumDBClientOps) ReadConfig(file string) ([]byte, error) { if file == "key" { return []byte(o.key), nil } o.mu.Lock() defer o.mu.Unlock() return o.config[file], nil } func (o *sumDBClientOps) WriteConfig(file string, old, data []byte) error { o.mu.Lock() defer o.mu.Unlock() if !bytes.Equal(o.config[file], old) { return sumdb.ErrWriteConflict } o.config[file] = data return nil } func (o *sumDBClientOps) ReadCache(file string) ([]byte, error) { o.mu.Lock() defer o.mu.Unlock() data, ok := o.cache[file] if !ok { return nil, errors.New("not cached") } return data, nil } func (o *sumDBClientOps) WriteCache(file string, data []byte) { o.mu.Lock() defer o.mu.Unlock() o.cache[file] = data } func (o *sumDBClientOps) Log(msg string) { o.t.Log(msg) } func (o *sumDBClientOps) SecurityError(msg string) { o.t.Errorf("checksum database security error: %s", msg) } // TestGoSumDB_ClientRoundTrip runs the go command's checksum database client // against a real database implementation through the proxy, so signatures, // record proofs and tile hashes are checked end to end. A second client then // verifies the same lookup from the proxy's cache with the database down. func TestGoSumDB_ClientRoundTrip(t *testing.T) { signerKey, verifierKey, err := note.GenerateKey(rand.Reader, testSumDBName) if err != nil { t.Fatal(err) } gosum := func(path, version string) ([]byte, error) { return fmt.Appendf(nil, "%s %s h1:Y29udGVudA==\n%s %s/go.mod h1:Z28ubW9k\n", path, version, path, version), nil } database := sumdb.NewServer(sumdb.NewTestServer(signerKey, gosum)) var down atomic.Bool upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if down.Load() { http.Error(w, "unavailable", http.StatusInternalServerError) return } database.ServeHTTP(w, r) })) defer upstream.Close() h := newSumDBTestHandler(t, upstream.URL, true, time.Hour) proxy := httptest.NewServer(h.Routes()) defer proxy.Close() remote := proxy.URL + "/sumdb/" + testSumDBName client := sumdb.NewClient(newSumDBClientOps(t, remote, verifierKey)) for _, m := range [][2]string{{"rsc.io/quote", "v1.5.2"}, {"golang.org/x/text", "v0.3.0"}, {"github.com/Google/UUID", "v1.6.0"}} { lines, err := client.Lookup(m[0], m[1]) if err != nil { t.Fatalf("lookup %s@%s through the proxy: %v", m[0], m[1], err) } if want := m[0] + " " + m[1] + " h1:Y29udGVudA=="; len(lines) != 1 || lines[0] != want { t.Errorf("lookup %s@%s = %q, want [%q]", m[0], m[1], lines, want) } } down.Store(true) offline := sumdb.NewClient(newSumDBClientOps(t, remote, verifierKey)) if _, err := offline.Lookup("rsc.io/quote", "v1.5.2"); err != nil { t.Fatalf("lookup from the proxy cache with the database down: %v", err) } }