mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 23:59:16 -05:00
* Handle Swift registry identities in cache and vulnerability APIs * Preserve Go cache and policy behavior when upgrading PURL
289 lines
9.5 KiB
Go
289 lines
9.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
gomodule "golang.org/x/mod/module"
|
|
"golang.org/x/mod/semver"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// Go cooldown measures a version by the Time in its .info, which the module
|
|
// proxy takes from the commit the version points at. That is when the code
|
|
// was committed, not when the version was tagged or first fetched.
|
|
//
|
|
// Versions in cooldown are left out of @v/list and @latest, and their .info,
|
|
// .mod and .zip are answered with 403. The go command falls back to the next
|
|
// GOPROXY entry on 404 and 410, so with GOPROXY=proxy,direct a 404 would let
|
|
// it fetch the version directly.
|
|
|
|
// goInfoFetchLimit bounds the .info requests made at once to filter a list.
|
|
const goInfoFetchLimit = 8
|
|
|
|
const errGoCooldown = "version is in cooldown"
|
|
|
|
// goInfo is the JSON served for a version's .info and for @latest.
|
|
type goInfo struct {
|
|
Version string `json:"Version"`
|
|
Time time.Time `json:"Time"`
|
|
}
|
|
|
|
func (h *GoHandler) cooldownEnabled() bool {
|
|
return h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled()
|
|
}
|
|
|
|
func (h *GoHandler) allowedAt(module string, published time.Time) bool {
|
|
if published.IsZero() {
|
|
return true
|
|
}
|
|
return h.proxy.cooldown().IsAllowed("golang", canonicalPackagePURL("golang", module), published)
|
|
}
|
|
|
|
// writeGoMetadataError answers a failed upstream metadata fetch.
|
|
func (h *GoHandler) writeGoMetadataError(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
if writeUpstreamHeld(w, err) {
|
|
return
|
|
}
|
|
h.proxy.Logger.Error("go metadata fetch failed", "error", err)
|
|
http.Error(w, "failed to fetch from upstream", http.StatusBadGateway)
|
|
}
|
|
|
|
// fetchGoMetadata reads a module's @v/list, @latest, or a version's .info.
|
|
// The .info of a canonical version comes from the cache whatever its age,
|
|
// like proxyVersionFile serves it.
|
|
func (h *GoHandler) fetchGoMetadata(ctx context.Context, escapedModule, module, rest string) ([]byte, error) {
|
|
cacheKey, upstreamURL := module+"/"+rest, h.upstreamURL+"/"+escapedModule+"/"+rest
|
|
if v, ok := strings.CutPrefix(rest, "@v/"); ok && strings.HasSuffix(v, ".info") && goVersionImmutable(strings.TrimSuffix(v, ".info")) {
|
|
res := h.proxy.fetchImmutableMetadata(ctx, "golang", cacheKey, upstreamURL, "*/*")
|
|
return res.body, res.err
|
|
}
|
|
body, _, err := h.proxy.FetchOrCacheMetadata(ctx, "golang", cacheKey, upstreamURL, "*/*")
|
|
return body, err
|
|
}
|
|
|
|
// Keep escaped module paths in publish-time keys for cache compatibility.
|
|
// Only canonical versions are stored; branch queries can resolve differently.
|
|
func (h *GoHandler) goTimeKey(module, version string) (versionPURL, packagePURL string, ok bool) {
|
|
if h.proxy.DB == nil || gomodule.CanonicalVersion(version) != version {
|
|
return "", "", false
|
|
}
|
|
escaped, err := gomodule.EscapePath(module)
|
|
if err != nil {
|
|
return "", "", false
|
|
}
|
|
return canonicalVersionPURL("golang", escaped, version), canonicalPackagePURL("golang", escaped), true
|
|
}
|
|
|
|
// publishTime returns a version's time from the versions table, or from its
|
|
// .info, which is then stored. A version upstream doesn't know, or whose
|
|
// .info has no time, gets the zero time and is let through, as in the other
|
|
// ecosystems. A failed fetch is an error, so a flaky or rate-limited
|
|
// upstream can't let a version through.
|
|
func (h *GoHandler) publishTime(ctx context.Context, escapedModule, module, version string) (time.Time, error) {
|
|
if versionPURL, _, ok := h.goTimeKey(module, version); ok {
|
|
if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
|
|
return ver.PublishedAt.Time, nil
|
|
}
|
|
}
|
|
|
|
escapedVersion, err := gomodule.EscapeVersion(version)
|
|
if err != nil {
|
|
return time.Time{}, nil
|
|
}
|
|
body, err := h.fetchGoMetadata(ctx, escapedModule, module, "@v/"+escapedVersion+".info")
|
|
if errors.Is(err, ErrUpstreamNotFound) {
|
|
return time.Time{}, nil
|
|
}
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
var info goInfo
|
|
if err := json.Unmarshal(body, &info); err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
if !info.Time.IsZero() {
|
|
h.storePublishTime(module, version, info.Time)
|
|
}
|
|
return info.Time, nil
|
|
}
|
|
|
|
func (h *GoHandler) storePublishTime(module, version string, published time.Time) {
|
|
versionPURL, packagePURL, ok := h.goTimeKey(module, version)
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.proxy.DB.SetVersionPublishedAt(versionPURL, packagePURL, published); err != nil {
|
|
h.proxy.Logger.Warn("cooldown: could not store go publish time",
|
|
"module", module, "version", version, "error", err)
|
|
}
|
|
}
|
|
|
|
// checkCooldown answers a request for an exact version in cooldown, or whose
|
|
// time couldn't be read, and reports whether it did.
|
|
func (h *GoHandler) checkCooldown(w http.ResponseWriter, r *http.Request, escapedModule, module, version string) bool {
|
|
if !h.cooldownEnabled() {
|
|
return false
|
|
}
|
|
published, err := h.publishTime(r.Context(), escapedModule, module, version)
|
|
if err != nil {
|
|
h.proxy.Logger.Warn("cooldown: could not read go version time",
|
|
"module", module, "version", version, "error", err)
|
|
http.Error(w, "failed to check cooldown", http.StatusBadGateway)
|
|
return true
|
|
}
|
|
if !h.allowedAt(module, published) {
|
|
h.refuseCooldown(w, module, version)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (h *GoHandler) refuseCooldown(w http.ResponseWriter, module, version string) {
|
|
h.proxy.Logger.Info("cooldown: withholding go module version", "module", module, "version", version)
|
|
http.Error(w, errGoCooldown+": "+canonicalVersionPURL("golang", module, version), http.StatusForbidden)
|
|
}
|
|
|
|
// allowedVersions returns the listed versions of module that are out of
|
|
// cooldown, in their upstream order.
|
|
func (h *GoHandler) allowedVersions(ctx context.Context, escapedModule, module string) ([]string, error) {
|
|
body, err := h.fetchGoMetadata(ctx, escapedModule, module, "@v/list")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var versions []string
|
|
for line := range strings.SplitSeq(string(body), "\n") {
|
|
if v := strings.TrimSpace(line); v != "" {
|
|
versions = append(versions, v)
|
|
}
|
|
}
|
|
|
|
keep := make([]bool, len(versions))
|
|
g, gctx := errgroup.WithContext(ctx)
|
|
g.SetLimit(goInfoFetchLimit)
|
|
for i, v := range versions {
|
|
g.Go(func() error {
|
|
published, err := h.publishTime(gctx, escapedModule, module, v)
|
|
if err != nil {
|
|
return fmt.Errorf("reading time of %s: %w", v, err)
|
|
}
|
|
keep[i] = h.allowedAt(module, published)
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
allowed := versions[:0]
|
|
for i, v := range versions {
|
|
if keep[i] {
|
|
allowed = append(allowed, v)
|
|
} else {
|
|
h.proxy.Logger.Info("cooldown: filtering go module version", "module", module, "version", v)
|
|
}
|
|
}
|
|
return allowed, nil
|
|
}
|
|
|
|
// serveCooldownList serves @v/list without versions in cooldown.
|
|
func (h *GoHandler) serveCooldownList(w http.ResponseWriter, r *http.Request, escapedModule, module string) {
|
|
allowed, err := h.allowedVersions(r.Context(), escapedModule, module)
|
|
if err != nil {
|
|
h.writeGoMetadataError(w, err)
|
|
return
|
|
}
|
|
w.Header().Set(headerContentType, "text/plain; charset=utf-8")
|
|
if len(allowed) > 0 {
|
|
_, _ = w.Write([]byte(strings.Join(allowed, "\n") + "\n"))
|
|
}
|
|
}
|
|
|
|
// serveCooldownInfo serves a .info, refusing it when the version it names is
|
|
// in cooldown. rest is the request after /@v/: an escaped version or a query
|
|
// such as a branch name, followed by .info.
|
|
func (h *GoHandler) serveCooldownInfo(w http.ResponseWriter, r *http.Request, escapedModule, module, rest string) {
|
|
body, err := h.fetchGoMetadata(r.Context(), escapedModule, module, "@v/"+rest)
|
|
if err != nil {
|
|
h.writeGoMetadataError(w, err)
|
|
return
|
|
}
|
|
var info goInfo
|
|
if err := json.Unmarshal(body, &info); err == nil && info.Version != "" {
|
|
if !h.allowedAt(module, info.Time) {
|
|
h.refuseCooldown(w, module, info.Version)
|
|
return
|
|
}
|
|
if !info.Time.IsZero() {
|
|
h.storePublishTime(module, info.Version, info.Time)
|
|
}
|
|
}
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
_, _ = w.Write(body)
|
|
}
|
|
|
|
// serveCooldownLatest serves @latest. When upstream's answer is in cooldown,
|
|
// it serves the newest listed version out of cooldown instead: the highest
|
|
// release, or else the highest pre-release, which is how the go command
|
|
// picks from a list.
|
|
func (h *GoHandler) serveCooldownLatest(w http.ResponseWriter, r *http.Request, escapedModule, module string) {
|
|
body, err := h.fetchGoMetadata(r.Context(), escapedModule, module, "@latest")
|
|
if err != nil {
|
|
h.writeGoMetadataError(w, err)
|
|
return
|
|
}
|
|
var info goInfo
|
|
if err := json.Unmarshal(body, &info); err != nil || h.allowedAt(module, info.Time) {
|
|
w.Header().Set(headerContentType, contentTypeJSON)
|
|
_, _ = w.Write(body)
|
|
return
|
|
}
|
|
|
|
allowed, err := h.allowedVersions(r.Context(), escapedModule, module)
|
|
if err != nil {
|
|
h.writeGoMetadataError(w, err)
|
|
return
|
|
}
|
|
best := newestGoVersion(allowed)
|
|
if best == "" {
|
|
h.refuseCooldown(w, module, info.Version)
|
|
return
|
|
}
|
|
escapedVersion, err := gomodule.EscapeVersion(best)
|
|
if err != nil {
|
|
h.refuseCooldown(w, module, info.Version)
|
|
return
|
|
}
|
|
h.serveCooldownInfo(w, r, escapedModule, module, escapedVersion+".info")
|
|
}
|
|
|
|
// newestGoVersion returns the highest release in versions, or the highest
|
|
// pre-release when there is no release.
|
|
func newestGoVersion(versions []string) string {
|
|
var release, prerelease string
|
|
for _, v := range versions {
|
|
if !semver.IsValid(v) {
|
|
continue
|
|
}
|
|
if semver.Prerelease(v) == "" {
|
|
if release == "" || semver.Compare(v, release) > 0 {
|
|
release = v
|
|
}
|
|
} else if prerelease == "" || semver.Compare(v, prerelease) > 0 {
|
|
prerelease = v
|
|
}
|
|
}
|
|
if release != "" {
|
|
return release
|
|
}
|
|
return prerelease
|
|
}
|