internal/deps/registry.go
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}