2026-10-18 21:27:18 +02:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.
Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.
fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00
|
|
|
"encoding/json"
|
2026-10-18 21:27:18 +02:00
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.
Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.
fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00
|
|
|
"sync/atomic"
|
2026-10-18 21:27:18 +02:00
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.
Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.
fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00
|
|
|
"github.com/git-pkgs/cooldown"
|
2026-10-18 21:27:18 +02:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// TestNPMTarballKeepsCachedPackumentFormat checks that a tarball download
|
|
|
|
|
// revalidates the packument in the format the metadata route cached it in,
|
|
|
|
|
// rather than replacing the abbreviated document every client is served with
|
|
|
|
|
// the full one.
|
|
|
|
|
func TestNPMTarballKeepsCachedPackumentFormat(t *testing.T) {
|
|
|
|
|
const abbreviated = "application/vnd.npm.install-v1+json"
|
|
|
|
|
var (
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
accepts []string
|
|
|
|
|
)
|
|
|
|
|
var upstream *httptest.Server
|
|
|
|
|
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
accept := r.Header.Get("Accept")
|
|
|
|
|
mu.Lock()
|
|
|
|
|
accepts = append(accepts, accept)
|
|
|
|
|
mu.Unlock()
|
|
|
|
|
contentType, etag := contentTypeJSON, `"full"`
|
|
|
|
|
if strings.HasPrefix(accept, abbreviated) {
|
|
|
|
|
contentType, etag = abbreviated, `"abbreviated"`
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set(headerETag, etag)
|
|
|
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
|
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set(headerContentType, contentType)
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"name":"demo","versions":{"1.0.0":{"name":"demo","version":"1.0.0","dist":{"tarball":%q}}}}`,
|
|
|
|
|
upstream.URL+"/demo/-/demo-1.0.0.tgz")
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(upstream.Close)
|
|
|
|
|
|
|
|
|
|
proxy, db, _, artifactFetcher := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Nanosecond // the tarball request revalidates
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
artifactFetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package")), ContentType: "application/gzip"}
|
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
|
|
|
|
|
|
if w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil); w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("metadata: status %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if w := serveNPM(t, h, http.MethodGet, "/demo/-/demo-1.0.0.tgz", nil, nil); w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("tarball: status %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mu.Lock()
|
|
|
|
|
defer mu.Unlock()
|
|
|
|
|
if len(accepts) != 2 {
|
|
|
|
|
t.Fatalf("upstream packument requests = %d (%q), want 2", len(accepts), accepts)
|
|
|
|
|
}
|
|
|
|
|
for _, accept := range accepts {
|
|
|
|
|
if accept != npmAcceptDefault {
|
|
|
|
|
t.Errorf("packument requested with Accept %q, want %q", accept, npmAcceptDefault)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
entry, err := db.GetMetadataCache("npm", "demo")
|
|
|
|
|
if err != nil || entry == nil {
|
|
|
|
|
t.Fatalf("cache row: %v, %v", entry, err)
|
|
|
|
|
}
|
|
|
|
|
if entry.ContentType.String != abbreviated {
|
|
|
|
|
t.Errorf("cached packument Content-Type = %q, want %q", entry.ContentType.String, abbreviated)
|
|
|
|
|
}
|
|
|
|
|
}
|
Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.
Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.
fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00
|
|
|
|
|
|
|
|
// abbreviatedThenFullRegistry serves a packument with a 2.0.0 published an
|
|
|
|
|
// hour ago and a 1.0.0 published a month ago. The abbreviated form, like
|
|
|
|
|
// registry.npmjs.org's, has no time map. fail makes requests for the full
|
|
|
|
|
// document fail.
|
|
|
|
|
func abbreviatedThenFullRegistry(t *testing.T, fail *atomic.Bool, fullRequests *atomic.Int64) *httptest.Server {
|
|
|
|
|
t.Helper()
|
|
|
|
|
const abbreviated = "application/vnd.npm.install-v1+json"
|
|
|
|
|
var upstream *httptest.Server
|
|
|
|
|
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != "/demo" {
|
|
|
|
|
w.WriteHeader(http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
versions := fmt.Sprintf(`{"1.0.0":{"name":"demo","version":"1.0.0","dist":{"tarball":%q}},"2.0.0":{"name":"demo","version":"2.0.0","dist":{"tarball":%q}}}`,
|
|
|
|
|
upstream.URL+"/demo/-/demo-1.0.0.tgz", upstream.URL+"/demo/-/demo-2.0.0.tgz")
|
|
|
|
|
if strings.HasPrefix(r.Header.Get("Accept"), abbreviated) {
|
|
|
|
|
w.Header().Set(headerContentType, abbreviated)
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"name":"demo","modified":%q,"dist-tags":{"latest":"2.0.0"},"versions":%s}`,
|
|
|
|
|
time.Now().Format(time.RFC3339), versions)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fullRequests.Add(1)
|
|
|
|
|
if fail.Load() {
|
|
|
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
|
|
|
_, _ = fmt.Fprintf(w, `{"name":"demo","dist-tags":{"latest":"2.0.0"},"time":{"1.0.0":%q,"2.0.0":%q},"versions":%s}`,
|
|
|
|
|
time.Now().Add(-30*24*time.Hour).Format(time.RFC3339), time.Now().Add(-time.Hour).Format(time.RFC3339), versions)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(upstream.Close)
|
|
|
|
|
return upstream
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedAbbreviatedPackument caches the abbreviated packument for demo through
|
|
|
|
|
// the metadata route while cooldown is off, then turns cooldown on.
|
|
|
|
|
func seedAbbreviatedPackument(t *testing.T, upstream *httptest.Server) *NPMHandler {
|
|
|
|
|
t.Helper()
|
|
|
|
|
proxy, db, _, artifactFetcher := setupTestProxy(t)
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
proxy.MetadataTTL = time.Hour
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
artifactFetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("package")), ContentType: "application/gzip"}
|
|
|
|
|
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
|
|
|
|
|
|
|
|
|
|
if w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil); w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("seeding metadata: status %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
entry, err := db.GetMetadataCache("npm", "demo")
|
|
|
|
|
if err != nil || entry == nil || entry.ContentType.String != "application/vnd.npm.install-v1+json" {
|
|
|
|
|
t.Fatalf("expected an abbreviated packument in the cache, got %+v (%v)", entry, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
proxy.Cooldown = &cooldown.Config{Default: "7d"}
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A packument cached in abbreviated form before cooldown was turned on has
|
|
|
|
|
// no publish times, so cooldown could not filter anything while it stayed
|
|
|
|
|
// fresh. With cooldown on it must be refetched in full.
|
|
|
|
|
func TestNPMCooldownRefetchesCachedAbbreviatedPackument(t *testing.T) {
|
|
|
|
|
var fail atomic.Bool
|
|
|
|
|
var fullRequests atomic.Int64
|
|
|
|
|
upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
|
|
|
|
|
h := seedAbbreviatedPackument(t, upstream)
|
|
|
|
|
|
|
|
|
|
w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("metadata: status %d: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
var doc struct {
|
|
|
|
|
DistTags map[string]string `json:"dist-tags"`
|
|
|
|
|
Versions map[string]json.RawMessage `json:"versions"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
|
|
|
|
|
t.Fatalf("decoding metadata: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if _, ok := doc.Versions["2.0.0"]; ok {
|
|
|
|
|
t.Error("version 2.0.0, published an hour ago, was not filtered by cooldown")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := doc.Versions["1.0.0"]; !ok {
|
|
|
|
|
t.Error("version 1.0.0 missing")
|
|
|
|
|
}
|
|
|
|
|
if doc.DistTags["latest"] != "1.0.0" {
|
|
|
|
|
t.Errorf("dist-tags.latest = %q, want 1.0.0", doc.DistTags["latest"])
|
|
|
|
|
}
|
|
|
|
|
if fullRequests.Load() == 0 {
|
|
|
|
|
t.Error("full packument was never fetched from upstream")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if w := serveNPM(t, h, http.MethodGet, "/demo/-/demo-2.0.0.tgz", nil, nil); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("tarball 2.0.0: status %d, want %d (withheld by cooldown)", w.Code, http.StatusNotFound)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The tarball check alone must not trust the abbreviated packument either.
|
|
|
|
|
func TestNPMCooldownTarballRefetchesCachedAbbreviatedPackument(t *testing.T) {
|
|
|
|
|
var fail atomic.Bool
|
|
|
|
|
var fullRequests atomic.Int64
|
|
|
|
|
upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
|
|
|
|
|
h := seedAbbreviatedPackument(t, upstream)
|
|
|
|
|
|
|
|
|
|
if w := serveNPM(t, h, http.MethodGet, "/demo/-/demo-2.0.0.tgz", nil, nil); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("tarball 2.0.0: status %d, want %d (withheld by cooldown)", w.Code, http.StatusNotFound)
|
|
|
|
|
}
|
|
|
|
|
if fullRequests.Load() == 0 {
|
|
|
|
|
t.Error("full packument was never fetched from upstream")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// When upstream cannot supply the full packument, the abbreviated one must not
|
|
|
|
|
// stand in for it as a stale copy: it would let every version through.
|
|
|
|
|
func TestNPMCooldownSkipsAbbreviatedStaleFallback(t *testing.T) {
|
|
|
|
|
var fail atomic.Bool
|
|
|
|
|
var fullRequests atomic.Int64
|
|
|
|
|
upstream := abbreviatedThenFullRegistry(t, &fail, &fullRequests)
|
|
|
|
|
h := seedAbbreviatedPackument(t, upstream)
|
|
|
|
|
h.proxy.MetadataTTL = time.Nanosecond // force a refetch
|
|
|
|
|
fail.Store(true)
|
|
|
|
|
|
|
|
|
|
w := serveNPM(t, h, http.MethodGet, "/demo", nil, nil)
|
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
|
|
|
t.Errorf("metadata: status %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(w.Body.String(), "2.0.0") {
|
|
|
|
|
t.Errorf("served the abbreviated packument as a stale copy: %s", w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|