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

Compare commits

...
Author SHA1 Message Date
Andrew Nesbitt
ac137a3289
Extract portable npm metadata transformations 2026-10-18 17:59:14 +01:00
15 changed files with 1083 additions and 754 deletions

View file

@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/git-pkgs/proxy/internal/jsonscan"
"github.com/git-pkgs/proxy/internal/retention"
"github.com/git-pkgs/purl"
)
@ -226,7 +227,7 @@ func (h *ComposerHandler) rewriteMetadataWithLocal(body []byte, localPackage str
if !json.Valid(body) {
return nil, nil, errors.New("composer metadata is not valid JSON")
}
format, _, err := lookupJSONString(body, "minified")
format, _, err := jsonscan.LookupString(body, "minified")
if err != nil {
return nil, nil, err
}
@ -236,17 +237,17 @@ func (h *ComposerHandler) rewriteMetadataWithLocal(body []byte, localPackage str
var kept []string
listed := false
out.Grow(len(body) + len(body)/8)
err = rewriteJSONMembers(&out, body, func(out *bytes.Buffer, m jsonMember) (bool, error) {
packages := m.value(body)
if !jsonKeyIs(m.key(body), "packages") || packages[0] != '{' {
err = jsonscan.RewriteMembers(&out, body, func(out *bytes.Buffer, m jsonscan.Member) (bool, error) {
packages := m.Value(body)
if !jsonscan.KeyIs(m.Key(body), "packages") || packages[0] != '{' {
return false, nil
}
return true, rewriteJSONMembers(out, packages, func(out *bytes.Buffer, p jsonMember) (bool, error) {
versions := p.value(packages)
return true, jsonscan.RewriteMembers(out, packages, func(out *bytes.Buffer, p jsonscan.Member) (bool, error) {
versions := p.Value(packages)
if versions[0] != '[' {
return false, nil
}
packageName, err := jsonKey(p.key(packages))
packageName, err := jsonscan.Key(p.Key(packages))
if err != nil {
return false, err
}
@ -286,8 +287,8 @@ func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, v
_, routed := h.routes.match(packageName)
out.WriteByte('[')
err := forEachJSONElement(versions, func(entry []byte) error {
if minified && jsonStringIs(entry, composerDevReset) {
err := jsonscan.EachElement(versions, func(entry []byte) error {
if minified && jsonscan.StringIs(entry, composerDevReset) {
upstream.reset()
written.reset()
devReset = true
@ -428,15 +429,15 @@ func disableDefaultNotification(fields *composerFields) {
// rewriteDist returns a version's dist object with its url pointing at this
// proxy. A dist without a string url is returned unchanged.
func (h *ComposerHandler) rewriteDist(packageName, version string, dist []byte) []byte {
urlMember, ok, err := findJSONMember(dist, "url")
if err != nil || !ok || dist[urlMember.valStart] != '"' {
urlMember, ok, err := jsonscan.FindMember(dist, "url")
if err != nil || !ok || dist[urlMember.ValStart] != '"' {
return dist
}
var upstreamURL string
if err := json.Unmarshal(urlMember.value(dist), &upstreamURL); err != nil {
if err := json.Unmarshal(urlMember.Value(dist), &upstreamURL); err != nil {
return dist
}
distType, _, _ := lookupJSONString(dist, "type")
distType, _, _ := jsonscan.LookupString(dist, "type")
proxyURL, ok := h.proxyDistURL(packageName, version, upstreamURL, distType)
if !ok {
@ -446,10 +447,10 @@ func (h *ComposerHandler) rewriteDist(packageName, version string, dist []byte)
if err != nil {
return dist
}
rewritten := make([]byte, 0, len(dist)-len(urlMember.value(dist))+len(encoded))
rewritten = append(rewritten, dist[:urlMember.valStart]...)
rewritten := make([]byte, 0, len(dist)-len(urlMember.Value(dist))+len(encoded))
rewritten = append(rewritten, dist[:urlMember.ValStart]...)
rewritten = append(rewritten, encoded...)
return append(rewritten, dist[urlMember.valEnd:]...)
return append(rewritten, dist[urlMember.ValEnd:]...)
}
// proxyDistURL returns the URL this proxy serves a version's archive at, or
@ -593,9 +594,9 @@ func (f *composerFields) set(name string, key, value []byte) {
// apply overlays the members of a version entry. In minified metadata the
// value "__unset" removes the field instead.
func (f *composerFields) apply(entry []byte, minified bool) error {
return forEachJSONMember(entry, func(m jsonMember) error {
key, value := m.key(entry), m.value(entry)
if minified && jsonStringIs(value, composerUnset) {
return jsonscan.EachMember(entry, func(m jsonscan.Member) error {
key, value := m.Key(entry), m.Value(entry)
if minified && jsonscan.StringIs(value, composerUnset) {
value = nil
}
if bytes.IndexByte(key, '\\') < 0 {
@ -604,7 +605,7 @@ func (f *composerFields) apply(entry []byte, minified bool) error {
return nil
}
}
name, err := jsonKey(key)
name, err := jsonscan.Key(key)
if err != nil {
return err
}
@ -837,11 +838,11 @@ func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaU
// the minified format as it needs, since it runs on each archive the proxy
// has not cached yet.
func composerDistURL(body []byte, packageName, version string) (composerDist, error) {
format, _, err := lookupJSONString(body, "minified")
format, _, err := jsonscan.LookupString(body, "minified")
if err != nil {
return composerDist{}, err
}
versions, err := lookupJSON(body, "packages", packageName)
versions, err := jsonscan.Lookup(body, "packages", packageName)
if err != nil || len(versions) == 0 || versions[0] != '[' {
return composerDist{}, err
}
@ -849,8 +850,8 @@ func composerDistURL(body []byte, packageName, version string) (composerDist, er
fields := newComposerFields()
var dist composerDist
err = forEachJSONElement(versions, func(entry []byte) error {
if minified && jsonStringIs(entry, composerDevReset) {
err = jsonscan.EachElement(versions, func(entry []byte) error {
if minified && jsonscan.StringIs(entry, composerDevReset) {
fields.reset()
return nil
}
@ -863,20 +864,20 @@ func composerDistURL(body []byte, packageName, version string) (composerDist, er
if err := fields.apply(entry, minified); err != nil {
return err
}
if !jsonStringIs(fields.get("version"), version) {
if !jsonscan.StringIs(fields.get("version"), version) {
return nil
}
distRaw := fields.get("dist")
dist.url, _, _ = lookupJSONString(distRaw, "url")
dist.url, _, _ = jsonscan.LookupString(distRaw, "url")
if dist.url != "" {
dist.distType, _, _ = lookupJSONString(distRaw, "type")
dist.shasum, _, _ = lookupJSONString(distRaw, "shasum")
dist.distType, _, _ = jsonscan.LookupString(distRaw, "type")
dist.shasum, _, _ = jsonscan.LookupString(distRaw, "shasum")
dist.published = fields.get("time")
return errStopScan
return jsonscan.ErrStop
}
return nil
})
if errors.Is(err, errStopScan) {
if errors.Is(err, jsonscan.ErrStop) {
err = nil
}
return dist, err

View file

@ -79,7 +79,7 @@ func TestNPMDenylistMetadata(t *testing.T) {
h := NewNPMHandler(p, "https://proxy.example", "")
for _, times := range []string{"", `,"time":{"1.0.0":"2020-01-01T00:00:00Z","2.0.0":"2021-01-01T00:00:00Z"}`} {
body := `{"versions":{"1.0.0":{},"2.0.0":{},"3.0.0-beta.1":{}},"dist-tags":{"latest":"2.0.0","beta":"2.0.0","next":"3.0.0-beta.1"}` + times + `}`
out, err := h.rewriteMetadata("@scope/demo", []byte(body))
out, _, err := h.rewriteMetadataKeeping("@scope/demo", []byte(body))
if err != nil {
t.Fatal(err)
}
@ -87,7 +87,7 @@ func TestNPMDenylistMetadata(t *testing.T) {
t.Fatalf("incorrect metadata: %s", out)
}
}
out, err := h.rewriteMetadata("@scope/demo", []byte(`{"versions":{"2.0.0":{}},"dist-tags":{"latest":"2.0.0"}}`))
out, _, err := h.rewriteMetadataKeeping("@scope/demo", []byte(`{"versions":{"2.0.0":{}},"dist-tags":{"latest":"2.0.0"}}`))
if err != nil || strings.Contains(string(out), "2.0.0") {
t.Fatalf("all denied: %s, %v", out, err)
}
@ -205,7 +205,7 @@ func TestDenylistAndCooldownCompose(t *testing.T) {
setTestDenylist(t, p, "pkg:npm/demo@1.0.0")
h := NewNPMHandler(p, "https://proxy.test", "")
body := fmt.Sprintf(`{"versions":{"1.0.0":{},"2.0.0":{},"3.0.0":{}},"time":{"1.0.0":"2020-01-01T00:00:00Z","2.0.0":"2021-01-01T00:00:00Z","3.0.0":%q},"dist-tags":{"latest":"3.0.0"}}`, time.Now().UTC().Format(time.RFC3339))
out, err := h.rewriteMetadata("demo", []byte(body))
out, _, err := h.rewriteMetadataKeeping("demo", []byte(body))
if err != nil || strings.Contains(string(out), "1.0.0") || strings.Contains(string(out), "3.0.0") || !strings.Contains(string(out), `"latest":"2.0.0"`) {
t.Fatalf("combined policy: %s, %v", out, err)
}

View file

@ -2,21 +2,18 @@ package handler
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"reflect"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/git-pkgs/integrity"
"github.com/git-pkgs/proxy/internal/jsonscan"
"github.com/git-pkgs/proxy/internal/metadata/npm"
"github.com/git-pkgs/proxy/internal/retention"
)
@ -360,444 +357,51 @@ func (h *NPMHandler) npmVersionMetadataUsesFilteredPackument(packageName string)
return false
}
// rewriteNPMVersionFromPackument picks one version entry out of an already
// filtered packument and rewrites its tarball URL.
func (h *NPMHandler) rewriteNPMVersionFromPackument(packageName, specifier string, packument []byte) ([]byte, error) {
filtered, err := h.rewriteMetadata(packageName, packument)
if err != nil {
return nil, err
}
target := specifier
if resolved, ok, err := lookupJSONString(filtered, "dist-tags", specifier); err != nil {
return nil, err
} else if ok {
target = resolved
}
if h.proxy.versionDenied("npm", packageName, target) {
return nil, ErrVersionDenied
}
versionRaw, err := lookupJSON(filtered, "versions", target)
if err != nil {
return nil, err
}
if len(versionRaw) == 0 {
return nil, ErrVersionNotFound
}
var out bytes.Buffer
h.writeNPMVersion(&out, packageName, target, versionRaw)
return out.Bytes(), nil
out, err := npm.Version(packageName, specifier, packument, h.npmRewriteOptions(packageName, nil))
return out, npmRewriteError(err)
}
// rewriteMetadata rewrites tarball URLs in npm package metadata to point at this proxy.
// If cooldown is enabled, versions published too recently are filtered out.
//
// The document is never decoded as a whole. A full packument can run to tens
// of megabytes, and decoding it into generic maps took many times that in
// memory for every rewrite. Instead the version names, time map and dist-tags
// are decoded to decide what to keep, and the response is assembled from
// slices of the original bytes: only each version's tarball URL, and the time
// map and dist-tags when filtering changed them, are written fresh. Everything
// else reaches the client byte for byte as upstream sent it.
func (h *NPMHandler) rewriteMetadata(packageName string, body []byte) ([]byte, error) {
out, _, err := h.rewriteMetadataKeeping(packageName, body)
return out, err
}
// rewriteMetadataKeeping is rewriteMetadata that also reports the versions it
// kept.
func (h *NPMHandler) rewriteMetadataKeeping(packageName string, body []byte) ([]byte, []string, error) {
return h.rewriteMetadataWithLocal(packageName, body, nil)
}
// rewriteMetadataWithLocal is rewriteMetadataKeeping that also lists the
// pushed versions in local, in place of any upstream entry with the same
// version. Cooldown and the denylist only filter upstream versions, and
// dist-tags stay as upstream has them.
func (h *NPMHandler) rewriteMetadataWithLocal(packageName string, body []byte, local []npmLocalEntry) ([]byte, []string, error) {
doc, err := indexNPMPackument(body)
if err != nil {
return nil, nil, err
}
out, kept, err := npm.Rewrite(packageName, body, h.npmRewriteOptions(packageName, local))
return out, kept, npmRewriteError(err)
}
versionsRaw := doc.value(doc.versionsAt)
if len(versionsRaw) == 0 || versionsRaw[0] != '{' {
version, ok, err := lookupJSONString(body, "version")
if err != nil {
return nil, nil, err
}
if ok {
if h.proxy.versionDenied("npm", packageName, version) {
return nil, nil, ErrVersionDenied
}
var out bytes.Buffer
h.writeNPMVersion(&out, packageName, version, body)
return out.Bytes(), []string{version}, nil
}
if len(h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName))) != 0 {
return nil, nil, errors.New("npm metadata has no versions object")
}
return body, nil, nil // No versions to rewrite
func (h *NPMHandler) npmRewriteOptions(packageName string, local []npmLocalEntry) npm.Options {
return npm.Options{
ProxyURL: h.proxyURL,
Cooldown: h.proxy.Cooldown,
DeniedVersions: h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName)),
EvaluatedAt: time.Now(),
LocalVersions: local,
OnTarballRewrite: func(version, oldURL, newURL string) {
h.proxy.Logger.Debug("rewrote tarball URL", "package", packageName, "version", version,
"old", oldURL, "new", newURL)
},
OnCooldownFilter: func(version, publishedAt string) {
h.proxy.Logger.Info("cooldown: filtering npm version", "package", packageName, "version", version,
"published", publishedAt)
},
}
}
// The filters only look at which versions exist, so they run on a map of
// version names alongside the decoded time map and dist-tags.
versions := map[string]any{}
if err := forEachJSONMember(versionsRaw, func(m jsonMember) error {
version, err := jsonKey(m.key(versionsRaw))
versions[version] = nil
func npmRewriteError(err error) error {
switch {
case errors.Is(err, npm.ErrVersionDenied):
return ErrVersionDenied
case errors.Is(err, npm.ErrVersionNotFound):
return ErrVersionNotFound
default:
return err
}); err != nil {
return nil, nil, err
}
metadata := map[string]any{}
timeMap := doc.decodeObject(doc.timeAt, metadata, "time")
distTags := doc.decodeObject(doc.tagsAt, metadata, "dist-tags")
timeEntries := len(timeMap)
originalTags := maps.Clone(distTags)
h.applyCooldownFiltering(metadata, versions, packageName)
h.applyDenylistFiltering(metadata, versions, packageName)
for _, e := range local {
delete(versions, e.version)
}
// Untouched members are copied as they are; the three the filters
// handled are written from their filtered state, and only when it changed.
out, err := doc.write(func(out *bytes.Buffer, i int) error {
switch {
case i == doc.versionsAt:
return h.writeNPMVersions(out, packageName, versionsRaw, versions, local)
case i == doc.timeAt && timeMap != nil && (len(timeMap) != timeEntries || len(local) > 0):
pushedAt := make([][2]string, 0, len(local))
for _, e := range local {
pushedAt = append(pushedAt, [2]string{e.version, e.time})
}
return writeFilteredJSONObjectWith(out, doc.value(i), func(key string) bool {
_, ok := timeMap[key]
_, pushed := findNPMLocalEntry(local, key)
return ok && !pushed
}, pushedAt)
case i == doc.tagsAt && distTags != nil && !reflect.DeepEqual(distTags, originalTags):
encoded, err := json.Marshal(distTags)
out.Write(encoded)
return err
default:
out.Write(doc.value(i))
return nil
}
})
kept := slices.Collect(maps.Keys(versions))
for _, e := range local {
kept = append(kept, e.version)
}
return out, kept, err
}
// npmPackument is a packument's top-level members as offsets into its bytes,
// with the positions of the members rewriteMetadata filters, or -1 for those
// it lacks. A key that repeats is recorded at its last occurrence, the one
// JSON.parse keeps.
type npmPackument struct {
body []byte
members []jsonMember
versionsAt, timeAt, tagsAt int
}
func indexNPMPackument(body []byte) (*npmPackument, error) {
if !json.Valid(body) {
return nil, errors.New("npm metadata is not valid JSON")
}
doc := &npmPackument{body: body, versionsAt: -1, timeAt: -1, tagsAt: -1}
err := forEachJSONMember(body, func(m jsonMember) error {
switch key := m.key(body); {
case jsonKeyIs(key, "versions"):
doc.versionsAt = len(doc.members)
case jsonKeyIs(key, "time"):
doc.timeAt = len(doc.members)
case jsonKeyIs(key, "dist-tags"):
doc.tagsAt = len(doc.members)
}
doc.members = append(doc.members, m)
return nil
})
return doc, err
}
// value returns the raw value of member i, or nil when i is -1.
func (d *npmPackument) value(i int) []byte {
if i < 0 {
return nil
}
return d.members[i].value(d.body)
}
// decodeObject decodes member i into metadata[name] and returns it when it is
// an object. The filters edit the returned map in place.
func (d *npmPackument) decodeObject(i int, metadata map[string]any, name string) map[string]any {
raw := d.value(i)
if raw == nil {
return nil
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
metadata[name] = value
object, _ := value.(map[string]any)
return object
}
// write assembles the document in member order, with writeValue writing each
// member's value. Earlier duplicates of the filtered keys are dropped: they
// would carry unfiltered data, and JSON.parse ignores them anyway.
func (d *npmPackument) write(writeValue func(out *bytes.Buffer, i int) error) ([]byte, error) {
var out bytes.Buffer
out.Grow(len(d.body) + len(d.body)/8)
out.WriteByte('{')
first := true
for i, m := range d.members {
if i != d.versionsAt && i != d.timeAt && i != d.tagsAt {
key := m.key(d.body)
if jsonKeyIs(key, "versions") || jsonKeyIs(key, "time") || jsonKeyIs(key, "dist-tags") {
continue
}
}
if !first {
out.WriteByte(',')
}
first = false
out.Write(m.key(d.body))
out.WriteByte(':')
if err := writeValue(&out, i); err != nil {
return nil, err
}
}
out.WriteByte('}')
return out.Bytes(), nil
}
// writeNPMVersions writes the versions object, keeping the versions still in
// keep and pointing each kept version's tarball at this proxy, followed by the
// pushed versions in local.
func (h *NPMHandler) writeNPMVersions(out *bytes.Buffer, packageName string, versionsRaw []byte, keep map[string]any, local []npmLocalEntry) error {
out.WriteByte('{')
first := true
err := forEachJSONMember(versionsRaw, func(m jsonMember) error {
version, err := jsonKey(m.key(versionsRaw))
if err != nil {
return err
}
if _, ok := keep[version]; !ok {
return nil
}
if !first {
out.WriteByte(',')
}
first = false
out.Write(m.key(versionsRaw))
out.WriteByte(':')
h.writeNPMVersion(out, packageName, version, m.value(versionsRaw))
return nil
})
for _, e := range local {
if !first {
out.WriteByte(',')
}
first = false
key, _ := json.Marshal(e.version)
out.Write(key)
out.WriteByte(':')
out.Write(e.entry)
}
out.WriteByte('}')
return err
}
// writeNPMVersion writes one version entry with its dist.tarball pointing at
// this proxy. An entry without a string tarball is written unchanged.
func (h *NPMHandler) writeNPMVersion(out *bytes.Buffer, packageName, version string, raw []byte) {
dist, ok, err := findJSONMember(raw, "dist")
if err != nil || !ok {
out.Write(raw)
return
}
distRaw := dist.value(raw)
tarball, ok, err := findJSONMember(distRaw, "tarball")
if err != nil || !ok || distRaw[tarball.valStart] != '"' {
out.Write(raw)
return
}
var oldTarball string
if err := json.Unmarshal(tarball.value(distRaw), &oldTarball); err != nil {
out.Write(raw)
return
}
newTarball := h.proxyTarballURL(packageName, version, oldTarball)
encoded, err := json.Marshal(newTarball)
if err != nil {
out.Write(raw)
return
}
out.Write(raw[:dist.valStart+tarball.valStart])
out.Write(encoded)
out.Write(raw[dist.valStart+tarball.valEnd:])
h.proxy.Logger.Debug("rewrote tarball URL",
"package", packageName, "version", version,
"old", oldTarball, "new", newTarball)
}
// writeFilteredJSONObject writes the object obj holds, keeping the members
// whose decoded key keep accepts.
func writeFilteredJSONObject(out *bytes.Buffer, obj []byte, keep func(string) bool) error {
return writeFilteredJSONObjectWith(out, obj, keep, nil)
}
// writeFilteredJSONObjectWith is writeFilteredJSONObject followed by extra
// string members.
func writeFilteredJSONObjectWith(out *bytes.Buffer, obj []byte, keep func(string) bool, extra [][2]string) error {
out.WriteByte('{')
first := true
err := forEachJSONMember(obj, func(m jsonMember) error {
key, err := jsonKey(m.key(obj))
if err != nil {
return err
}
if !keep(key) {
return nil
}
if !first {
out.WriteByte(',')
}
first = false
out.Write(obj[m.keyStart:m.valEnd])
return nil
})
for _, member := range extra {
if !first {
out.WriteByte(',')
}
first = false
key, _ := json.Marshal(member[0])
value, _ := json.Marshal(member[1])
out.Write(key)
out.WriteByte(':')
out.Write(value)
}
out.WriteByte('}')
return err
}
// applyCooldownFiltering removes versions that are too recently published,
// and updates dist-tags.latest if the current latest was filtered out.
func (h *NPMHandler) applyCooldownFiltering(metadata map[string]any, versions map[string]any, packageName string) {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
return
}
timeMap, _ := metadata["time"].(map[string]any)
if timeMap == nil {
return
}
packagePURL := canonicalPackagePURL("npm", packageName)
for version := range versions {
publishedStr, ok := timeMap[version].(string)
if !ok {
continue
}
publishedAt, err := time.Parse(time.RFC3339, publishedStr)
if err != nil {
continue
}
if !h.proxy.Cooldown.IsAllowed("npm", packagePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering npm version",
"package", packageName, "version", version,
"published", publishedStr)
delete(versions, version)
delete(timeMap, version)
}
}
h.updateDistTagsLatest(metadata, versions, timeMap)
}
// updateDistTagsLatest updates the dist-tags.latest field if the current latest
// version was removed by cooldown filtering.
func (h *NPMHandler) updateDistTagsLatest(metadata, versions, timeMap map[string]any) {
distTags, ok := metadata["dist-tags"].(map[string]any)
if !ok {
return
}
latest, ok := distTags["latest"].(string)
if !ok {
return
}
if _, exists := versions[latest]; exists {
return
}
if newLatest := h.findNewestVersion(versions, timeMap); newLatest != "" {
distTags["latest"] = newLatest
}
}
// proxyTarballURL returns the proxy URL that serves the tarball upstream
// lists at tarball for this version.
func (h *NPMHandler) proxyTarballURL(packageName, version, tarball string) string {
filename := tarball
if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
filename = tarball[idx+1:]
}
if h.extractVersionFromFilename(packageName, filename) != version {
_, shortName, scoped := strings.Cut(packageName, "/")
if !scoped {
shortName = packageName
}
filename = shortName + "-" + version + ".tgz"
}
return fmt.Sprintf("%s/npm/%s/-/%s", h.proxyURL, url.PathEscape(packageName), filename)
}
// findNewestVersion returns the version string with the most recent timestamp
// from the remaining versions, using the time map.
func (h *NPMHandler) findNewestVersion(versions map[string]any, timeMap map[string]any) string {
if timeMap == nil {
return ""
}
type versionTime struct {
version string
t time.Time
}
var vts []versionTime
for v := range versions {
if ts, ok := timeMap[v].(string); ok {
if t, err := time.Parse(time.RFC3339, ts); err == nil {
vts = append(vts, versionTime{v, t})
}
}
}
if len(vts) == 0 {
return ""
}
sort.Slice(vts, func(i, j int) bool {
return vts[i].t.After(vts[j].t)
})
return vts[0].version
return npm.TarballURL(h.proxyURL, packageName, version, tarball)
}
// handleDownload serves a package tarball, fetching and caching from upstream if needed.
@ -898,13 +502,13 @@ func (h *NPMHandler) getTarball(r *http.Request, registryURL, packageName, versi
// returns "" when neither is usable, and the tarball is fetched unverified as
// before.
func npmVersionIntegrity(body []byte, version string) string {
sri, _, _ := lookupJSONString(body, "versions", version, "dist", "integrity")
sri, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "integrity")
if sri != "" {
if _, err := integrity.ParseSRI(sri); err == nil {
return sri
}
}
shasum, _, _ := lookupJSONString(body, "versions", version, "dist", "shasum")
shasum, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "shasum")
return sha1Checksum(shasum)
}
@ -912,7 +516,7 @@ func npmVersionIntegrity(body []byte, version string) string {
// or "" if it has none. It reads that one value in place rather than decoding
// every version, since it runs on each tarball the proxy has not cached yet.
func npmVersionTarball(body []byte, version string) string {
tarball, _, _ := lookupJSONString(body, "versions", version, "dist", "tarball")
tarball, _, _ := jsonscan.LookupString(body, "versions", version, "dist", "tarball")
return tarball
}
@ -978,7 +582,7 @@ func (h *NPMHandler) versionInCooldown(packageName, version string, metadata fun
return false
}
published, ok, err := lookupJSONString(body, "time", version)
published, ok, err := jsonscan.LookupString(body, "time", version)
if err != nil {
h.proxy.Logger.Warn("cooldown: could not parse npm metadata for download check",
"package", packageName, "version", version, "error", err)
@ -1076,28 +680,6 @@ func (h *NPMHandler) parseDownloadPath(path string) (packageName, filename strin
return packageName, filename
}
// extractVersionFromFilename extracts version from npm tarball filename.
// e.g., "lodash-4.17.21.tgz" -> "4.17.21"
// e.g., "core-7.23.0.tgz" for @babel/core -> "7.23.0"
func (h *NPMHandler) extractVersionFromFilename(packageName, filename string) string {
// Remove .tgz extension
if !strings.HasSuffix(filename, ".tgz") {
return ""
}
base := strings.TrimSuffix(filename, ".tgz")
// For scoped packages, the filename uses the short name
shortName := packageName
if strings.Contains(packageName, "/") {
parts := strings.SplitN(packageName, "/", scopedParts)
shortName = parts[1]
}
// Expected format: {shortName}-{version}
prefix := shortName + "-"
if !strings.HasPrefix(base, prefix) {
return ""
}
return strings.TrimPrefix(base, prefix)
return npm.VersionFromFilename(packageName, filename)
}

View file

@ -8,15 +8,10 @@ import (
"time"
"github.com/Masterminds/semver/v3"
"github.com/git-pkgs/proxy/internal/metadata/npm"
)
// npmLocalEntry is a pushed version as a packument lists it: the version
// entry built from its package.json, and the time it was pushed.
type npmLocalEntry struct {
version string
entry json.RawMessage
time string
}
type npmLocalEntry = npm.LocalVersion
// npmLocalEntries returns the pushed versions of a package as packument
// entries, or nil when there are none or hosted packages are disabled.
@ -42,14 +37,14 @@ func (h *NPMHandler) npmLocalEntries(packageName string) ([]npmLocalEntry, error
if err != nil {
return nil, err
}
entries = append(entries, npmLocalEntry{version: lv.Version, entry: entry, time: lv.PushedAt.Format(time.RFC3339)})
entries = append(entries, npmLocalEntry{Version: lv.Version, Entry: entry, PublishedAt: lv.PushedAt.Format(time.RFC3339)})
}
return entries, nil
}
func findNPMLocalEntry(local []npmLocalEntry, version string) (npmLocalEntry, bool) {
for _, e := range local {
if e.version == version {
if e.Version == version {
return e, true
}
}
@ -81,7 +76,7 @@ func (h *NPMHandler) serveLocalMetadata(w http.ResponseWriter, r *http.Request,
case req.versionLookup && pushed:
w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(e.entry)
_, _ = w.Write(e.Entry)
case h.proxy.hostedOnly("npm", req.packageName):
h.serveHostedOnlyMetadata(w, r, req.packageName, req.specifier, req.versionLookup, req.metadataCacheKey, local)
case len(local) > 0 && !req.versionLookup:
@ -144,7 +139,7 @@ func (h *NPMHandler) serveHostedOnlyMetadata(w http.ResponseWriter, r *http.Requ
}
w.Header().Set(headerContentType, contentTypeJSON)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(e.entry)
_, _ = w.Write(e.Entry)
return
}
out, err := localNPMPackument(packageName, local)
@ -161,8 +156,8 @@ func localNPMPackument(packageName string, local []npmLocalEntry) ([]byte, error
versions := make(map[string]json.RawMessage, len(local))
times := make(map[string]string, len(local))
for _, e := range local {
versions[e.version] = e.entry
times[e.version] = e.time
versions[e.Version] = e.Entry
times[e.Version] = e.PublishedAt
}
return json.Marshal(map[string]any{
"name": packageName,
@ -179,10 +174,10 @@ func latestNPMVersion(local []npmLocalEntry) string {
var latestSemver *semver.Version
semverOnly := true
for _, e := range local {
if e.time > latestTime {
latest, latestTime = e.version, e.time
if e.PublishedAt > latestTime {
latest, latestTime = e.Version, e.PublishedAt
}
v, err := semver.NewVersion(e.version)
v, err := semver.NewVersion(e.Version)
if err != nil {
semverOnly = false
continue

View file

@ -0,0 +1,107 @@
package handler
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net/http"
"os"
"strings"
"testing"
"github.com/git-pkgs/cooldown"
)
func TestNPMMetadataLogsPolicyAndURLChanges(t *testing.T) {
body, err := os.ReadFile("../metadata/npm/testdata/packument.json")
if err != nil {
t.Fatal(err)
}
f := newNPMHostedFixture(t, map[string]string{"/@example%2Fwidgets": string(body)})
f.proxy.Cooldown = &cooldown.Config{Default: "7d"}
setTestDenylist(t, f.proxy, "pkg:npm/@example/widgets@2.0.0")
var logs bytes.Buffer
f.proxy.Logger = slog.New(slog.NewJSONHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
if rec := f.get(t, "/@example/widgets"); rec.Code != http.StatusOK {
t.Fatalf("HTTP %d: %s", rec.Code, rec.Body.String())
}
want := map[string]map[string]string{
"cooldown: filtering npm version": {"level": "INFO", "package": "@example/widgets", "version": "3.0.0", "published": "2099-01-01T00:00:00Z"},
"rewrote tarball URL": {"level": "DEBUG", "package": "@example/widgets", "version": "1.0.0",
"old": "https://registry.npmjs.org/@example/widgets/-/widgets-1.0.0.tgz", "new": "http://proxy.local/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"},
}
decoder := json.NewDecoder(&logs)
for {
var record map[string]any
if err := decoder.Decode(&record); err == io.EOF {
break
} else if err != nil {
t.Fatal(err)
}
message, _ := record["msg"].(string)
fields, ok := want[message]
if !ok {
continue
}
for key, value := range fields {
if record[key] != value {
t.Errorf("%s: %s = %v, want %q", message, key, record[key], value)
}
}
delete(want, message)
}
if len(want) != 0 {
t.Errorf("missing log records: %v", want)
}
}
func TestNPMRoutesWithPortableMetadata(t *testing.T) {
body, err := os.ReadFile("../metadata/npm/testdata/packument.json")
if err != nil {
t.Fatal(err)
}
for _, hosted := range []bool{false, true} {
name := "upstream"
if hosted {
name = "hosted override"
}
t.Run(name, func(t *testing.T) {
f := newNPMHostedFixture(t, map[string]string{"/@example%2Fwidgets": string(body)})
f.proxy.Cooldown = &cooldown.Config{Default: "7d"}
setTestDenylist(t, f.proxy, "pkg:npm/@example/widgets@2.0.0")
if hosted {
f.push(t, "@example/widgets", "3.0.0", `{"name":"@example/widgets","version":"3.0.0","dependencies":{"local":"1.0.0"}}`)
}
rec := f.get(t, "/@example/widgets")
doc := decodePackument(t, rec)
count := 1
if hosted {
count = 2
}
if len(doc.Versions) != count || doc.DistTags["latest"] != "1.0.0" || len(doc.DistTags) != 1 {
t.Fatalf("filtered metadata: %s", rec.Body.String())
}
if doc.Versions["1.0.0"].Dist.Tarball != "http://proxy.local/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz" || !strings.Contains(rec.Body.String(), "12345678901234567890") {
t.Fatalf("rewritten metadata: %s", rec.Body.String())
}
if hosted && doc.Versions["3.0.0"].Dependencies["local"] != "1.0.0" {
t.Fatalf("hosted override: %s", rec.Body.String())
}
checkNPMVersionResponses(t, f, hosted)
})
}
}
func checkNPMVersionResponses(t *testing.T, f npmHostedFixture, hosted bool) {
t.Helper()
for specifier, want := range map[string]int{"latest": http.StatusOK, "1.0.0": http.StatusOK, "2.0.0": http.StatusForbidden, "3.0.0": http.StatusNotFound} {
if hosted && specifier == "3.0.0" {
want = http.StatusOK
}
got := f.get(t, "/@example/widgets/"+specifier)
if got.Code != want {
t.Errorf("%s: HTTP %d, want %d: %s", specifier, got.Code, want, got.Body.String())
}
}
}

View file

@ -43,7 +43,7 @@ func BenchmarkNPMRewriteMetadata(b *testing.B) {
b.SetBytes(int64(len(body)))
b.ReportAllocs()
for b.Loop() {
if _, err := h.rewriteMetadata("big", body); err != nil {
if _, _, err := h.rewriteMetadataKeeping("big", body); err != nil {
b.Fatal(err)
}
}

View file

@ -1,9 +1,7 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@ -14,159 +12,6 @@ import (
"github.com/git-pkgs/cooldown"
)
// TestNPMRewriteMetadataKeepsUpstreamBytes checks that only tarball URLs
// change: everything else, including numbers encoding/json would round
// through float64 and characters it would HTML-escape, is copied verbatim.
func TestNPMRewriteMetadataKeepsUpstreamBytes(t *testing.T) {
h := &NPMHandler{proxy: testProxy(), proxyURL: "http://proxy"}
input := `{"name":"demo","_big":12345678901234567890,"readme":"<b>&</b>",` +
`"versions":{"1.0.0":{"version":"1.0.0","dist":{"shasum":"s","tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz","integrity":"i"}}}}`
out, err := h.rewriteMetadata("demo", []byte(input))
if err != nil {
t.Fatal(err)
}
want := strings.Replace(input, "https://registry.npmjs.org/demo/-/demo-1.0.0.tgz", "http://proxy/npm/demo/-/demo-1.0.0.tgz", 1)
if string(out) != want {
t.Errorf("rewrite =\n%s\nwant\n%s", out, want)
}
}
func TestNPMRewriteMetadataUnusualEntries(t *testing.T) {
h := &NPMHandler{proxy: testProxy(), proxyURL: "http://proxy"}
input := `{
"versions": {
"1.0.0": "not an object",
"2.0.0": {"dist": "no object either"},
"3.0.0": {"dist": {"tarball": 7}},
"4.0.0": {"dist": {"tarball": "https://registry.npmjs.org/demo/-/renamed.tgz"}},
"5.0.0": {"dist": {}}
}
}`
out, err := h.rewriteMetadata("demo", []byte(input))
if err != nil {
t.Fatal(err)
}
var doc struct {
Versions map[string]json.RawMessage `json:"versions"`
}
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out)
}
for v, want := range map[string]string{
"1.0.0": `"not an object"`,
"2.0.0": `{"dist": "no object either"}`,
"3.0.0": `{"dist": {"tarball": 7}}`,
"4.0.0": `{"dist": {"tarball": "http://proxy/npm/demo/-/demo-4.0.0.tgz"}}`,
"5.0.0": `{"dist": {}}`,
} {
if got := string(doc.Versions[v]); got != want {
t.Errorf("%s = %s, want %s", v, got, want)
}
}
}
func TestNPMRewriteMetadataInvalidJSON(t *testing.T) {
h := &NPMHandler{proxy: testProxy(), proxyURL: "http://proxy"}
for _, input := range []string{`{"versions": {}`, `[1]`, `{"versions": {}} trailing`} {
if _, err := h.rewriteMetadata("demo", []byte(input)); err == nil {
t.Errorf("%s: expected an error", input)
}
}
}
func TestNPMRewriteMetadataNoVersions(t *testing.T) {
h := &NPMHandler{proxy: testProxy(), proxyURL: "http://proxy"}
input := `{"name":"demo","versions":[]}`
out, err := h.rewriteMetadata("demo", []byte(input))
if err != nil || string(out) != input {
t.Errorf("rewrite = %s, %v; want input unchanged", out, err)
}
setTestDenylist(t, h.proxy, "pkg:npm/demo@1.0.0")
if _, err := h.rewriteMetadata("demo", []byte(input)); err == nil {
t.Error("expected an error when a denylisted package has no versions object")
}
}
// TestNPMRewriteMetadataCooldownAndDenylist runs both filters together and
// checks that versions, their time entries and the tags pointing at them are
// all removed, with latest moved to the newest version left.
func TestNPMRewriteMetadataCooldownAndDenylist(t *testing.T) {
now := time.Now()
ts := func(age time.Duration) string { return now.Add(-age).UTC().Format(time.RFC3339) }
proxy := testProxy()
proxy.Cooldown = &cooldown.Config{Default: "3d"}
setTestDenylist(t, proxy, "pkg:npm/demo@2.0.0")
h := &NPMHandler{proxy: proxy, proxyURL: "http://proxy"}
version := func(v string) string {
return fmt.Sprintf(`%q:{"version":%q,"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-%s.tgz"}}`, v, v, v)
}
input := `{"name":"demo",` +
`"dist-tags":{"latest":"3.0.0","beta":"2.0.0","next":"1.0.0"},` +
`"versions":{` + version("1.0.0") + `,` + version("2.0.0") + `,` + version("3.0.0") + `},` +
`"time":{"created":"` + ts(1000*time.Hour) + `","1.0.0":"` + ts(900*time.Hour) + `","2.0.0":"` + ts(800*time.Hour) + `","3.0.0":"` + ts(time.Hour) + `"}}`
out, err := h.rewriteMetadata("demo", []byte(input))
if err != nil {
t.Fatal(err)
}
var doc struct {
DistTags map[string]string `json:"dist-tags"`
Versions map[string]json.RawMessage `json:"versions"`
Time map[string]string `json:"time"`
}
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out)
}
if len(doc.Versions) != 1 || doc.Versions["1.0.0"] == nil {
t.Errorf("versions = %v, want only 1.0.0", keysOf(doc.Versions))
}
if _, ok := doc.Time["2.0.0"]; ok {
t.Error("time still lists denylisted 2.0.0")
}
if _, ok := doc.Time["3.0.0"]; ok {
t.Error("time still lists 3.0.0, which is inside cooldown")
}
if _, ok := doc.Time["created"]; !ok {
t.Error("time lost its created entry")
}
want := map[string]string{"latest": "1.0.0", "next": "1.0.0"}
if fmt.Sprint(doc.DistTags) != fmt.Sprint(want) {
t.Errorf("dist-tags = %v, want %v", doc.DistTags, want)
}
if !strings.Contains(string(doc.Versions["1.0.0"]), "http://proxy/npm/demo/-/demo-1.0.0.tgz") {
t.Errorf("1.0.0 tarball not rewritten: %s", doc.Versions["1.0.0"])
}
}
// TestNPMRewriteMetadataDuplicateKeys checks that a repeated top-level key
// cannot smuggle an unfiltered copy of the versions past the filters.
func TestNPMRewriteMetadataDuplicateKeys(t *testing.T) {
proxy := testProxy()
setTestDenylist(t, proxy, "pkg:npm/demo@2.0.0")
h := &NPMHandler{proxy: proxy, proxyURL: "http://proxy"}
input := `{"versions":{"2.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}},` +
`"versions":{"1.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}},"2.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}}}`
out, err := h.rewriteMetadata("demo", []byte(input))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "2.0.0") || strings.Count(string(out), `"versions"`) != 1 {
t.Errorf("rewrite = %s", out)
}
}
func keysOf(m map[string]json.RawMessage) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
// TestMetadataServesRewriteWithoutReadingStorage checks that a repeat
// request inside the metadata TTL is answered from the rewrite cache by the
// stored digest alone, without reading the cached document back.

View file

@ -153,7 +153,7 @@ func TestNPMRewriteMetadata(t *testing.T) {
}
}`
output, err := h.rewriteMetadata("lodash", []byte(input))
output, _, err := h.rewriteMetadataKeeping("lodash", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}
@ -193,7 +193,7 @@ func TestNPMRewriteMetadataScopedPackage(t *testing.T) {
}
}`
output, err := h.rewriteMetadata("@babel/core", []byte(input))
output, _, err := h.rewriteMetadataKeeping("@babel/core", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}
@ -308,7 +308,7 @@ func TestNPMRewriteMetadataCooldown(t *testing.T) {
}
}`
output, err := h.rewriteMetadata("testpkg", []byte(input))
output, _, err := h.rewriteMetadataKeeping("testpkg", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}
@ -364,7 +364,7 @@ func TestNPMRewriteMetadataCooldownExemptPackage(t *testing.T) {
}
}`
output, err := h.rewriteMetadata("testpkg", []byte(input))
output, _, err := h.rewriteMetadataKeeping("testpkg", []byte(input))
if err != nil {
t.Fatalf("rewriteMetadata failed: %v", err)
}
@ -1065,7 +1065,7 @@ func TestNPMMetadataPath(t *testing.T) {
func TestNPMVersionMetadataKeepsVersionSegment(t *testing.T) {
tests := []struct {
request, upstreamPath, wantTarball string
body string
body string
}{
{
request: "/lodash/4.17.21",

View file

@ -0,0 +1,309 @@
package jsonscan
import (
"bytes"
"encoding/json"
"errors"
)
// The helpers in this file walk a JSON document in place, reporting where
// object members sit as offsets into the original bytes. They let a handler
// read or replace one value in a large metadata document without decoding the
// rest of it into Go values, which for an npm packument costs many times the
// document's size.
//
// They check structure only as far as they need to find their way through
// the document. Callers that copy unvisited bytes into a response validate the
// whole document first with json.Valid.
var ErrMalformed = errors.New("malformed JSON")
// ErrNotObject is returned when a value expected to be an object is not.
var ErrNotObject = errors.New("JSON value is not an object")
// ErrStop is returned by a callback to end a walk early once it has
// found what it was looking for.
var ErrStop = errors.New("stop scanning")
// Member is one member of a JSON object as offsets into the bytes that
// were scanned. The key span includes its quotes.
type Member struct {
KeyStart, KeyEnd int
ValStart, ValEnd int
}
func (m Member) Key(data []byte) []byte { return data[m.KeyStart:m.KeyEnd] }
func (m Member) Value(data []byte) []byte { return data[m.ValStart:m.ValEnd] }
func skipJSONSpace(data []byte, i int) int {
for i < len(data) {
switch data[i] {
case ' ', '\t', '\n', '\r':
i++
default:
return i
}
}
return i
}
// skipJSONString returns the index just past the string starting at data[i].
func skipJSONString(data []byte, i int) (int, error) {
for j := i + 1; j < len(data); j++ {
switch data[j] {
case '\\':
j++
case '"':
return j + 1, nil
}
}
return 0, ErrMalformed
}
// skipJSONValue returns the index just past the value starting at data[i].
func skipJSONValue(data []byte, i int) (int, error) {
if i >= len(data) {
return 0, ErrMalformed
}
switch data[i] {
case '"':
return skipJSONString(data, i)
case '{', '[':
depth := 0
for j := i; j < len(data); j++ {
switch data[j] {
case '"':
end, err := skipJSONString(data, j)
if err != nil {
return 0, err
}
j = end - 1
case '{', '[':
depth++
case '}', ']':
depth--
if depth == 0 {
return j + 1, nil
}
}
}
return 0, ErrMalformed
case '}', ']', ',', ':':
return 0, ErrMalformed
default:
// A number, true, false or null runs to the next delimiter.
j := i
for j < len(data) {
switch data[j] {
case ',', '}', ']', ' ', '\t', '\n', '\r':
return j, nil
}
j++
}
return j, nil
}
}
// EachMember calls fn for each member of the object obj holds, in
// document order. It returns ErrNotObject if obj is not an object, and
// stops early with fn's error if fn returns one.
func EachMember(obj []byte, fn func(Member) error) error {
i := skipJSONSpace(obj, 0)
if i >= len(obj) || obj[i] != '{' {
return ErrNotObject
}
i = skipJSONSpace(obj, i+1)
if i < len(obj) && obj[i] == '}' {
return nil
}
for {
if i >= len(obj) || obj[i] != '"' {
return ErrMalformed
}
KeyEnd, err := skipJSONString(obj, i)
if err != nil {
return err
}
colon := skipJSONSpace(obj, KeyEnd)
if colon >= len(obj) || obj[colon] != ':' {
return ErrMalformed
}
ValStart := skipJSONSpace(obj, colon+1)
ValEnd, err := skipJSONValue(obj, ValStart)
if err != nil {
return err
}
if err := fn(Member{KeyStart: i, KeyEnd: KeyEnd, ValStart: ValStart, ValEnd: ValEnd}); err != nil {
return err
}
next := skipJSONSpace(obj, ValEnd)
if next >= len(obj) {
return ErrMalformed
}
switch obj[next] {
case ',':
i = skipJSONSpace(obj, next+1)
case '}':
return nil
default:
return ErrMalformed
}
}
}
// EachElement calls fn with each element of the array arr holds, in
// order, and stops early with fn's error if fn returns one.
func EachElement(arr []byte, fn func(element []byte) error) error {
i := skipJSONSpace(arr, 0)
if i >= len(arr) || arr[i] != '[' {
return ErrMalformed
}
i = skipJSONSpace(arr, i+1)
if i < len(arr) && arr[i] == ']' {
return nil
}
for {
end, err := skipJSONValue(arr, i)
if err != nil {
return err
}
if err := fn(arr[i:end]); err != nil {
return err
}
next := skipJSONSpace(arr, end)
if next >= len(arr) {
return ErrMalformed
}
switch arr[next] {
case ',':
i = skipJSONSpace(arr, next+1)
case ']':
return nil
default:
return ErrMalformed
}
}
}
// RewriteMembers copies the object obj holds to out byte for byte,
// except that rewrite may write a member's value itself. It returns true
// when it did; otherwise the original value is copied.
func RewriteMembers(out *bytes.Buffer, obj []byte, rewrite func(out *bytes.Buffer, m Member) (bool, error)) error {
copied := 0
err := EachMember(obj, func(m Member) error {
out.Write(obj[copied:m.ValStart])
copied = m.ValStart
rewritten, err := rewrite(out, m)
if rewritten {
copied = m.ValEnd
}
return err
})
out.Write(obj[copied:])
return err
}
// Key decodes a quoted key as EachMember reports it.
func Key(raw []byte) (string, error) {
if bytes.IndexByte(raw, '\\') < 0 {
return string(raw[1 : len(raw)-1]), nil
}
var key string
err := json.Unmarshal(raw, &key)
return key, err
}
// KeyIs reports whether a quoted key decodes to name.
func KeyIs(raw []byte, name string) bool {
if bytes.IndexByte(raw, '\\') < 0 {
return string(raw[1:len(raw)-1]) == name
}
key, err := Key(raw)
return err == nil && key == name
}
// StringIs reports whether raw is a JSON string that decodes to s.
func StringIs(raw []byte, s string) bool {
return len(raw) >= 2 && raw[0] == '"' && KeyIs(raw, s)
}
// FindMember returns the member of obj named name. When the key repeats,
// the last one wins, as it does for JSON.parse and encoding/json.
func FindMember(obj []byte, name string) (Member, bool, error) {
var found Member
ok := false
err := EachMember(obj, func(m Member) error {
if KeyIs(m.Key(obj), name) {
found, ok = m, true
}
return nil
})
return found, ok, err
}
// Lookup follows path through nested objects in doc and returns the
// value at its end. It returns nil and no error when a key along the path is
// missing or names something other than an object.
func Lookup(doc []byte, path ...string) ([]byte, error) {
value := doc
for _, name := range path {
m, ok, err := FindMember(value, name)
if errors.Is(err, ErrNotObject) {
return nil, nil
}
if err != nil || !ok {
return nil, err
}
value = m.Value(value)
}
return value, nil
}
// LookupString is Lookup for a string value. ok is false when the
// value is missing or is not a string.
func LookupString(doc []byte, path ...string) (string, bool, error) {
raw, err := Lookup(doc, path...)
if err != nil || len(raw) == 0 || raw[0] != '"' {
return "", false, err
}
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return "", false, err
}
return s, true, nil
}
// WriteFilteredObject writes the object obj holds, keeping the members
// whose decoded key keep accepts, followed by extra string members.
func WriteFilteredObject(out *bytes.Buffer, obj []byte, keep func(string) bool, extra [][2]string) error {
out.WriteByte('{')
first := true
err := EachMember(obj, func(m Member) error {
key, err := Key(m.Key(obj))
if err != nil {
return err
}
if !keep(key) {
return nil
}
if !first {
out.WriteByte(',')
}
first = false
out.Write(obj[m.KeyStart:m.ValEnd])
return nil
})
for _, member := range extra {
if !first {
out.WriteByte(',')
}
first = false
key, _ := json.Marshal(member[0])
value, _ := json.Marshal(member[1])
out.Write(key)
out.WriteByte(':')
out.Write(value)
}
out.WriteByte('}')
return err
}

View file

@ -0,0 +1,155 @@
package jsonscan
import (
"bytes"
"errors"
"testing"
)
func TestLookupJSONString(t *testing.T) {
doc := []byte(`{
"name": "demo",
"versions": {
"1.0.0": {"dist": {"tarball": "https://example.com/a.tgz", "shasum": "x"}},
"2.0.0": {"readme": "a } tricky \" string ] with {brackets}", "dist": {"tarball": "https://example.com/b.tgz"}},
"3.0.0": "not an object",
"4.0.0": {"dist": {"tarball": 42}}
},
"time": {"1.0.0": "2020-01-01T00:00:00Z", "1.0.0": "2021-01-01T00:00:00Z"},
"n": -1.5e3, "t": true, "z": null, "list": [1, {"a": []}, "]"]
}`)
cases := []struct {
path []string
want string
wantOK bool
}{
{[]string{"versions", "1.0.0", "dist", "tarball"}, "https://example.com/a.tgz", true},
{[]string{"versions", "2.0.0", "dist", "tarball"}, "https://example.com/b.tgz", true},
{[]string{"versions", "3.0.0", "dist", "tarball"}, "", false},
{[]string{"versions", "4.0.0", "dist", "tarball"}, "", false},
{[]string{"versions", "9.9.9", "dist", "tarball"}, "", false},
{[]string{"time", "1.0.0"}, "2021-01-01T00:00:00Z", true}, // last duplicate wins
{[]string{"name"}, "demo", true},
{[]string{"name", "x"}, "", false},
}
for _, c := range cases {
got, ok, err := LookupString(doc, c.path...)
if err != nil || ok != c.wantOK || got != c.want {
t.Errorf("lookup %v = %q, %v, %v; want %q, %v", c.path, got, ok, err, c.want, c.wantOK)
}
}
}
func TestLookupJSONEscapedKey(t *testing.T) {
doc := []byte(`{"versions": {"1.0.0": {"dist": {"tarball": "u"}}}}`)
got, ok, err := LookupString(doc, "versions", "1.0.0", "dist", "tarball")
if err != nil || !ok || got != "u" {
t.Errorf("lookup = %q, %v, %v", got, ok, err)
}
}
func TestLookupJSONMalformed(t *testing.T) {
for _, doc := range []string{
`{"versions": {"1.0.0": {"dist": `,
`{"versions" {}}`,
`{"versions": {"a": 1 "b": 2}}`,
`{"a": "unterminated}`,
`{"a": [1, 2}`,
} {
_, _, err := LookupString([]byte(doc), "versions", "1.0.0", "dist", "tarball")
if !errors.Is(err, ErrMalformed) {
t.Errorf("%s: err = %v, want ErrMalformed", doc, err)
}
}
}
func TestForEachJSONMemberOffsets(t *testing.T) {
doc := []byte(` { "a" : 1 , "b":{"c":[true,null]} ,"d":"x\"y" } `)
var got [][2]string
err := EachMember(doc, func(m Member) error {
got = append(got, [2]string{string(m.Key(doc)), string(m.Value(doc))})
return nil
})
want := [][2]string{{`"a"`, `1`}, {`"b"`, `{"c":[true,null]}`}, {`"d"`, `"x\"y"`}}
if err != nil || len(got) != len(want) {
t.Fatalf("members = %v, %v", got, err)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("member %d = %v, want %v", i, got[i], want[i])
}
}
if err := EachMember([]byte(`[1]`), func(Member) error { return nil }); !errors.Is(err, ErrNotObject) {
t.Errorf("array: err = %v, want ErrNotObject", err)
}
if err := EachMember([]byte(`{}`), func(Member) error { t.Error("called for empty object"); return nil }); err != nil {
t.Errorf("empty object: err = %v", err)
}
}
func TestWriteFilteredJSONObject(t *testing.T) {
var out bytes.Buffer
err := WriteFilteredObject(&out, []byte(`{"a": 1, "b": {"x": 2}, "c": 3}`), func(k string) bool { return k != "b" }, nil)
if err != nil || out.String() != `{"a": 1,"c": 3}` {
t.Errorf("filtered = %s, %v", out.String(), err)
}
}
func TestForEachJSONElement(t *testing.T) {
var got []string
err := EachElement([]byte(` [ 1, "a,]", {"b": [2]} ,[] ] `), func(e []byte) error {
got = append(got, string(e))
return nil
})
want := []string{`1`, `"a,]"`, `{"b": [2]}`, `[]`}
if err != nil || len(got) != len(want) {
t.Fatalf("elements = %q, %v; want %q", got, err, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("element %d = %s, want %s", i, got[i], want[i])
}
}
if err := EachElement([]byte(`[]`), func([]byte) error { t.Error("called for empty array"); return nil }); err != nil {
t.Errorf("empty array: err = %v", err)
}
for _, bad := range []string{`{}`, `[1,`, `[1 2]`, `[1`} {
if err := EachElement([]byte(bad), func([]byte) error { return nil }); err == nil {
t.Errorf("%s: expected an error", bad)
}
}
}
func TestRewriteJSONMembers(t *testing.T) {
obj := []byte(`{ "a": 1, "b": [2], "c" : 3 }`)
var out bytes.Buffer
err := RewriteMembers(&out, obj, func(out *bytes.Buffer, m Member) (bool, error) {
if !KeyIs(m.Key(obj), "b") {
return false, nil
}
out.WriteString(`"B"`)
return true, nil
})
if want := `{ "a": 1, "b": "B", "c" : 3 }`; err != nil || out.String() != want {
t.Errorf("rewritten = %s, %v; want %s", out.String(), err, want)
}
}
func TestJSONStringIs(t *testing.T) {
for raw, want := range map[string]bool{
`"~dev"`: true,
`"~\u0064ev"`: true,
`"~dev2"`: false,
`{"~dev":1}`: false,
`"`: false,
``: false,
`["~dev"]`: false,
} {
if got := StringIs([]byte(raw), "~dev"); got != want {
t.Errorf("StringIs(%s) = %v, want %v", raw, got, want)
}
}
}

View file

@ -0,0 +1,46 @@
package npm
import "github.com/Masterminds/semver/v3"
func (h *transformer) applyDenylistFiltering(metadata, versions map[string]any) {
denied := h.DeniedVersions
if len(denied) == 0 {
return
}
times, _ := metadata["time"].(map[string]any)
for version := range denied {
delete(versions, version)
delete(times, version)
}
tags, _ := metadata["dist-tags"].(map[string]any)
for tag, value := range tags {
version, _ := value.(string)
if !denied[version] {
continue
}
delete(tags, tag)
// Custom tags describe publisher intent; do not retarget them. For
// latest, use the highest remaining stable version, even in abbreviated
// packuments without publication timestamps.
if tag == "latest" {
if latest := newestStableNPMVersion(versions); latest != "" {
tags[tag] = latest
}
}
}
}
func newestStableNPMVersion(versions map[string]any) string {
var best *semver.Version
result := ""
for version := range versions {
parsed, err := semver.StrictNewVersion(version)
if err != nil || parsed.Prerelease() != "" {
continue
}
if best == nil || parsed.GreaterThan(best) || (parsed.Equal(best) && version > result) {
best, result = parsed, version
}
}
return result
}

View file

@ -0,0 +1,454 @@
// Package npm transforms registry metadata without storage or network access.
package npm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"maps"
"net/url"
"reflect"
"slices"
"sort"
"strings"
"time"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/proxy/internal/jsonscan"
"github.com/git-pkgs/proxy/internal/packageurl"
)
var ErrVersionDenied = errors.New("package version blocked by denylist")
var ErrVersionNotFound = errors.New("version not found")
// CooldownPolicy evaluates version age at the caller's supplied time.
type CooldownPolicy interface {
Enabled() bool
Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision
}
// LocalVersion is an already approved hosted version and its publication time.
type LocalVersion struct {
Version string
Entry json.RawMessage
PublishedAt string
}
// Options supplies the public URL, policy and hosted versions for a rewrite.
// EvaluatedAt is used as given; the transformation never reads the clock.
type Options struct {
ProxyURL string
Cooldown CooldownPolicy
DeniedVersions map[string]bool
EvaluatedAt time.Time
LocalVersions []LocalVersion
// Optional callbacks report changes synchronously without retaining events.
OnTarballRewrite func(version, oldURL, newURL string)
OnCooldownFilter func(version, publishedAt string)
}
type transformer struct{ Options }
// Rewrite filters upstream versions and rewrites their tarball URLs, preserving
// untouched JSON values verbatim. Hosted entries override upstream versions.
// The returned version set is unordered. The document, denylist and hosted
// entries are not modified.
func Rewrite(packageName string, body []byte, options Options) ([]byte, []string, error) {
return (&transformer{options}).rewriteMetadataWithLocal(packageName, body, options.LocalVersions)
}
// Version resolves a version or dist-tag through the filtered packument.
func Version(packageName, specifier string, packument []byte, options Options) ([]byte, error) {
filtered, _, err := Rewrite(packageName, packument, options)
if err != nil {
return nil, err
}
target := specifier
if resolved, ok, err := jsonscan.LookupString(filtered, "dist-tags", specifier); err != nil {
return nil, err
} else if ok {
target = resolved
}
if options.DeniedVersions[target] {
return nil, ErrVersionDenied
}
versionRaw, err := jsonscan.Lookup(filtered, "versions", target)
if err != nil {
return nil, err
}
if len(versionRaw) == 0 {
return nil, ErrVersionNotFound
}
var out bytes.Buffer
(&transformer{options}).writeNPMVersion(&out, packageName, target, versionRaw)
return out.Bytes(), nil
}
func (h *transformer) rewriteMetadataWithLocal(packageName string, body []byte, local []LocalVersion) ([]byte, []string, error) {
doc, err := indexNPMPackument(body)
if err != nil {
return nil, nil, err
}
versionsRaw := doc.value(doc.versionsAt)
if len(versionsRaw) == 0 || versionsRaw[0] != '{' {
version, ok, err := jsonscan.LookupString(body, "version")
if err != nil {
return nil, nil, err
}
if ok {
if h.DeniedVersions[version] {
return nil, nil, ErrVersionDenied
}
var out bytes.Buffer
h.writeNPMVersion(&out, packageName, version, body)
return out.Bytes(), []string{version}, nil
}
if len(h.DeniedVersions) != 0 {
return nil, nil, errors.New("npm metadata has no versions object")
}
return body, nil, nil // No versions to rewrite
}
// The filters only look at which versions exist, so they run on a map of
// version names alongside the decoded time map and dist-tags.
versions := map[string]any{}
if err := jsonscan.EachMember(versionsRaw, func(m jsonscan.Member) error {
version, err := jsonscan.Key(m.Key(versionsRaw))
versions[version] = nil
return err
}); err != nil {
return nil, nil, err
}
metadata := map[string]any{}
timeMap := doc.decodeObject(doc.timeAt, metadata, "time")
distTags := doc.decodeObject(doc.tagsAt, metadata, "dist-tags")
timeEntries := len(timeMap)
originalTags := maps.Clone(distTags)
h.applyCooldownFiltering(metadata, versions, packageName)
h.applyDenylistFiltering(metadata, versions)
for _, e := range local {
delete(versions, e.Version)
}
// Untouched members are copied as they are; the three the filters
// handled are written from their filtered state, and only when it changed.
out, err := doc.write(func(out *bytes.Buffer, i int) error {
switch {
case i == doc.versionsAt:
return h.writeNPMVersions(out, packageName, versionsRaw, versions, local)
case i == doc.timeAt && timeMap != nil && (len(timeMap) != timeEntries || len(local) > 0):
pushedAt := make([][2]string, 0, len(local))
for _, e := range local {
pushedAt = append(pushedAt, [2]string{e.Version, e.PublishedAt})
}
return jsonscan.WriteFilteredObject(out, doc.value(i), func(key string) bool {
_, ok := timeMap[key]
pushed := slices.ContainsFunc(local, func(e LocalVersion) bool { return e.Version == key })
return ok && !pushed
}, pushedAt)
case i == doc.tagsAt && distTags != nil && !reflect.DeepEqual(distTags, originalTags):
encoded, err := json.Marshal(distTags)
out.Write(encoded)
return err
default:
out.Write(doc.value(i))
return nil
}
})
kept := slices.Collect(maps.Keys(versions))
for _, e := range local {
kept = append(kept, e.Version)
}
return out, kept, err
}
// npmPackument is a packument's top-level members as offsets into its bytes,
// with the positions of the members rewriteMetadata filters, or -1 for those
// it lacks. A key that repeats is recorded at its last occurrence, the one
// JSON.parse keeps.
type npmPackument struct {
body []byte
members []jsonscan.Member
versionsAt, timeAt, tagsAt int
}
func indexNPMPackument(body []byte) (*npmPackument, error) {
if !json.Valid(body) {
return nil, errors.New("npm metadata is not valid JSON")
}
doc := &npmPackument{body: body, versionsAt: -1, timeAt: -1, tagsAt: -1}
err := jsonscan.EachMember(body, func(m jsonscan.Member) error {
switch key := m.Key(body); {
case jsonscan.KeyIs(key, "versions"):
doc.versionsAt = len(doc.members)
case jsonscan.KeyIs(key, "time"):
doc.timeAt = len(doc.members)
case jsonscan.KeyIs(key, "dist-tags"):
doc.tagsAt = len(doc.members)
}
doc.members = append(doc.members, m)
return nil
})
return doc, err
}
// value returns the raw value of member i, or nil when i is -1.
func (d *npmPackument) value(i int) []byte {
if i < 0 {
return nil
}
return d.members[i].Value(d.body)
}
// decodeObject decodes member i into metadata[name] and returns it when it is
// an object. The filters edit the returned map in place.
func (d *npmPackument) decodeObject(i int, metadata map[string]any, name string) map[string]any {
raw := d.value(i)
if raw == nil {
return nil
}
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return nil
}
metadata[name] = value
object, _ := value.(map[string]any)
return object
}
// write assembles the document in member order, with writeValue writing each
// member's value. Earlier duplicates of the filtered keys are dropped: they
// would carry unfiltered data, and JSON.parse ignores them anyway.
func (d *npmPackument) write(writeValue func(out *bytes.Buffer, i int) error) ([]byte, error) {
var out bytes.Buffer
out.Grow(len(d.body) + len(d.body)/8)
out.WriteByte('{')
first := true
for i, m := range d.members {
if i != d.versionsAt && i != d.timeAt && i != d.tagsAt {
key := m.Key(d.body)
if jsonscan.KeyIs(key, "versions") || jsonscan.KeyIs(key, "time") || jsonscan.KeyIs(key, "dist-tags") {
continue
}
}
if !first {
out.WriteByte(',')
}
first = false
out.Write(m.Key(d.body))
out.WriteByte(':')
if err := writeValue(&out, i); err != nil {
return nil, err
}
}
out.WriteByte('}')
return out.Bytes(), nil
}
// writeNPMVersions writes the versions object, keeping the versions still in
// keep and pointing each kept version's tarball at this proxy, followed by the
// pushed versions in local.
func (h *transformer) writeNPMVersions(out *bytes.Buffer, packageName string, versionsRaw []byte, keep map[string]any, local []LocalVersion) error {
out.WriteByte('{')
first := true
err := jsonscan.EachMember(versionsRaw, func(m jsonscan.Member) error {
version, err := jsonscan.Key(m.Key(versionsRaw))
if err != nil {
return err
}
if _, ok := keep[version]; !ok {
return nil
}
if !first {
out.WriteByte(',')
}
first = false
out.Write(m.Key(versionsRaw))
out.WriteByte(':')
h.writeNPMVersion(out, packageName, version, m.Value(versionsRaw))
return nil
})
for _, e := range local {
if !first {
out.WriteByte(',')
}
first = false
key, _ := json.Marshal(e.Version)
out.Write(key)
out.WriteByte(':')
out.Write(e.Entry)
}
out.WriteByte('}')
return err
}
// writeNPMVersion writes one version entry with its dist.tarball pointing at
// this proxy. An entry without a string tarball is written unchanged.
func (h *transformer) writeNPMVersion(out *bytes.Buffer, packageName, version string, raw []byte) {
dist, ok, err := jsonscan.FindMember(raw, "dist")
if err != nil || !ok {
out.Write(raw)
return
}
distRaw := dist.Value(raw)
tarball, ok, err := jsonscan.FindMember(distRaw, "tarball")
if err != nil || !ok || distRaw[tarball.ValStart] != '"' {
out.Write(raw)
return
}
var oldTarball string
if err := json.Unmarshal(tarball.Value(distRaw), &oldTarball); err != nil {
out.Write(raw)
return
}
newTarball := TarballURL(h.ProxyURL, packageName, version, oldTarball)
encoded, err := json.Marshal(newTarball)
if err != nil {
out.Write(raw)
return
}
out.Write(raw[:dist.ValStart+tarball.ValStart])
out.Write(encoded)
out.Write(raw[dist.ValStart+tarball.ValEnd:])
if h.OnTarballRewrite != nil {
h.OnTarballRewrite(version, oldTarball, newTarball)
}
}
// applyCooldownFiltering removes versions that are too recently published,
// and updates dist-tags.latest if the current latest was filtered out.
func (h *transformer) applyCooldownFiltering(metadata map[string]any, versions map[string]any, packageName string) {
if h.Cooldown == nil || !h.Cooldown.Enabled() {
return
}
timeMap, _ := metadata["time"].(map[string]any)
if timeMap == nil {
return
}
packagePURL := packageurl.MakeString("npm", packageName, "")
for version := range versions {
publishedStr, ok := timeMap[version].(string)
if !ok {
continue
}
publishedAt, err := time.Parse(time.RFC3339, publishedStr)
if err != nil {
continue
}
if !h.Cooldown.Evaluate("npm", packagePURL, publishedAt, h.EvaluatedAt).Allowed {
if h.OnCooldownFilter != nil {
h.OnCooldownFilter(version, publishedStr)
}
delete(versions, version)
delete(timeMap, version)
}
}
h.updateDistTagsLatest(metadata, versions, timeMap)
}
// updateDistTagsLatest updates the dist-tags.latest field if the current latest
// version was removed by cooldown filtering.
func (h *transformer) updateDistTagsLatest(metadata, versions, timeMap map[string]any) {
distTags, ok := metadata["dist-tags"].(map[string]any)
if !ok {
return
}
latest, ok := distTags["latest"].(string)
if !ok {
return
}
if _, exists := versions[latest]; exists {
return
}
if newLatest := h.findNewestVersion(versions, timeMap); newLatest != "" {
distTags["latest"] = newLatest
}
}
// TarballURL returns the proxy URL that serves the tarball upstream
// lists at tarball for this version.
func TarballURL(proxyURL, packageName, version, tarball string) string {
filename := tarball
if idx := strings.LastIndex(tarball, "/"); idx >= 0 {
filename = tarball[idx+1:]
}
if VersionFromFilename(packageName, filename) != version {
_, shortName, scoped := strings.Cut(packageName, "/")
if !scoped {
shortName = packageName
}
filename = shortName + "-" + version + ".tgz"
}
return fmt.Sprintf("%s/npm/%s/-/%s", proxyURL, url.PathEscape(packageName), filename)
}
// findNewestVersion returns the version string with the most recent timestamp
// from the remaining versions, using the time map.
func (h *transformer) findNewestVersion(versions map[string]any, timeMap map[string]any) string {
if timeMap == nil {
return ""
}
type versionTime struct {
version string
t time.Time
}
var vts []versionTime
for v := range versions {
if ts, ok := timeMap[v].(string); ok {
if t, err := time.Parse(time.RFC3339, ts); err == nil {
vts = append(vts, versionTime{v, t})
}
}
}
if len(vts) == 0 {
return ""
}
sort.Slice(vts, func(i, j int) bool {
return vts[i].t.After(vts[j].t)
})
return vts[0].version
}
// VersionFromFilename extracts a version from an npm tarball filename.
func VersionFromFilename(packageName, filename string) string {
// Remove .tgz extension
if !strings.HasSuffix(filename, ".tgz") {
return ""
}
base := strings.TrimSuffix(filename, ".tgz")
// For scoped packages, the filename uses the short name
shortName := packageName
if _, name, scoped := strings.Cut(packageName, "/"); scoped {
shortName = name
}
// Expected format: {shortName}-{version}
prefix := shortName + "-"
if !strings.HasPrefix(base, prefix) {
return ""
}
return strings.TrimPrefix(base, prefix)
}

View file

@ -0,0 +1,153 @@
package npm
import (
"encoding/json"
"fmt"
"github.com/git-pkgs/cooldown"
"strings"
"testing"
"time"
)
// TestNPMRewriteMetadataKeepsUpstreamBytes checks that only tarball URLs
// change: everything else, including numbers encoding/json would round
// through float64 and characters it would HTML-escape, is copied verbatim.
func TestNPMRewriteMetadataKeepsUpstreamBytes(t *testing.T) {
options := Options{ProxyURL: "http://proxy"}
input := `{"name":"demo","_big":12345678901234567890,"readme":"<b>&</b>",` +
`"versions":{"1.0.0":{"version":"1.0.0","dist":{"shasum":"s","tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz","integrity":"i"}}}}`
out, _, err := Rewrite("demo", []byte(input), options)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(input, "https://registry.npmjs.org/demo/-/demo-1.0.0.tgz", "http://proxy/npm/demo/-/demo-1.0.0.tgz", 1)
if string(out) != want {
t.Errorf("rewrite =\n%s\nwant\n%s", out, want)
}
}
func TestNPMRewriteMetadataUnusualEntries(t *testing.T) {
options := Options{ProxyURL: "http://proxy"}
input := `{
"versions": {
"1.0.0": "not an object",
"2.0.0": {"dist": "no object either"},
"3.0.0": {"dist": {"tarball": 7}},
"4.0.0": {"dist": {"tarball": "https://registry.npmjs.org/demo/-/renamed.tgz"}},
"5.0.0": {"dist": {}}
}
}`
out, _, err := Rewrite("demo", []byte(input), options)
if err != nil {
t.Fatal(err)
}
var doc struct {
Versions map[string]json.RawMessage `json:"versions"`
}
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out)
}
for v, want := range map[string]string{
"1.0.0": `"not an object"`,
"2.0.0": `{"dist": "no object either"}`,
"3.0.0": `{"dist": {"tarball": 7}}`,
"4.0.0": `{"dist": {"tarball": "http://proxy/npm/demo/-/demo-4.0.0.tgz"}}`,
"5.0.0": `{"dist": {}}`,
} {
if got := string(doc.Versions[v]); got != want {
t.Errorf("%s = %s, want %s", v, got, want)
}
}
}
func TestNPMRewriteMetadataInvalidJSON(t *testing.T) {
options := Options{ProxyURL: "http://proxy"}
for _, input := range []string{`{"versions": {}`, `[1]`, `{"versions": {}} trailing`} {
if _, _, err := Rewrite("demo", []byte(input), options); err == nil {
t.Errorf("%s: expected an error", input)
}
}
}
func TestNPMRewriteMetadataNoVersions(t *testing.T) {
options := Options{ProxyURL: "http://proxy"}
input := `{"name":"demo","versions":[]}`
out, _, err := Rewrite("demo", []byte(input), options)
if err != nil || string(out) != input {
t.Errorf("rewrite = %s, %v; want input unchanged", out, err)
}
options.DeniedVersions = map[string]bool{"1.0.0": true}
if _, _, err := Rewrite("demo", []byte(input), options); err == nil {
t.Error("expected an error when a denylisted package has no versions object")
}
}
// TestNPMRewriteMetadataCooldownAndDenylist runs both filters together and
// checks that versions, their time entries and the tags pointing at them are
// all removed, with latest moved to the newest version left.
func TestNPMRewriteMetadataCooldownAndDenylist(t *testing.T) {
now := time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
ts := func(age time.Duration) string { return now.Add(-age).UTC().Format(time.RFC3339) }
options := Options{ProxyURL: "http://proxy", EvaluatedAt: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)}
options.Cooldown = &cooldown.Config{Default: "3d"}
options.DeniedVersions = map[string]bool{"2.0.0": true}
version := func(v string) string {
return fmt.Sprintf(`%q:{"version":%q,"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-%s.tgz"}}`, v, v, v)
}
input := `{"name":"demo",` +
`"dist-tags":{"latest":"3.0.0","beta":"2.0.0","next":"1.0.0"},` +
`"versions":{` + version("1.0.0") + `,` + version("2.0.0") + `,` + version("3.0.0") + `},` +
`"time":{"created":"` + ts(1000*time.Hour) + `","1.0.0":"` + ts(900*time.Hour) + `","2.0.0":"` + ts(800*time.Hour) + `","3.0.0":"` + ts(time.Hour) + `"}}`
out, _, err := Rewrite("demo", []byte(input), options)
if err != nil {
t.Fatal(err)
}
var doc struct {
DistTags map[string]string `json:"dist-tags"`
Versions map[string]json.RawMessage `json:"versions"`
Time map[string]string `json:"time"`
}
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("output is not JSON: %v\n%s", err, out)
}
if len(doc.Versions) != 1 || doc.Versions["1.0.0"] == nil {
t.Errorf("versions = %v, want only 1.0.0", doc.Versions)
}
if _, ok := doc.Time["2.0.0"]; ok {
t.Error("time still lists denylisted 2.0.0")
}
if _, ok := doc.Time["3.0.0"]; ok {
t.Error("time still lists 3.0.0, which is inside cooldown")
}
if _, ok := doc.Time["created"]; !ok {
t.Error("time lost its created entry")
}
want := map[string]string{"latest": "1.0.0", "next": "1.0.0"}
if fmt.Sprint(doc.DistTags) != fmt.Sprint(want) {
t.Errorf("dist-tags = %v, want %v", doc.DistTags, want)
}
if !strings.Contains(string(doc.Versions["1.0.0"]), "http://proxy/npm/demo/-/demo-1.0.0.tgz") {
t.Errorf("1.0.0 tarball not rewritten: %s", doc.Versions["1.0.0"])
}
}
// TestNPMRewriteMetadataDuplicateKeys checks that a repeated top-level key
// cannot smuggle an unfiltered copy of the versions past the filters.
func TestNPMRewriteMetadataDuplicateKeys(t *testing.T) {
options := Options{ProxyURL: "http://proxy", EvaluatedAt: time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)}
options.DeniedVersions = map[string]bool{"2.0.0": true}
input := `{"versions":{"2.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}},` +
`"versions":{"1.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz"}},"2.0.0":{"dist":{"tarball":"https://registry.npmjs.org/demo/-/demo-2.0.0.tgz"}}}}`
out, _, err := Rewrite("demo", []byte(input), options)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), "2.0.0") || strings.Count(string(out), `"versions"`) != 1 {
t.Errorf("rewrite = %s", out)
}
}

View file

@ -0,0 +1,148 @@
package npm
import (
"bytes"
_ "embed"
"encoding/json"
"errors"
"slices"
"strings"
"testing"
"time"
"github.com/git-pkgs/cooldown"
)
//go:embed testdata/packument.json
var packument []byte
func TestRewriteCallbacks(t *testing.T) {
options := Options{ProxyURL: "https://proxy.example", Cooldown: &cooldown.Config{Default: "7d"},
DeniedVersions: map[string]bool{"2.0.0": true}, EvaluatedAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)}
want, kept, err := Rewrite("@example/widgets", packument, options)
if err != nil {
t.Fatal(err)
}
var rewrites, filtered int
options.OnTarballRewrite = func(version, oldURL, newURL string) {
rewrites++
if version != "1.0.0" || oldURL != "https://registry.npmjs.org/@example/widgets/-/widgets-1.0.0.tgz" || newURL != "https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz" {
t.Errorf("rewrite event: %s, %s, %s", version, oldURL, newURL)
}
}
options.OnCooldownFilter = func(version, publishedAt string) {
filtered++
if version != "3.0.0" || publishedAt != "2099-01-01T00:00:00Z" {
t.Errorf("filter event: %s, %s", version, publishedAt)
}
}
out, gotKept, err := Rewrite("@example/widgets", packument, options)
if err != nil || !bytes.Equal(out, want) || !slices.Equal(gotKept, kept) || rewrites != 1 || filtered != 1 {
t.Fatalf("callbacks: rewrites=%d, filtered=%d, kept=%v, error=%v", rewrites, filtered, gotKept, err)
}
}
func TestRewriteHostedOverride(t *testing.T) {
options := Options{
ProxyURL: "https://proxy.example",
Cooldown: &cooldown.Config{Default: "7d"},
DeniedVersions: map[string]bool{"2.0.0": true},
EvaluatedAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
LocalVersions: []LocalVersion{{Version: "3.0.0", PublishedAt: "2030-01-01T00:00:00Z",
Entry: json.RawMessage(`{"version":"3.0.0","dist":{"tarball":"https://proxy.example/npm/@example%2Fwidgets/-/widgets-3.0.0.tgz","integrity":"sha512-local"}}`)}},
}
before := bytes.Clone(packument)
localBefore := bytes.Clone(options.LocalVersions[0].Entry)
out, kept, err := Rewrite("@example/widgets", packument, options)
if err != nil {
t.Fatal(err)
}
slices.Sort(kept)
if !slices.Equal(kept, []string{"1.0.0", "3.0.0"}) {
t.Fatalf("kept = %v", kept)
}
var doc struct {
Versions map[string]json.RawMessage `json:"versions"`
Tags map[string]string `json:"dist-tags"`
Time map[string]string `json:"time"`
}
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
if len(doc.Versions) != 2 || !bytes.Equal(doc.Versions["3.0.0"], localBefore) || doc.Time["3.0.0"] != options.LocalVersions[0].PublishedAt {
t.Fatalf("hosted override lost: %s", out)
}
if len(doc.Tags) != 1 || doc.Tags["latest"] != "1.0.0" || doc.Time["2.0.0"] != "" || doc.Time["created"] == "" {
t.Fatalf("policy output: %s", out)
}
for _, want := range []string{"12345678901234567890", "<b>Widgets & tools</b>", "https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"} {
if !strings.Contains(string(out), want) {
t.Errorf("output lost %q", want)
}
}
if !bytes.Equal(packument, before) || !bytes.Equal(options.LocalVersions[0].Entry, localBefore) || len(options.DeniedVersions) != 1 || !options.DeniedVersions["2.0.0"] {
t.Fatal("rewrite mutated its input")
}
again, _, err := Rewrite("@example/widgets", packument, options)
if err != nil || !bytes.Equal(out, again) {
t.Fatalf("repeat changed output: %v", err)
}
}
func TestVersionPolicyErrors(t *testing.T) {
options := Options{ProxyURL: "https://proxy.example", Cooldown: &cooldown.Config{Default: "7d"},
DeniedVersions: map[string]bool{"2.0.0": true}, EvaluatedAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)}
for _, tc := range []struct {
specifier string
want error
}{
{"1.0.0", nil}, {"latest", nil}, {"2.0.0", ErrVersionDenied}, {"3.0.0", ErrVersionNotFound}, {"beta", ErrVersionNotFound}, {"missing", ErrVersionNotFound},
} {
t.Run(tc.specifier, func(t *testing.T) {
out, err := Version("@example/widgets", tc.specifier, packument, options)
if !errors.Is(err, tc.want) {
t.Fatalf("error = %v, want %v", err, tc.want)
}
if tc.want == nil && (!bytes.Contains(out, []byte(`"version": "1.0.0"`)) || !bytes.Contains(out, []byte("https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz"))) {
t.Fatalf("resolved version: %s", out)
}
})
}
}
func TestRewriteCooldownEvaluationTime(t *testing.T) {
body := []byte(`{"versions":{"1.0.0":{}},"time":{"1.0.0":"2030-01-01T00:00:00Z"}}`)
boundary := time.Date(2030, 1, 8, 0, 0, 0, 0, time.UTC)
options := Options{Cooldown: &cooldown.Config{Default: "7d"}}
for _, tc := range []struct {
at time.Time
count int
}{
{boundary.Add(-time.Nanosecond), 0}, {boundary, 1}, {boundary.Add(time.Second), 1},
} {
options.EvaluatedAt = tc.at
_, kept, err := Rewrite("@example/widgets", body, options)
if err != nil || len(kept) != tc.count {
t.Errorf("at %s: kept %v, error %v", tc.at, kept, err)
}
}
options.Cooldown = &cooldown.Config{Default: "7d", Packages: map[string]string{"pkg:npm/%40example/widgets": "0"}}
options.EvaluatedAt = boundary.Add(-time.Hour)
_, kept, err := Rewrite("@example/widgets", body, options)
if err != nil || len(kept) != 1 {
t.Fatalf("scoped exemption: kept %v, error %v", kept, err)
}
}
func TestRewriteVersionDocument(t *testing.T) {
body := []byte(`{"name":"@example/widgets","version":"1.0.0","dist":{"tarball":"https://cdn.example/abcdef.tgz"}}`)
options := Options{ProxyURL: "https://proxy.example"}
out, kept, err := Rewrite("@example/widgets", body, options)
if err != nil || !slices.Equal(kept, []string{"1.0.0"}) || !bytes.Contains(out, []byte("https://proxy.example/npm/@example%2Fwidgets/-/widgets-1.0.0.tgz")) {
t.Fatalf("rewrite: %s, %v, %v", out, kept, err)
}
options.DeniedVersions = map[string]bool{"1.0.0": true}
if _, _, err := Rewrite("@example/widgets", body, options); !errors.Is(err, ErrVersionDenied) {
t.Fatalf("denied version: %v", err)
}
}

View file

@ -0,0 +1,12 @@
{
"name": "@example/widgets",
"dist-tags": {"latest": "3.0.0", "beta": "2.0.0"},
"versions": {
"1.0.0": {"name": "@example/widgets", "version": "1.0.0", "dependencies": {"left-pad": "^1.3.0"}, "dist": {"integrity": "sha512-example", "tarball": "https://registry.npmjs.org/@example/widgets/-/widgets-1.0.0.tgz"}},
"2.0.0": {"name": "@example/widgets", "version": "2.0.0", "dist": {"tarball": "https://registry.npmjs.org/@example/widgets/-/widgets-2.0.0.tgz"}},
"3.0.0": {"name": "@example/widgets", "version": "3.0.0", "dist": {"tarball": "https://cdn.example/abcdef.tgz"}}
},
"time": {"created": "2020-01-01T00:00:00Z", "1.0.0": "2020-01-01T00:00:00Z", "2.0.0": "2021-01-01T00:00:00Z", "3.0.0": "2099-01-01T00:00:00Z"},
"readme": "<b>Widgets & tools</b>",
"downloads": 12345678901234567890
}