Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions internal/npm/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
package npm

import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
Expand Down Expand Up @@ -71,14 +73,52 @@ type versionInfo struct {
Dependencies map[string]string `json:"dependencies"`
DevDeps map[string]string `json:"devDependencies"`
OptionalDeps map[string]string `json:"optionalDependencies"`
Deprecated string `json:"deprecated"`
Deprecated deprecatedField `json:"deprecated"`
Dist distInfo `json:"dist"`
Maintainers []maintainerInfo `json:"maintainers"`
NpmUser map[string]interface{} `json:"_npmUser"`
Engines interface{} `json:"engines"`
Funding interface{} `json:"funding"`
}

// deprecatedField is the npm version "deprecated" field, which the packument
// spec defines as a string carrying the deprecation message. In practice some
// packuments emit a boolean instead (false meaning "not deprecated", true
// meaning deprecated with no message), which the registry historically
// accepted. UnmarshalJSON normalizes both shapes to the string form the rest
// of the code relies on: any non-empty value marks the version deprecated.
type deprecatedField string

// UnmarshalJSON accepts a string (the deprecation message), a boolean, or
// null/empty. Strings are kept verbatim; booleans map to "true" (deprecated)
// or "" (not deprecated) so the non-empty check driving StatusDeprecated keeps
// working.
func (d *deprecatedField) UnmarshalJSON(data []byte) error {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
*d = ""
return nil
}
if trimmed[0] == '"' {
var s string
if err := json.Unmarshal(trimmed, &s); err != nil {
return err
}
*d = deprecatedField(s)
return nil
}
var b bool
if err := json.Unmarshal(trimmed, &b); err != nil {
return err
}
if b {
*d = "true"
return nil
}
*d = ""
return nil
}

type distInfo struct {
Shasum string `json:"shasum"`
Tarball string `json:"tarball"`
Expand Down Expand Up @@ -191,7 +231,7 @@ func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Versi
Integrity: integrity,
Status: status,
Metadata: map[string]any{
"deprecated": v.Deprecated,
"deprecated": string(v.Deprecated),
"dist": v.Dist,
"engines": v.Engines,
"_npmUser": v.NpmUser,
Expand Down
80 changes: 80 additions & 0 deletions internal/npm/npm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,86 @@ func TestFetchVersions_LegacyEnginesArray(t *testing.T) {
}
}

// TestFetchVersions_DeprecatedShapes verifies that the "deprecated" field is
// handled across the shapes npm packuments emit: absent/null, a string
// message, and the legacy boolean form (false == not deprecated,
// true == deprecated with no message). String and true values must mark the
// version StatusDeprecated; absent/null/false must leave it active.
func TestFetchVersions_DeprecatedShapes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := map[string]interface{}{
"_id": "shapes",
"name": "shapes",
"dist-tags": map[string]string{"latest": "1.3.0"},
"versions": map[string]interface{}{
"1.0.0": map[string]interface{}{ // absent
"name": "shapes", "version": "1.0.0",
"dist": map[string]interface{}{"integrity": "sha512-a"},
},
"1.1.0": map[string]interface{}{ // string message
"name": "shapes", "version": "1.1.0",
"deprecated": "use v2 instead",
"dist": map[string]interface{}{"integrity": "sha512-b"},
},
"1.2.0": map[string]interface{}{ // boolean false
"name": "shapes", "version": "1.2.0",
"deprecated": false,
"dist": map[string]interface{}{"integrity": "sha512-c"},
},
"1.3.0": map[string]interface{}{ // boolean true
"name": "shapes", "version": "1.3.0",
"deprecated": true,
"dist": map[string]interface{}{"integrity": "sha512-d"},
},
},
"time": map[string]string{
"1.0.0": "2020-01-01T00:00:00.000Z",
"1.1.0": "2020-02-01T00:00:00.000Z",
"1.2.0": "2020-03-01T00:00:00.000Z",
"1.3.0": "2020-04-01T00:00:00.000Z",
},
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer server.Close()

reg := New(server.URL, core.DefaultClient())
versions, err := reg.FetchVersions(context.Background(), "shapes")
if err != nil {
t.Fatalf("FetchVersions failed: %v", err)
}

wantStatus := map[string]core.VersionStatus{
"1.0.0": core.StatusNone,
"1.1.0": core.StatusDeprecated,
"1.2.0": core.StatusNone, // boolean false is not deprecated
"1.3.0": core.StatusDeprecated,
}
wantDep := map[string]string{
"1.0.0": "",
"1.1.0": "use v2 instead",
"1.2.0": "",
"1.3.0": "true",
}

byNumber := map[string]core.Version{}
for _, v := range versions {
byNumber[v.Number] = v
}
for _, num := range []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0"} {
v, ok := byNumber[num]
if !ok {
t.Fatalf("missing version %s", num)
}
if v.Status != wantStatus[num] {
t.Errorf("%s status = %q, want %q", num, v.Status, wantStatus[num])
}
if v.Metadata["deprecated"] != wantDep[num] {
t.Errorf("%s deprecated metadata = %q, want %q", num, v.Metadata["deprecated"], wantDep[num])
}
}
}

func TestFetchPackageScoped(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Path can be encoded in different ways depending on the URL library
Expand Down