internal/deps/registry.go

6f16ad5df0845d8f44ec6530f1d1d6993d8dcb70
gitbay/internal/deps/registry.go history · blame · raw

137 lines · 3723 bytes

  1package deps
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"regexp"
 10	"strings"
 11	"time"
 12)
 13
 14// Registry endpoints. Each is a fixed public host: unlike webhooks and
 15// mirrors, no part of the URL comes from user input except the package
 16// name, and safeName restricts that to characters that need no escaping in
 17// a URL path.
 18var endpoints = map[string]string{
 19	EcoGo:    "https://proxy.golang.org",
 20	EcoNPM:   "https://registry.npmjs.org",
 21	EcoCargo: "https://crates.io/api/v1/crates",
 22	EcoPyPI:  "https://pypi.org/pypi",
 23}
 24
 25// maxBody bounds a registry response. The npm packument is the large one,
 26// which is why the abbreviated metadata is requested.
 27const maxBody = 4 << 20
 28
 29// safeName is the shape a package name may have before it goes into a URL.
 30var safeName = regexp.MustCompile(`^@?[A-Za-z0-9][A-Za-z0-9._/@+-]*$`)
 31
 32// Client queries package registries. The zero value is not usable; call
 33// NewClient.
 34type Client struct {
 35	HTTP    *http.Client
 36	Hosts   map[string]string // overridden in tests
 37	Version string            // reported in User-Agent
 38}
 39
 40func NewClient(version string) *Client {
 41	return &Client{
 42		HTTP:    &http.Client{Timeout: 20 * time.Second},
 43		Hosts:   endpoints,
 44		Version: version,
 45	}
 46}
 47
 48// Latest returns the current release of one package. A prerelease is
 49// reported as no answer: nothing should be nudged onto an rc.
 50func (c *Client) Latest(ctx context.Context, eco, name string) (string, error) {
 51	base, ok := c.Hosts[eco]
 52	if !ok {
 53		return "", fmt.Errorf("unknown ecosystem %q", eco)
 54	}
 55	if !safeName.MatchString(name) || strings.Contains(name, "..") {
 56		return "", fmt.Errorf("unusable package name %q", name)
 57	}
 58	var path, accept string
 59	switch eco {
 60	case EcoGo:
 61		path = "/" + escapeModule(name) + "/@latest"
 62	case EcoNPM:
 63		path = "/" + name
 64		accept = "application/vnd.npm.install-v1+json" // dist-tags, not the full packument
 65	case EcoCargo:
 66		path = "/" + name
 67	case EcoPyPI:
 68		path = "/" + name + "/json"
 69	}
 70	body, err := c.get(ctx, base+path, accept)
 71	if err != nil {
 72		return "", err
 73	}
 74	var doc struct {
 75		Version  string            `json:"Version"`   // go
 76		DistTags map[string]string `json:"dist-tags"` // npm
 77		Crate    struct {
 78			MaxStableVersion string `json:"max_stable_version"`
 79		} `json:"crate"` // cargo
 80		Info struct {
 81			Version string `json:"version"`
 82		} `json:"info"` // pypi
 83	}
 84	if err := json.Unmarshal(body, &doc); err != nil {
 85		return "", fmt.Errorf("%s %s: %w", eco, name, err)
 86	}
 87	var latest string
 88	switch eco {
 89	case EcoGo:
 90		latest = doc.Version
 91	case EcoNPM:
 92		latest = doc.DistTags["latest"]
 93	case EcoCargo:
 94		latest = doc.Crate.MaxStableVersion
 95	case EcoPyPI:
 96		latest = doc.Info.Version
 97	}
 98	if latest == "" || IsPrerelease(latest) {
 99		return "", nil
100	}
101	return latest, nil
102}
103
104func (c *Client) get(ctx context.Context, u, accept string) ([]byte, error) {
105	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
106	if err != nil {
107		return nil, err
108	}
109	req.Header.Set("User-Agent", "gitbay/"+c.Version+" (+https://gitbay.org)")
110	if accept != "" {
111		req.Header.Set("Accept", accept)
112	}
113	resp, err := c.HTTP.Do(req)
114	if err != nil {
115		return nil, err
116	}
117	defer resp.Body.Close()
118	if resp.StatusCode != http.StatusOK {
119		return nil, fmt.Errorf("%s: %s", u, resp.Status)
120	}
121	return io.ReadAll(io.LimitReader(resp.Body, maxBody))
122}
123
124// escapeModule applies the module proxy's case encoding: an uppercase
125// letter becomes "!" followed by its lowercase form, so paths stay distinct
126// on case-insensitive filesystems.
127func escapeModule(path string) string {
128	var b strings.Builder
129	for _, r := range path {
130		if r >= 'A' && r <= 'Z' {
131			b.WriteByte('!')
132			r += 'a' - 'A'
133		}
134		b.WriteRune(r)
135	}
136	return b.String()
137}