Skip to content

Commit f4a64d3

Browse files
npm: support GitHub Packages SHA tarballs
Rebuild GitHub's content-addressed tarball URLs from verified metadata while preserving regular npm downloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d0f9319 commit f4a64d3

2 files changed

Lines changed: 249 additions & 19 deletions

File tree

internal/handler/npm.go

Lines changed: 111 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ import (
1212
)
1313

1414
const (
15-
npmUpstream = "https://registry.npmjs.org"
16-
npmAbbreviatedCT = "application/vnd.npm.install-v1+json"
17-
scopedParts = 2 // scope + name in scoped packages
15+
npmUpstream = "https://registry.npmjs.org"
16+
npmAbbreviatedCT = "application/vnd.npm.install-v1+json"
17+
npmTarballShasumParam = "git-pkgs-shasum"
18+
scopedParts = 2 // scope + name in scoped packages
1819
)
1920

2021
// NPMHandler handles npm registry protocol requests.
@@ -199,13 +200,13 @@ func (h *NPMHandler) rewriteTarballURLs(versions map[string]any, packageName str
199200
continue
200201
}
201202

202-
filename := tarball
203-
if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
204-
filename = tarball[idx+1:]
205-
}
203+
filename, useShasum := h.proxyTarballFilename(packageName, version, tarball, dist)
206204

207205
escapedName := url.PathEscape(packageName)
208206
newTarball := fmt.Sprintf("%s/npm/%s/-/%s", h.proxyURL, escapedName, filename)
207+
if useShasum != "" {
208+
newTarball += "?" + url.Values{npmTarballShasumParam: []string{useShasum}}.Encode()
209+
}
209210
dist["tarball"] = newTarball
210211

211212
h.proxy.Logger.Debug("rewrote tarball URL",
@@ -214,6 +215,35 @@ func (h *NPMHandler) rewriteTarballURLs(versions map[string]any, packageName str
214215
}
215216
}
216217

218+
func (h *NPMHandler) proxyTarballFilename(packageName, version, tarball string, dist map[string]any) (string, string) {
219+
filename := tarball
220+
if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
221+
filename = tarball[idx+1:]
222+
}
223+
if h.extractVersionFromFilename(packageName, filename) != "" {
224+
return filename, ""
225+
}
226+
227+
shasum, _ := dist["shasum"].(string)
228+
if shasum == "" {
229+
return filename, ""
230+
}
231+
232+
return npmTarballFilename(packageName, version), shasum
233+
}
234+
235+
func npmTarballFilename(packageName, version string) string {
236+
return npmPackageShortName(packageName) + "-" + version + ".tgz"
237+
}
238+
239+
func npmPackageShortName(packageName string) string {
240+
parts := strings.SplitN(packageName, "/", scopedParts)
241+
if len(parts) == scopedParts {
242+
return parts[1]
243+
}
244+
return packageName
245+
}
246+
217247
// findNewestVersion returns the version string with the most recent timestamp
218248
// from the remaining versions, using the time map.
219249
func (h *NPMHandler) findNewestVersion(versions map[string]any, timeMap map[string]any) string {
@@ -265,12 +295,12 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
265295
h.proxy.Logger.Info("npm download request",
266296
"package", packageName, "version", version, "filename", filename)
267297

268-
downloadURL := fmt.Sprintf(
269-
"%s/%s/-/%s",
270-
h.upstreamURL,
271-
escapeNPMDownloadPackage(packageName),
272-
url.PathEscape(filename),
273-
)
298+
downloadURL, err := h.downloadURL(r, packageName, version, filename)
299+
if err != nil {
300+
h.proxy.Logger.Error("failed to resolve npm tarball URL", "error", err)
301+
JSONError(w, http.StatusBadRequest, "invalid tarball request")
302+
return
303+
}
274304
result, err := h.proxy.GetOrFetchArtifactFromURL(
275305
r.Context(), "npm", packageName, version, filename, downloadURL,
276306
)
@@ -287,6 +317,72 @@ func (h *NPMHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
287317
ServeArtifact(w, result)
288318
}
289319

320+
func (h *NPMHandler) downloadURL(r *http.Request, packageName, version, filename string) (string, error) {
321+
shasum := r.URL.Query().Get(npmTarballShasumParam)
322+
if shasum != "" {
323+
return h.tarballURLForShasum(r, packageName, version, shasum)
324+
}
325+
326+
return fmt.Sprintf(
327+
"%s/%s/-/%s",
328+
h.upstreamURL,
329+
escapeNPMDownloadPackage(packageName),
330+
url.PathEscape(filename),
331+
), nil
332+
}
333+
334+
func (h *NPMHandler) tarballURLForShasum(r *http.Request, packageName, version, shasum string) (string, error) {
335+
metadataURL := fmt.Sprintf("%s/%s", h.upstreamURL, url.PathEscape(packageName))
336+
body, _, err := h.proxy.FetchOrCacheMetadata(r.Context(), "npm", packageName, metadataURL, npmAbbreviatedCT)
337+
if err != nil {
338+
return "", fmt.Errorf("fetching npm metadata: %w", err)
339+
}
340+
341+
var metadata map[string]any
342+
if err := json.Unmarshal(body, &metadata); err != nil {
343+
return "", fmt.Errorf("parsing npm metadata: %w", err)
344+
}
345+
346+
versions, ok := metadata["versions"].(map[string]any)
347+
if !ok {
348+
return "", errors.New("npm metadata has no versions")
349+
}
350+
vdata, ok := versions[version].(map[string]any)
351+
if !ok {
352+
return "", fmt.Errorf("npm metadata has no version %q", version)
353+
}
354+
dist, ok := vdata["dist"].(map[string]any)
355+
if !ok {
356+
return "", fmt.Errorf("npm metadata version %q has no dist", version)
357+
}
358+
if actualShasum, _ := dist["shasum"].(string); actualShasum != shasum {
359+
return "", errors.New("npm tarball checksum does not match metadata")
360+
}
361+
tarball, ok := dist["tarball"].(string)
362+
if !ok {
363+
return "", fmt.Errorf("npm metadata version %q has no tarball", version)
364+
}
365+
366+
return h.validateUpstreamTarballURL(tarball)
367+
}
368+
369+
func (h *NPMHandler) validateUpstreamTarballURL(tarball string) (string, error) {
370+
tarballURL, err := url.Parse(tarball)
371+
if err != nil {
372+
return "", fmt.Errorf("parsing tarball URL: %w", err)
373+
}
374+
upstreamURL, err := url.Parse(h.upstreamURL)
375+
if err != nil {
376+
return "", fmt.Errorf("parsing upstream URL: %w", err)
377+
}
378+
if tarballURL.User != nil || tarballURL.Scheme != upstreamURL.Scheme ||
379+
!strings.EqualFold(tarballURL.Host, upstreamURL.Host) {
380+
return "", errors.New("npm tarball URL does not match upstream registry")
381+
}
382+
383+
return tarballURL.String(), nil
384+
}
385+
290386
func escapeNPMDownloadPackage(packageName string) string {
291387
scope, name, scoped := strings.Cut(packageName, "/")
292388
if scoped && strings.HasPrefix(scope, "@") && len(scope) > 1 && name != "" && !strings.Contains(name, "/") {
@@ -345,12 +441,8 @@ func (h *NPMHandler) extractVersionFromFilename(packageName, filename string) st
345441
}
346442
base := strings.TrimSuffix(filename, ".tgz")
347443

348-
// For scoped packages, the filename uses the short name
349-
shortName := packageName
350-
if strings.Contains(packageName, "/") {
351-
parts := strings.SplitN(packageName, "/", scopedParts)
352-
shortName = parts[1]
353-
}
444+
// For scoped packages, the filename uses the short name.
445+
shortName := npmPackageShortName(packageName)
354446

355447
// Expected format: {shortName}-{version}
356448
prefix := shortName + "-"

internal/handler/npm_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,144 @@ func TestNPMRewriteMetadataScopedPackage(t *testing.T) {
210210
}
211211
}
212212

213+
func TestNPMRewriteMetadataGitHubPackagesTarball(t *testing.T) {
214+
h := &NPMHandler{
215+
proxy: testProxy(),
216+
proxyURL: "http://localhost:8080",
217+
}
218+
219+
input := `{
220+
"name": "@example/private-package",
221+
"versions": {
222+
"1.0.0": {
223+
"dist": {
224+
"shasum": "e053d091c6ae91793f6333f5fe0a55633cf3c584",
225+
"tarball": "https://npm.pkg.github.com/download/@example/private-package/1.0.0/e053d091c6ae91793f6333f5fe0a55633cf3c584"
226+
}
227+
}
228+
}
229+
}`
230+
231+
output, err := h.rewriteMetadata("@example/private-package", []byte(input))
232+
if err != nil {
233+
t.Fatalf("rewriteMetadata failed: %v", err)
234+
}
235+
236+
var result map[string]any
237+
if err := json.Unmarshal(output, &result); err != nil {
238+
t.Fatalf("failed to parse output: %v", err)
239+
}
240+
241+
versions := result["versions"].(map[string]any)
242+
v := versions[testVersion100].(map[string]any)
243+
dist := v["dist"].(map[string]any)
244+
tarball := dist["tarball"].(string)
245+
246+
expected := "http://localhost:8080/npm/@example%2Fprivate-package/-/private-package-1.0.0.tgz?git-pkgs-shasum=e053d091c6ae91793f6333f5fe0a55633cf3c584"
247+
if tarball != expected {
248+
t.Errorf("tarball = %q, want %q", tarball, expected)
249+
}
250+
}
251+
252+
func TestNPMHandlerDownloadsGitHubPackagesTarball(t *testing.T) {
253+
const shasum = "e053d091c6ae91793f6333f5fe0a55633cf3c584"
254+
const tarballPath = "/download/@example/private-package/1.0.0/" + shasum
255+
256+
var upstream *httptest.Server
257+
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
258+
if r.URL.Path != "/@example/private-package" {
259+
t.Errorf("metadata path = %q, want scoped package path", r.URL.Path)
260+
w.WriteHeader(http.StatusNotFound)
261+
return
262+
}
263+
w.Header().Set("Content-Type", contentTypeJSON)
264+
_, _ = io.WriteString(w, `{"versions":{"1.0.0":{"dist":{"shasum":"`+shasum+`","tarball":"`+upstream.URL+tarballPath+`"}}}}`)
265+
}))
266+
defer upstream.Close()
267+
268+
proxy, _, _, artifactFetcher := setupTestProxy(t)
269+
proxy.HTTPClient = upstream.Client()
270+
artifactFetcher.artifact = &fetch.Artifact{
271+
Body: io.NopCloser(strings.NewReader("package")),
272+
ContentType: "application/gzip",
273+
}
274+
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
275+
276+
req := httptest.NewRequest(
277+
http.MethodGet,
278+
"/@example/private-package/-/private-package-1.0.0.tgz?git-pkgs-shasum="+shasum,
279+
nil,
280+
)
281+
w := httptest.NewRecorder()
282+
h.Routes().ServeHTTP(w, req)
283+
284+
if w.Code != http.StatusOK {
285+
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
286+
}
287+
if artifactFetcher.fetchedURL != upstream.URL+tarballPath {
288+
t.Errorf("fetched URL = %q, want %q", artifactFetcher.fetchedURL, upstream.URL+tarballPath)
289+
}
290+
}
291+
292+
func TestNPMHandlerRejectsMismatchedGitHubPackagesChecksum(t *testing.T) {
293+
const actualShasum = "e053d091c6ae91793f6333f5fe0a55633cf3c584"
294+
295+
var upstream *httptest.Server
296+
upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
297+
w.Header().Set("Content-Type", contentTypeJSON)
298+
_, _ = io.WriteString(w, `{"versions":{"1.0.0":{"dist":{"shasum":"`+actualShasum+`","tarball":"`+upstream.URL+`/download/pkg/1.0.0/`+actualShasum+`"}}}}`)
299+
}))
300+
defer upstream.Close()
301+
302+
proxy, _, _, artifactFetcher := setupTestProxy(t)
303+
proxy.HTTPClient = upstream.Client()
304+
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
305+
306+
req := httptest.NewRequest(
307+
http.MethodGet,
308+
"/pkg/-/pkg-1.0.0.tgz?git-pkgs-shasum=incorrect",
309+
nil,
310+
)
311+
w := httptest.NewRecorder()
312+
h.Routes().ServeHTTP(w, req)
313+
314+
if w.Code != http.StatusBadRequest {
315+
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
316+
}
317+
if artifactFetcher.fetchedURL != "" {
318+
t.Errorf("artifact fetcher should not be called, fetched URL = %q", artifactFetcher.fetchedURL)
319+
}
320+
}
321+
322+
func TestNPMHandlerRejectsTarballFromDifferentHost(t *testing.T) {
323+
const shasum = "e053d091c6ae91793f6333f5fe0a55633cf3c584"
324+
325+
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
326+
w.Header().Set("Content-Type", contentTypeJSON)
327+
_, _ = io.WriteString(w, `{"versions":{"1.0.0":{"dist":{"shasum":"`+shasum+`","tarball":"https://example.invalid/package.tgz"}}}}`)
328+
}))
329+
defer upstream.Close()
330+
331+
proxy, _, _, artifactFetcher := setupTestProxy(t)
332+
proxy.HTTPClient = upstream.Client()
333+
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
334+
335+
req := httptest.NewRequest(
336+
http.MethodGet,
337+
"/pkg/-/pkg-1.0.0.tgz?git-pkgs-shasum="+shasum,
338+
nil,
339+
)
340+
w := httptest.NewRecorder()
341+
h.Routes().ServeHTTP(w, req)
342+
343+
if w.Code != http.StatusBadRequest {
344+
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadRequest, w.Body.String())
345+
}
346+
if artifactFetcher.fetchedURL != "" {
347+
t.Errorf("artifact fetcher should not be called, fetched URL = %q", artifactFetcher.fetchedURL)
348+
}
349+
}
350+
213351
func TestNPMHandlerMetadataProxy(t *testing.T) {
214352
// Create a mock upstream server
215353
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {

0 commit comments

Comments
 (0)