2026-10-25 22:19:24 +01:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-25 22:19:37 +01:00
|
|
|
|
|
|
|
|
func TestAbbreviated(t *testing.T) {
|
|
|
|
|
for body, want := range map[string]bool{
|
|
|
|
|
`{"name":"demo","modified":"2024-01-01T00:00:00Z","versions":{}}`: true,
|
|
|
|
|
`{"name":"demo","modified":"2024-01-01T00:00:00Z","time":{},"versions":{}}`: false,
|
|
|
|
|
`{"name":"demo","versions":{}}`: false,
|
|
|
|
|
`["modified"]`: false,
|
|
|
|
|
`{"modified":`: false,
|
|
|
|
|
} {
|
|
|
|
|
if got := Abbreviated([]byte(body)); got != want {
|
|
|
|
|
t.Errorf("Abbreviated(%s) = %v, want %v", body, got, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-27 16:16:11 +01:00
|
|
|
|
|
|
|
|
// TestNPMRewriteAttestationsURL checks that dist.attestations.url points at
|
|
|
|
|
// the proxy whichever order dist lists its members in, and that nothing else
|
|
|
|
|
// in dist changes.
|
|
|
|
|
func TestNPMRewriteAttestationsURL(t *testing.T) {
|
|
|
|
|
options := Options{ProxyURL: "http://proxy"}
|
|
|
|
|
tests := []struct{ name, version, dist, want string }{
|
|
|
|
|
{"demo", "1.0.0",
|
|
|
|
|
`{"tarball":"https://registry.npmjs.org/demo/-/demo-1.0.0.tgz","attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/demo@1.0.0","provenance":{"predicateType":"p"}}}`,
|
|
|
|
|
`{"tarball":"http://proxy/npm/demo/-/demo-1.0.0.tgz","attestations":{"url":"http://proxy/npm/-/npm/v1/attestations/demo@1.0.0","provenance":{"predicateType":"p"}}}`},
|
|
|
|
|
{"@scope/demo", "2.0.0-rc.1",
|
|
|
|
|
`{"attestations":{"provenance":{},"url":"https://registry.npmjs.org/-/npm/v1/attestations/@scope%2fdemo@2.0.0-rc.1"},"tarball":"https://registry.npmjs.org/@scope/demo/-/demo-2.0.0-rc.1.tgz"}`,
|
|
|
|
|
`{"attestations":{"provenance":{},"url":"http://proxy/npm/-/npm/v1/attestations/@scope%2Fdemo@2.0.0-rc.1"},"tarball":"http://proxy/npm/@scope%2Fdemo/-/demo-2.0.0-rc.1.tgz"}`},
|
|
|
|
|
{"demo", "3.0.0",
|
|
|
|
|
`{"attestations":{"url":7}}`,
|
|
|
|
|
`{"attestations":{"url":7}}`},
|
|
|
|
|
}
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
input := `{"versions":{"` + tt.version + `":{"dist":` + tt.dist + `}}}`
|
|
|
|
|
out, _, err := Rewrite(tt.name, []byte(input), options)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
want := `{"versions":{"` + tt.version + `":{"dist":` + tt.want + `}}}`
|
|
|
|
|
if string(out) != want {
|
|
|
|
|
t.Errorf("%s@%s: rewrite =\n%s\nwant\n%s", tt.name, tt.version, out, want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
Close npm, PyPI and pub cooldown gaps in metadata and downloads
npm: a tarball request for a version the cached packument did not list
yet (cached within metadata_ttl before the release) found no publish
time and was let through cooldown, and fetched without integrity. When
cooldown applies and the cached packument lacks the version, fetch it
again and decide on what upstream lists now; if it can't be fetched,
fail as when a publish time can't be read.
npm: cooldown only moved dist-tags.latest. Other tags kept naming
versions in cooldown, while the denylist removes such tags. Cooldown
now removes them too, and drops latest when no version is left, in
both the in-memory and streamed rewrites.
PyPI: the simple JSON rewrite removed filtered files but left their
versions in the PEP 700 versions list. Remove them there too.
pub: latest was never rewritten, so its archive_url pointed at pub.dev,
it kept naming a version in cooldown when no version was left, and the
replacement was the last list entry, which may be a prerelease. Point
latest's archive_url at the proxy, replace a held back latest with the
newest stable version left (or the newest prerelease), and drop it
when nothing is left.
2026-11-08 08:00:14 +01:00
|
|
|
|
|
|
|
|
// TestNPMRewriteCooldownDropsTags checks that cooldown, like the denylist,
|
|
|
|
|
// drops a tag other than latest that names a version it withholds, in both
|
|
|
|
|
// the in-memory and the streamed rewrite.
|
|
|
|
|
func TestNPMRewriteCooldownDropsTags(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: now, Cooldown: &cooldown.Config{Default: "3d"}}
|
|
|
|
|
input := `{"name":"demo",` +
|
|
|
|
|
`"dist-tags":{"latest":"3.0.0","next":"3.0.0","legacy":"1.0.0"},` +
|
|
|
|
|
`"versions":{"1.0.0":{},"2.0.0":{},"3.0.0":{}},` +
|
|
|
|
|
`"time":{"1.0.0":"` + ts(900*time.Hour) + `","2.0.0":"` + ts(800*time.Hour) + `","3.0.0":"` + ts(time.Hour) + `"}}`
|
|
|
|
|
|
|
|
|
|
rewrite := map[string]func() ([]byte, []string, error){
|
|
|
|
|
"memory": func() ([]byte, []string, error) { return Rewrite("demo", []byte(input), options) },
|
|
|
|
|
"stream": func() ([]byte, []string, error) { return rewriteStream("demo", []byte(input), options) },
|
|
|
|
|
}
|
|
|
|
|
for name, run := range rewrite {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
out, _, err := run()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var doc struct {
|
|
|
|
|
DistTags map[string]string `json:"dist-tags"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(out, &doc); err != nil {
|
|
|
|
|
t.Fatalf("output is not JSON: %v\n%s", err, out)
|
|
|
|
|
}
|
|
|
|
|
want := map[string]string{"latest": "2.0.0", "legacy": "1.0.0"}
|
|
|
|
|
if fmt.Sprint(doc.DistTags) != fmt.Sprint(want) {
|
|
|
|
|
t.Errorf("dist-tags = %v, want %v", doc.DistTags, want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|