Skip to content

Commit 3407fc1

Browse files
committed
fix(npm): handle boolean-form deprecated field
The npm packument spec defines a version's `deprecated` field as a string carrying the deprecation message, but some packuments emit a boolean instead (false meaning "not deprecated", true meaning deprecated with no message). The struct typed it as `string`, so unmarshalling failed with "cannot unmarshal bool into Go struct field versionInfo.versions.deprecated of type string" and FetchVersions returned an error for the whole package (e.g. react, which has 5 versions with `deprecated: false`). Introduce a `deprecatedField` string type with a custom UnmarshalJSON that accepts a string, boolean, or null and normalizes them to the string form the rest of the code relies on. Strings are kept verbatim; `true` maps to "true" (deprecated); `false` and null map to "" (not deprecated). This preserves the existing non-empty check that drives StatusDeprecated, so boolean-false versions stay active while string/true versions stay deprecated. Adds TestFetchVersions_DeprecatedShapes covering absent, string-message, boolean-false, and boolean-true forms.
1 parent 94cb7f3 commit 3407fc1

2 files changed

Lines changed: 121 additions & 1 deletion

File tree

internal/npm/npm.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
package npm
33

44
import (
5+
"bytes"
56
"context"
7+
"encoding/json"
68
"fmt"
79
"net/url"
810
"strings"
@@ -71,14 +73,52 @@ type versionInfo struct {
7173
Dependencies map[string]string `json:"dependencies"`
7274
DevDeps map[string]string `json:"devDependencies"`
7375
OptionalDeps map[string]string `json:"optionalDependencies"`
74-
Deprecated string `json:"deprecated"`
76+
Deprecated deprecatedField `json:"deprecated"`
7577
Dist distInfo `json:"dist"`
7678
Maintainers []maintainerInfo `json:"maintainers"`
7779
NpmUser map[string]interface{} `json:"_npmUser"`
7880
Engines map[string]string `json:"engines"`
7981
Funding interface{} `json:"funding"`
8082
}
8183

84+
// deprecatedField is the npm version "deprecated" field, which the packument
85+
// spec defines as a string carrying the deprecation message. In practice some
86+
// packuments emit a boolean instead (false meaning "not deprecated", true
87+
// meaning deprecated with no message), which the registry historically
88+
// accepted. UnmarshalJSON normalizes both shapes to the string form the rest
89+
// of the code relies on: any non-empty value marks the version deprecated.
90+
type deprecatedField string
91+
92+
// UnmarshalJSON accepts a string (the deprecation message), a boolean, or
93+
// null/empty. Strings are kept verbatim; booleans map to "true" (deprecated)
94+
// or "" (not deprecated) so the non-empty check driving StatusDeprecated keeps
95+
// working.
96+
func (d *deprecatedField) UnmarshalJSON(data []byte) error {
97+
trimmed := bytes.TrimSpace(data)
98+
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
99+
*d = ""
100+
return nil
101+
}
102+
if trimmed[0] == '"' {
103+
var s string
104+
if err := json.Unmarshal(trimmed, &s); err != nil {
105+
return err
106+
}
107+
*d = deprecatedField(s)
108+
return nil
109+
}
110+
var b bool
111+
if err := json.Unmarshal(trimmed, &b); err != nil {
112+
return err
113+
}
114+
if b {
115+
*d = "true"
116+
return nil
117+
}
118+
*d = ""
119+
return nil
120+
}
121+
82122
type distInfo struct {
83123
Shasum string `json:"shasum"`
84124
Tarball string `json:"tarball"`

internal/npm/npm_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,86 @@ func TestFetchVersions_NoProvenance(t *testing.T) {
162162
}
163163
}
164164

165+
// TestFetchVersions_DeprecatedShapes verifies that the "deprecated" field is
166+
// handled across the shapes npm packuments emit: absent/null, a string
167+
// message, and the legacy boolean form (false == not deprecated,
168+
// true == deprecated with no message). String and true values must mark the
169+
// version StatusDeprecated; absent/null/false must leave it active.
170+
func TestFetchVersions_DeprecatedShapes(t *testing.T) {
171+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
172+
resp := map[string]interface{}{
173+
"_id": "shapes",
174+
"name": "shapes",
175+
"dist-tags": map[string]string{"latest": "1.3.0"},
176+
"versions": map[string]interface{}{
177+
"1.0.0": map[string]interface{}{ // absent
178+
"name": "shapes", "version": "1.0.0",
179+
"dist": map[string]interface{}{"integrity": "sha512-a"},
180+
},
181+
"1.1.0": map[string]interface{}{ // string message
182+
"name": "shapes", "version": "1.1.0",
183+
"deprecated": "use v2 instead",
184+
"dist": map[string]interface{}{"integrity": "sha512-b"},
185+
},
186+
"1.2.0": map[string]interface{}{ // boolean false
187+
"name": "shapes", "version": "1.2.0",
188+
"deprecated": false,
189+
"dist": map[string]interface{}{"integrity": "sha512-c"},
190+
},
191+
"1.3.0": map[string]interface{}{ // boolean true
192+
"name": "shapes", "version": "1.3.0",
193+
"deprecated": true,
194+
"dist": map[string]interface{}{"integrity": "sha512-d"},
195+
},
196+
},
197+
"time": map[string]string{
198+
"1.0.0": "2020-01-01T00:00:00.000Z",
199+
"1.1.0": "2020-02-01T00:00:00.000Z",
200+
"1.2.0": "2020-03-01T00:00:00.000Z",
201+
"1.3.0": "2020-04-01T00:00:00.000Z",
202+
},
203+
}
204+
_ = json.NewEncoder(w).Encode(resp)
205+
}))
206+
defer server.Close()
207+
208+
reg := New(server.URL, core.DefaultClient())
209+
versions, err := reg.FetchVersions(context.Background(), "shapes")
210+
if err != nil {
211+
t.Fatalf("FetchVersions failed: %v", err)
212+
}
213+
214+
wantStatus := map[string]core.VersionStatus{
215+
"1.0.0": core.StatusNone,
216+
"1.1.0": core.StatusDeprecated,
217+
"1.2.0": core.StatusNone, // boolean false is not deprecated
218+
"1.3.0": core.StatusDeprecated,
219+
}
220+
wantDep := map[string]string{
221+
"1.0.0": "",
222+
"1.1.0": "use v2 instead",
223+
"1.2.0": "",
224+
"1.3.0": "true",
225+
}
226+
227+
byNumber := map[string]core.Version{}
228+
for _, v := range versions {
229+
byNumber[v.Number] = v
230+
}
231+
for _, num := range []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0"} {
232+
v, ok := byNumber[num]
233+
if !ok {
234+
t.Fatalf("missing version %s", num)
235+
}
236+
if v.Status != wantStatus[num] {
237+
t.Errorf("%s status = %q, want %q", num, v.Status, wantStatus[num])
238+
}
239+
if got := string(v.Metadata["deprecated"].(deprecatedField)); got != wantDep[num] {
240+
t.Errorf("%s deprecated metadata = %q, want %q", num, got, wantDep[num])
241+
}
242+
}
243+
}
244+
165245
func TestFetchPackageScoped(t *testing.T) {
166246
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167247
// Path can be encoded in different ways depending on the URL library

0 commit comments

Comments
 (0)