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/maven_cooldown.go
simon chrzanowski cb566d5df6
Expire cooldown rewrites when a withheld version becomes available (#638)
A cached rewrite made with cooldown on stays right until a version it held
back leaves cooldown, so it now expires then instead of after a fixed
time. Handlers ask cooldown through Proxy.cooldown, which reports each
withheld version to the rewrites running at that moment. A rewrite that
held nothing back is kept for the shortest cooldown, at most a day.
2026-10-30 14:06:42 +00:00

475 lines
16 KiB
Go

package handler
import (
"bytes"
"context"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"hash"
"io"
"net/http"
"net/url"
"path"
"slices"
"strings"
"time"
)
// Maven cooldown measures a version by the Last-Modified header of its .pom.
// On Maven Central that is the time the version was deployed. The header is
// read once per version and stored in the versions table, since release
// files don't change.
//
// maven-metadata.xml has a single <lastUpdated> for the whole artifact, so
// the proxy only looks at versions that can still be in cooldown: none when
// <lastUpdated> is older than the window, and otherwise the versions from
// the end of the list back to the first one that is old enough. Maven
// Central blocks clients that send bursts of requests, so the proxy doesn't
// read every version's time.
//
// SNAPSHOT versions are left alone: their files change after publishing.
const errMavenCooldown = "version is in cooldown"
// mavenTimeUserAgent is the User-Agent of publish-time requests, the one
// the artifact fetcher sends.
const mavenTimeUserAgent = "git-pkgs-proxy/1.0"
const (
mavenMetadataFile = "maven-metadata.xml"
mavenSnapshot = "-SNAPSHOT"
// mavenLastUpdatedLayout is the UTC timestamp format of <lastUpdated>.
mavenLastUpdatedLayout = "20060102150405"
)
// mavenChecksums are the checksum files served beside maven-metadata.xml.
// With cooldown on they are computed from the metadata as served.
var mavenChecksums = map[string]func() hash.Hash{
".md5": md5.New,
".sha1": sha1.New,
".sha256": sha256.New,
".sha512": sha512.New,
}
func (h *MavenHandler) cooldownEnabled() bool {
return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
}
func mavenPackagePURL(group, artifact string) string {
return canonicalPackagePURL("maven", group+":"+artifact)
}
// mavenCoordinates returns the group and artifact of an artifact directory
// such as com/google/guava/guava.
func mavenCoordinates(artifactDir string) (group, artifact string, ok bool) {
i := strings.LastIndex(artifactDir, "/")
if i <= 0 || i == len(artifactDir)-1 {
return "", "", false
}
return strings.ReplaceAll(artifactDir[:i], "/", "."), artifactDir[i+1:], true
}
// usableMavenVersion reports whether version can be put in a URL path as
// one segment. Versions come from upstream metadata.
func usableMavenVersion(version string) bool {
return version != "" && version != "." && version != ".." && !strings.ContainsAny(version, "/\\?#%")
}
// mavenTimeKey returns the versions-table PURLs a version's time is stored
// under. A named repository's keys carry its upstream as repository_url:
// the same coordinates there can have been published at another time than
// in the default repository.
func (h *MavenHandler) mavenTimeKey(source mavenSource, group, artifact, version string) (versionPURL, packagePURL string, ok bool) {
if h.proxy.DB == nil {
return "", "", false
}
name := group + ":" + artifact
versionPURL, packagePURL = canonicalVersionPURL("maven", name, version), canonicalPackagePURL("maven", name)
if source.name != "" {
qualifier := "?repository_url=" + url.QueryEscape(source.upstreamURL)
versionPURL += qualifier
packagePURL += qualifier
}
return versionPURL, packagePURL, true
}
// publishTime returns a version's publish time from the versions table, or
// from the Last-Modified of its .pom, which is then stored. A version
// without a .pom, or whose .pom has no Last-Modified, gets the zero time and
// is let through, as in the other ecosystems. A failed request is an error,
// so a rate-limited upstream can't let a version through.
func (h *MavenHandler) publishTime(ctx context.Context, source mavenSource, artifactDir, version string) (time.Time, error) {
group, artifact, ok := mavenCoordinates(artifactDir)
if !ok || !usableMavenVersion(version) {
return time.Time{}, nil
}
versionPURL, packagePURL, store := h.mavenTimeKey(source, group, artifact, version)
if store {
if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
return ver.PublishedAt.Time, nil
}
}
pomPath := artifactDir + "/" + version + "/" + artifact + "-" + version + ".pom"
published, found, err := h.pomLastModified(ctx, source.upstreamURL+"/"+pomPath)
if err == nil && !found && source.fallbackURL != "" {
published, _, err = h.pomLastModified(ctx, source.fallbackURL+"/"+pomPath)
}
if err != nil {
return time.Time{}, err
}
if store && !published.IsZero() {
if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, packagePURL, published); err != nil {
h.proxy.Logger.Warn("cooldown: could not store maven publish time",
"group", group, "artifact", artifact, "version", version, "error", err)
}
}
return published, nil
}
// cooldownFromDownload returns a context that stores the publish time of a
// version from the download of its .pom or Gradle .module, and whether the
// cooldown check is to run after the download. It does so only for those
// files of a version whose time is not stored yet; for anything else the
// check runs before. Both files are deployed with the version, and
// whichever a client fetches first gives its time.
func (h *MavenHandler) cooldownFromDownload(ctx context.Context, source mavenSource, urlPath, version string) (context.Context, bool) {
if !h.cooldownEnabled() || strings.HasSuffix(version, mavenSnapshot) {
return ctx, false
}
artifactDir := path.Dir(path.Dir(urlPath))
group, artifact, ok := mavenCoordinates(artifactDir)
if base := artifact + "-" + version; !ok || !usableMavenVersion(version) ||
(path.Base(urlPath) != base+".pom" && path.Base(urlPath) != base+".module") {
return ctx, false
}
versionPURL, packagePURL, store := h.mavenTimeKey(source, group, artifact, version)
if !store {
return ctx, false
}
if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
return ctx, false
}
return WithUpstreamLastModified(ctx, func(published time.Time) {
if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, packagePURL, published); err != nil {
h.proxy.Logger.Warn("cooldown: could not store maven publish time",
"group", group, "artifact", artifact, "version", version, "error", err)
}
}), true
}
// pomLastModified sends a HEAD request for a .pom and returns its
// Last-Modified. found is false when upstream doesn't have the file. Only a
// 2xx response's header is used: an error page's date says nothing about the
// version.
func (h *MavenHandler) pomLastModified(ctx context.Context, pomURL string) (published time.Time, found bool, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, pomURL, nil)
if err != nil {
return time.Time{}, false, err
}
// Name the proxy as its artifact downloads do; Maven Central asks
// tools not to send an HTTP library's default.
req.Header.Set("User-Agent", mavenTimeUserAgent)
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return time.Time{}, false, fmt.Errorf("reading pom time: %w", err)
}
_ = resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
return time.Time{}, false, nil
case resp.StatusCode == http.StatusTooManyRequests && h.proxy.RateLimits != nil:
// The gate has just noted the upstream's Retry-After; pass it on.
if err := h.proxy.RateLimits.Held(pomURL); err != nil {
return time.Time{}, false, err
}
return time.Time{}, false, fmt.Errorf("reading pom time: upstream returned %d", resp.StatusCode)
case resp.StatusCode < 200 || resp.StatusCode > 299:
return time.Time{}, false, fmt.Errorf("reading pom time: upstream returned %d", resp.StatusCode)
}
published, err = http.ParseTime(resp.Header.Get(headerLastModified))
if err != nil {
return time.Time{}, true, nil
}
return published, true, nil
}
// checkCooldown answers a request for a file of a version in cooldown, or
// whose time couldn't be read, and reports whether it did. The answer is
// 403, as for a version on the denylist.
func (h *MavenHandler) checkCooldown(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath, version string) bool {
if !h.cooldownEnabled() || strings.HasSuffix(version, mavenSnapshot) {
return false
}
artifactDir := path.Dir(path.Dir(urlPath))
group, artifact, ok := mavenCoordinates(artifactDir)
if !ok || h.proxy.Cooldown.For("maven", mavenPackagePURL(group, artifact)) == 0 {
return false
}
published, err := h.publishTime(r.Context(), source, artifactDir, version)
if err != nil {
if writeUpstreamHeld(w, err) {
return true
}
h.proxy.Logger.Warn("cooldown: could not read maven version time",
"group", group, "artifact", artifact, "version", version, "error", err)
http.Error(w, "failed to check cooldown", http.StatusBadGateway)
return true
}
if !h.proxy.cooldown().IsAllowed("maven", mavenPackagePURL(group, artifact), published) {
h.proxy.Logger.Info("cooldown: withholding maven version",
"group", group, "artifact", artifact, "version", version)
http.Error(w, errMavenCooldown+": "+canonicalVersionPURL("maven", group+":"+artifact, version), http.StatusForbidden)
return true
}
return false
}
// serveCooldownMetadata serves maven-metadata.xml without versions in
// cooldown, and its checksum files computed from what is served. It reports
// false for other files beside the metadata, such as a signature, when the
// metadata is served unchanged, so they are proxied as usual.
func (h *MavenHandler) serveCooldownMetadata(w http.ResponseWriter, r *http.Request, source mavenSource, urlPath string) bool {
filename := path.Base(urlPath)
suffix, ok := strings.CutPrefix(filename, mavenMetadataFile)
if !ok {
return false
}
metadataPath := path.Join(path.Dir(urlPath), mavenMetadataFile)
body, contentType, cacheKey, err := h.fetchMetadata(r.Context(), source, metadataPath)
if err != nil {
h.writeMetadataError(w, err)
return true
}
m, err := h.proxy.cachedRewrite(r.Context(), "maven", h.proxyURL, cacheKey, body, "", func(in []byte) ([]byte, []string, error) {
// The rewrite is shared by requests waiting on it and cached, so it
// finishes even if the request that started it goes away.
out, err := h.filterMetadata(context.WithoutCancel(r.Context()), source, path.Dir(urlPath), in)
return out, nil, err
})
if err != nil {
if r.Context().Err() != nil {
return true
}
if writeUpstreamHeld(w, err) {
return true
}
h.proxy.Logger.Warn("cooldown: could not filter maven metadata", "path", metadataPath, "error", err)
http.Error(w, "failed to check cooldown", http.StatusBadGateway)
return true
}
switch {
case suffix == "":
// The metadata cache records JSON when an upstream sent no type,
// and maven-metadata.xml is always XML.
if contentType == "" || contentType == contentTypeJSON {
contentType = "text/xml"
}
markStale(w, h.proxy.lookupCachedMeta("maven", cacheKey).stale)
serveRewrittenMetadataAs(w, r, m, contentType)
case mavenChecksums[suffix] != nil:
sum := mavenChecksums[suffix]()
_, _ = sum.Write(m.content())
w.Header().Set(headerContentType, "text/plain")
_, _ = io.WriteString(w, hex.EncodeToString(sum.Sum(nil)))
case bytes.Equal(m.content(), body):
return false
default:
// A signature of the upstream metadata doesn't match what is served.
http.Error(w, "not found", http.StatusNotFound)
}
return true
}
// filterMetadata removes versions in cooldown from an artifact's
// maven-metadata.xml and points <latest> and <release> at the newest
// versions left. Metadata without a version list, such as a group's plugin
// list or a SNAPSHOT's build list, is returned unchanged. The document is
// edited in place rather than encoded again, so everything else in it stays
// as upstream sent it.
func (h *MavenHandler) filterMetadata(ctx context.Context, source mavenSource, artifactDir string, body []byte) ([]byte, error) {
doc, err := parseMavenMetadata(body)
if err != nil {
return nil, err
}
group, artifact, ok := mavenCoordinates(artifactDir)
if !ok || len(doc.versions) == 0 {
return body, nil
}
packagePURL := mavenPackagePURL(group, artifact)
// No version can be newer than the metadata listing it.
if lastUpdated, err := time.Parse(mavenLastUpdatedLayout, doc.lastUpdated); err == nil &&
h.proxy.cooldown().IsAllowed("maven", packagePURL, lastUpdated) {
return body, nil
}
removed := map[string]bool{}
for i := len(doc.versions) - 1; i >= 0; i-- {
v := doc.versions[i].value
if strings.HasSuffix(v, mavenSnapshot) {
continue
}
published, err := h.publishTime(ctx, source, artifactDir, v)
if err != nil {
return nil, fmt.Errorf("reading time of %s: %w", v, err)
}
if published.IsZero() {
continue
}
if h.proxy.cooldown().IsAllowed("maven", packagePURL, published) {
break
}
h.proxy.Logger.Info("cooldown: filtering maven version", "group", group, "artifact", artifact, "version", v)
removed[v] = true
}
if len(removed) == 0 {
return body, nil
}
return doc.without(body, removed), nil
}
// mavenElement is an element of maven-metadata.xml: its text and where it
// starts and ends in the document.
type mavenElement struct {
value string
start, end int
}
type mavenMetadata struct {
versions []mavenElement
latest, release *mavenElement
lastUpdated string
}
// parseMavenMetadata reads the version list, <latest>, <release> and
// <lastUpdated> of a maven-metadata.xml, with their offsets.
func parseMavenMetadata(body []byte) (*mavenMetadata, error) {
dec := xml.NewDecoder(bytes.NewReader(body))
// Versions are ASCII, so any declared charset can be read as bytes.
dec.CharsetReader = func(_ string, in io.Reader) (io.Reader, error) { return in, nil }
doc := &mavenMetadata{}
var stack []string
var current *mavenElement
for {
offset := int(dec.InputOffset())
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("parsing maven metadata: %w", err)
}
switch t := tok.(type) {
case xml.StartElement:
stack = append(stack, t.Name.Local)
switch strings.Join(stack, "/") {
case "metadata/versioning/versions/version",
"metadata/versioning/latest",
"metadata/versioning/release",
"metadata/versioning/lastUpdated":
current = &mavenElement{start: offset}
}
case xml.CharData:
if current != nil {
current.value += string(t)
}
case xml.EndElement:
if current != nil {
current.value = strings.TrimSpace(current.value)
current.end = int(dec.InputOffset())
switch t.Name.Local {
case "version":
doc.versions = append(doc.versions, *current)
case "latest":
doc.latest = current
case "release":
doc.release = current
case "lastUpdated":
doc.lastUpdated = current.value
}
current = nil
}
if len(stack) > 0 {
stack = stack[:len(stack)-1]
}
}
}
return doc, nil
}
// without returns body with the removed versions taken out of the version
// list, and <latest> and <release> set to the last versions left in list
// order, which is how the repository chose them. Either is dropped when no
// version is left for it.
func (doc *mavenMetadata) without(body []byte, removed map[string]bool) []byte {
type edit struct {
start, end int
text string
}
var edits []edit
drop := func(e mavenElement) {
edits = append(edits, edit{start: lineStart(body, e.start), end: e.end})
}
var latest, release string
for _, v := range doc.versions {
if removed[v.value] {
drop(v)
continue
}
latest = v.value
if !strings.HasSuffix(v.value, mavenSnapshot) {
release = v.value
}
}
for _, field := range []struct {
el *mavenElement
name string
next string
}{{doc.latest, "latest", latest}, {doc.release, "release", release}} {
if field.el == nil || !removed[field.el.value] {
continue
}
if field.next == "" {
drop(*field.el)
continue
}
edits = append(edits, edit{field.el.start, field.el.end, "<" + field.name + ">" + field.next + "</" + field.name + ">"})
}
// Edits don't overlap; apply them in document order.
slices.SortFunc(edits, func(a, b edit) int { return a.start - b.start })
out := make([]byte, 0, len(body))
pos := 0
for _, e := range edits {
out = append(out, body[pos:e.start]...)
out = append(out, e.text...)
pos = e.end
}
return append(out, body[pos:]...)
}
// lineStart moves an element's start back over the indentation and line
// break before it, so removing the element removes its line.
func lineStart(body []byte, start int) int {
i := start
for i > 0 && (body[i-1] == ' ' || body[i-1] == '\t') {
i--
}
if i > 0 && body[i-1] == '\n' {
i--
if i > 0 && body[i-1] == '\r' {
i--
}
return i
}
return start
}