mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
fix: npm Dependency Cooldown Select Stable Version (#291)
* fix: npm Dependency Cooldown Select Stable Version * fix: Code review fixes
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
package interceptors
|
package interceptors
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
packagev1 "buf.build/gen/go/safedep/api/protocolbuffers/go/safedep/messages/package/v1"
|
||||||
|
"github.com/Masterminds/semver"
|
||||||
"github.com/safedep/pmg/internal/audit"
|
"github.com/safedep/pmg/internal/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -70,17 +72,51 @@ func recordCooldownStats(statsCollector *AnalysisStatsCollector, ecosystem packa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cooldownLatestEligibleVersion returns the most recently published version not in tooNew.
|
// cooldownHighestStableVersion returns the highest stable (non-prerelease) version
|
||||||
func cooldownLatestEligibleVersion(dates map[string]time.Time, tooNew map[string]bool) string {
|
// among candidates that does not exceed upperBound, ordered by semver. This mirrors
|
||||||
|
// what npm treats as the "latest" dist-tag.
|
||||||
|
//
|
||||||
|
// Prerelease versions are excluded — semver classifies both alpha builds
|
||||||
|
// (e.g. 1.2.0-alpha.1) and platform-specific builds (e.g. 1.2.0-win32-arm64) as
|
||||||
|
// prereleases, so neither can be promoted to latest. Unparseable versions are skipped.
|
||||||
|
//
|
||||||
|
// upperBound is the version dist-tags.latest currently points to. Bounding by it keeps
|
||||||
|
// a repaired latest on the lineage the maintainer marked as latest, rather than
|
||||||
|
// promoting a higher major/minor published under a different channel (e.g. `next`).
|
||||||
|
// An empty or unparseable upperBound applies no upper bound.
|
||||||
|
func cooldownHighestStableVersion(candidates []string, upperBound string) string {
|
||||||
|
var bound *semver.Version
|
||||||
|
if upperBound != "" {
|
||||||
|
if b, err := semver.NewVersion(upperBound); err == nil {
|
||||||
|
// If latest itself points to a prerelease/platform build (e.g.
|
||||||
|
// 1.0.0-win32-arm64), bound to its base release. The bound represents a
|
||||||
|
// release line, and semver ranks 1.0.0 > 1.0.0-win32-arm64, so without
|
||||||
|
// this the stable counterpart on the same line would be wrongly excluded.
|
||||||
|
if b.Prerelease() != "" {
|
||||||
|
if base, err := semver.NewVersion(fmt.Sprintf("%d.%d.%d", b.Major(), b.Minor(), b.Patch())); err == nil {
|
||||||
|
b = base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bound = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var latest string
|
var latest string
|
||||||
var latestTime time.Time
|
var latestVer *semver.Version
|
||||||
for version, publishDate := range dates {
|
for _, version := range candidates {
|
||||||
if tooNew[version] {
|
ver, err := semver.NewVersion(version)
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if publishDate.After(latestTime) {
|
if ver.Prerelease() != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if bound != nil && ver.GreaterThan(bound) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if latestVer == nil || ver.GreaterThan(latestVer) {
|
||||||
latest = version
|
latest = version
|
||||||
latestTime = publishDate
|
latestVer = ver
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return latest
|
return latest
|
||||||
|
|||||||
@@ -131,34 +131,72 @@ func TestCooldownOldestVersion(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCooldownLatestEligibleVersion(t *testing.T) {
|
func TestCooldownHighestStableVersion(t *testing.T) {
|
||||||
now := time.Now()
|
tests := []struct {
|
||||||
day := 24 * time.Hour
|
name string
|
||||||
|
candidates []string
|
||||||
|
upperBound string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "highest stable by semver, not lexical",
|
||||||
|
candidates: []string{"0.9.0", "0.10.0", "0.2.0"},
|
||||||
|
want: "0.10.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "excludes prerelease and platform builds",
|
||||||
|
candidates: []string{"0.132.0", "0.132.5-win32-arm64", "0.133.0-alpha.3", "0.131.0"},
|
||||||
|
want: "0.132.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no stable version returns empty",
|
||||||
|
candidates: []string{"1.0.0-alpha.1", "1.0.0-win32-arm64"},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable versions skipped",
|
||||||
|
candidates: []string{"latest", "not-a-version", "1.2.3"},
|
||||||
|
want: "1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single stable",
|
||||||
|
candidates: []string{"2.0.0"},
|
||||||
|
want: "2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
candidates: []string{},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upper bound excludes higher major from another channel",
|
||||||
|
candidates: []string{"1.4.0", "2.0.0"},
|
||||||
|
upperBound: "1.5.0",
|
||||||
|
want: "1.4.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upper bound allows versions at or below it",
|
||||||
|
candidates: []string{"1.4.0", "1.5.0", "2.0.0"},
|
||||||
|
upperBound: "1.5.0",
|
||||||
|
want: "1.5.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable upper bound applies no bound",
|
||||||
|
candidates: []string{"1.4.0", "2.0.0"},
|
||||||
|
upperBound: "not-a-version",
|
||||||
|
want: "2.0.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "prerelease upper bound does not exclude its stable counterpart",
|
||||||
|
candidates: []string{"1.0.0", "0.9.0"},
|
||||||
|
upperBound: "1.0.0-win32-arm64",
|
||||||
|
want: "1.0.0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("returns most recently published non-blocked version", func(t *testing.T) {
|
for _, tt := range tests {
|
||||||
dates := map[string]time.Time{
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
"1.0.0": now.Add(-30 * day),
|
assert.Equal(t, tt.want, cooldownHighestStableVersion(tt.candidates, tt.upperBound))
|
||||||
"2.0.0": now.Add(-10 * day),
|
})
|
||||||
"3.0.0": now.Add(-1 * day),
|
}
|
||||||
}
|
|
||||||
tooNew := map[string]bool{"3.0.0": true}
|
|
||||||
ver := cooldownLatestEligibleVersion(dates, tooNew)
|
|
||||||
assert.Equal(t, "2.0.0", ver)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("all versions blocked returns empty string", func(t *testing.T) {
|
|
||||||
dates := map[string]time.Time{"1.0.0": now, "2.0.0": now.Add(-1 * day)}
|
|
||||||
tooNew := map[string]bool{"1.0.0": true, "2.0.0": true}
|
|
||||||
ver := cooldownLatestEligibleVersion(dates, tooNew)
|
|
||||||
assert.Empty(t, ver)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty tooNew returns latest version", func(t *testing.T) {
|
|
||||||
dates := map[string]time.Time{
|
|
||||||
"1.0.0": now.Add(-30 * day),
|
|
||||||
"2.0.0": now.Add(-10 * day),
|
|
||||||
}
|
|
||||||
ver := cooldownLatestEligibleVersion(dates, map[string]bool{})
|
|
||||||
assert.Equal(t, "2.0.0", ver)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ func (h *npmCooldownHandler) stripCooldownVersions(body []byte, dates map[string
|
|||||||
return body, 0, remaining
|
return body, 0, remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// survivingVersions holds the version keys still present in the "versions" object
|
||||||
|
// after stripping. It is nil if the field is missing or unparseable, in which case
|
||||||
|
// dist-tag repair falls back to the publish-date set. When non-nil it bounds the
|
||||||
|
// repair candidates so a repaired dist-tag never points to a version absent from
|
||||||
|
// the packument (e.g. an unpublished version whose "time" entry lingers).
|
||||||
|
var survivingVersions map[string]bool
|
||||||
|
|
||||||
if raw, ok := metadata["versions"]; ok {
|
if raw, ok := metadata["versions"]; ok {
|
||||||
var versions map[string]json.RawMessage
|
var versions map[string]json.RawMessage
|
||||||
if err := json.Unmarshal(raw, &versions); err != nil {
|
if err := json.Unmarshal(raw, &versions); err != nil {
|
||||||
@@ -151,6 +158,10 @@ func (h *npmCooldownHandler) stripCooldownVersions(body []byte, dates map[string
|
|||||||
for v := range tooNew {
|
for v := range tooNew {
|
||||||
delete(versions, v)
|
delete(versions, v)
|
||||||
}
|
}
|
||||||
|
survivingVersions = make(map[string]bool, len(versions))
|
||||||
|
for v := range versions {
|
||||||
|
survivingVersions[v] = true
|
||||||
|
}
|
||||||
if updated, err := json.Marshal(versions); err != nil {
|
if updated, err := json.Marshal(versions); err != nil {
|
||||||
log.Warnf("Cooldown: failed to marshal updated versions: %v", err)
|
log.Warnf("Cooldown: failed to marshal updated versions: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -181,17 +192,48 @@ func (h *npmCooldownHandler) stripCooldownVersions(body []byte, dates map[string
|
|||||||
log.Warnf("Cooldown: failed to unmarshal dist-tags field: %v", err)
|
log.Warnf("Cooldown: failed to unmarshal dist-tags field: %v", err)
|
||||||
} else {
|
} else {
|
||||||
changed := false
|
changed := false
|
||||||
for tag, version := range distTags {
|
|
||||||
if tooNew[version] {
|
// Repair the latest tag only when it points at a stripped version.
|
||||||
latest := cooldownLatestEligibleVersion(dates, tooNew)
|
// The eligible-version scan and semver parsing are deferred to this
|
||||||
if latest != "" {
|
// branch so an unaffected latest tag costs nothing.
|
||||||
distTags[tag] = latest
|
if latest, ok := distTags["latest"]; ok && tooNew[latest] {
|
||||||
} else {
|
eligible := make([]string, 0, len(dates))
|
||||||
delete(distTags, tag)
|
for v := range dates {
|
||||||
|
if tooNew[v] {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
if survivingVersions != nil && !survivingVersions[v] {
|
||||||
|
continue // not in the packument's versions — would dangle
|
||||||
|
}
|
||||||
|
eligible = append(eligible, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
if latestStable := cooldownHighestStableVersion(eligible, latest); latestStable != "" {
|
||||||
|
// Repair latest to the highest stable eligible version so
|
||||||
|
// `npm install <pkg>` resolves a real release — never a
|
||||||
|
// more-recently-published prerelease or platform-specific
|
||||||
|
// build (see #275).
|
||||||
|
log.Infof("Cooldown: repaired dist-tag latest %s -> %s for stripped version", latest, latestStable)
|
||||||
|
distTags["latest"] = latestStable
|
||||||
|
} else {
|
||||||
|
// No stable version survives — drop the tag so npm fails
|
||||||
|
// cleanly instead of mis-resolving.
|
||||||
|
log.Infof("Cooldown: removed dist-tag latest (was %s, no eligible stable version remains)", latest)
|
||||||
|
delete(distTags, "latest")
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop any non-latest tag (beta, next, platform tags) whose target was
|
||||||
|
// stripped: an explicit `pkg@<tag>` request for a version in cooldown
|
||||||
|
// should fail cleanly, not resolve to an unrelated version.
|
||||||
|
for tag, version := range distTags {
|
||||||
|
if tag != "latest" && tooNew[version] {
|
||||||
|
delete(distTags, tag)
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
if updated, err := json.Marshal(distTags); err != nil {
|
if updated, err := json.Marshal(distTags); err != nil {
|
||||||
log.Warnf("Cooldown: failed to marshal updated dist-tags: %v", err)
|
log.Warnf("Cooldown: failed to marshal updated dist-tags: %v", err)
|
||||||
|
|||||||
@@ -198,13 +198,16 @@ func TestStripCooldownVersions_MixedVersions(t *testing.T) {
|
|||||||
|
|
||||||
var resultDistTags map[string]string
|
var resultDistTags map[string]string
|
||||||
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
// latest should be updated to an older eligible version
|
// latest should be repaired to the highest stable eligible version
|
||||||
assert.NotEqual(t, "1.0.2", resultDistTags["latest"])
|
assert.Equal(t, "1.0.1", resultDistTags["latest"])
|
||||||
|
|
||||||
var resultTime map[string]string
|
var resultTime map[string]string
|
||||||
require.NoError(t, json.Unmarshal(result["time"], &resultTime))
|
require.NoError(t, json.Unmarshal(result["time"], &resultTime))
|
||||||
assert.Contains(t, resultTime, "created")
|
assert.Contains(t, resultTime, "created")
|
||||||
assert.Contains(t, resultTime, "modified")
|
assert.Contains(t, resultTime, "modified")
|
||||||
|
assert.NotContains(t, resultTime, "1.0.2")
|
||||||
|
assert.Contains(t, resultTime, "1.0.0")
|
||||||
|
assert.Contains(t, resultTime, "1.0.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStripCooldownVersions_AllVersionsTooNew(t *testing.T) {
|
func TestStripCooldownVersions_AllVersionsTooNew(t *testing.T) {
|
||||||
@@ -279,6 +282,134 @@ func TestStripCooldownVersions_MalformedJSON(t *testing.T) {
|
|||||||
assert.Equal(t, body, newBody)
|
assert.Equal(t, body, newBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regression for #275: when the stable version that dist-tags.latest points to is
|
||||||
|
// stripped, latest must be repaired to the highest *stable* eligible version — never
|
||||||
|
// a more-recently-published prerelease or platform-specific build (e.g. -win32-arm64).
|
||||||
|
func TestStripCooldownVersions_LatestRepairedToStableNotPlatform(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
day := 24 * time.Hour
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"0.131.0": now.Add(-40 * day), // old stable
|
||||||
|
"0.132.0": now.Add(-30 * day), // old stable — expected latest after repair
|
||||||
|
"0.132.5-win32-arm64": now.Add(-6 * day), // eligible platform build, newer than 0.132.0
|
||||||
|
"0.133.0": now.Add(-1 * day), // too new stable (current latest)
|
||||||
|
"0.133.0-win32-arm64": now.Add(-1 * day), // too new platform build
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "0.133.0"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
assert.Equal(t, "0.132.0", resultDistTags["latest"],
|
||||||
|
"latest must be the highest stable eligible version, not a platform/prerelease build")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-latest dist-tags whose target is stripped should be removed, not rewritten to
|
||||||
|
// an unrelated version.
|
||||||
|
func TestStripCooldownVersions_NonLatestTagRemovedWhenStripped(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
day := 24 * time.Hour
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.0.0": now.Add(-30 * day), // eligible stable
|
||||||
|
"2.0.0-beta.1": now.Add(-1 * day), // too new prerelease
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.0.0", "next": "2.0.0-beta.1"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
assert.Equal(t, "1.0.0", resultDistTags["latest"], "eligible latest tag should be untouched")
|
||||||
|
assert.NotContains(t, resultDistTags, "next", "stripped non-latest tag should be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A repaired latest must point to a version that still exists in the "versions"
|
||||||
|
// object. A version present only in "time" (e.g. an unpublished version whose
|
||||||
|
// timestamp lingers) must not be promoted to latest, or npm would get a dangling tag.
|
||||||
|
func TestStripCooldownVersions_LatestRepairSkipsVersionsMissingFromPackument(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
old := time.Now().Add(-30 * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
tooNew := time.Now().Add(-1 * 24 * time.Hour).Format(time.RFC3339)
|
||||||
|
|
||||||
|
// "9.9.9" appears in time but NOT in versions; "2.0.0" (latest) is in cooldown.
|
||||||
|
body := []byte(`{
|
||||||
|
"name": "testpkg",
|
||||||
|
"dist-tags": {"latest": "2.0.0"},
|
||||||
|
"versions": {
|
||||||
|
"1.0.0": {"version": "1.0.0"},
|
||||||
|
"1.0.1": {"version": "1.0.1"},
|
||||||
|
"2.0.0": {"version": "2.0.0"}
|
||||||
|
},
|
||||||
|
"time": {
|
||||||
|
"1.0.0": "` + old + `",
|
||||||
|
"1.0.1": "` + old + `",
|
||||||
|
"9.9.9": "` + old + `",
|
||||||
|
"2.0.0": "` + tooNew + `"
|
||||||
|
}
|
||||||
|
}`)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
assert.Equal(t, "1.0.1", resultDistTags["latest"],
|
||||||
|
"latest must come from versions present in the packument, not a time-only entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repairing latest must respect the maintainer's dist-tag lineage: when latest is
|
||||||
|
// pinned to an older line while a higher stable major lives under another channel
|
||||||
|
// (e.g. next), stripping the fresh latest must fall back within the blessed line,
|
||||||
|
// not promote the unrelated higher major.
|
||||||
|
func TestStripCooldownVersions_LatestRepairStaysWithinBlessedLineage(t *testing.T) {
|
||||||
|
handler := newNpmCooldownHandler(nil)
|
||||||
|
now := time.Now()
|
||||||
|
day := 24 * time.Hour
|
||||||
|
versions := map[string]time.Time{
|
||||||
|
"1.4.0": now.Add(-40 * day), // eligible — previous blessed release
|
||||||
|
"1.5.0": now.Add(-1 * day), // fresh — current latest, stripped
|
||||||
|
"2.0.0": now.Add(-30 * day), // eligible higher major, published under `next`
|
||||||
|
}
|
||||||
|
distTags := map[string]string{"latest": "1.5.0", "next": "2.0.0"}
|
||||||
|
body := buildTestPackument(versions, distTags)
|
||||||
|
|
||||||
|
dates, err := handler.parseMetadataTime(body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
newBody, _, _ := handler.stripCooldownVersions(body, dates, 5)
|
||||||
|
|
||||||
|
var result map[string]json.RawMessage
|
||||||
|
require.NoError(t, json.Unmarshal(newBody, &result))
|
||||||
|
|
||||||
|
var resultDistTags map[string]string
|
||||||
|
require.NoError(t, json.Unmarshal(result["dist-tags"], &resultDistTags))
|
||||||
|
assert.Equal(t, "1.4.0", resultDistTags["latest"],
|
||||||
|
"latest must stay within the lineage it was pinned to, not jump to a higher major")
|
||||||
|
assert.Equal(t, "2.0.0", resultDistTags["next"], "eligible non-latest tag should be untouched")
|
||||||
|
}
|
||||||
|
|
||||||
func makeTestRequestContext(rawURL string) *proxy.RequestContext {
|
func makeTestRequestContext(rawURL string) *proxy.RequestContext {
|
||||||
u := mustParseURL(rawURL)
|
u := mustParseURL(rawURL)
|
||||||
return &proxy.RequestContext{
|
return &proxy.RequestContext{
|
||||||
|
|||||||
Reference in New Issue
Block a user