-
Notifications
You must be signed in to change notification settings - Fork 327
/
npmrc.go
296 lines (257 loc) · 8.84 KB
/
npmrc.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
package datasource
import (
"bytes"
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/google/osv-scanner/internal/cachedregexp"
"gopkg.in/ini.v1"
)
type npmrcConfig struct {
*ini.Section
}
func loadNpmrc(workdir string) (npmrcConfig, error) {
// Find & parse the 4 npmrc files (builtin, global, user, project) + values set in environment variables
// https://docs.npmjs.com/cli/v10/configuring-npm/npmrc
// https://docs.npmjs.com/cli/v10/using-npm/config
// project npmrc is always in ./.npmrc
projectFile, _ := filepath.Abs(filepath.Join(workdir, ".npmrc"))
// TODO: Pass in environment variables so we can sandbox tests
builtinFile := builtinNpmrc()
envVarOpts, _ := envVarNpmrc()
opts := ini.LoadOptions{
Loose: true, // ignore missing files
KeyValueDelimiters: "=", // default delimiters are "=:", but npmrc uses : in some keys
}
// Make use of data overwriting to load the correct values
fullNpmrc, err := ini.LoadSources(opts, builtinFile, projectFile, envVarOpts)
if err != nil {
return npmrcConfig{}, err
}
// user npmrc is either set as userconfig, or ${HOME}/.npmrc
// though userconfig cannot be set in the user or global npmrcs
var userFile string
switch {
case fullNpmrc.Section("").HasKey("userconfig"):
userFile = os.ExpandEnv(fullNpmrc.Section("").Key("userconfig").String())
// TODO: npm config replaces only ${VAR}, not $VAR
// and if VAR is unset, it will leave the string as "${VAR}"
default:
homeDir, err := os.UserHomeDir()
if err == nil { // only set userFile if homeDir exists
userFile = filepath.Join(homeDir, ".npmrc")
}
}
// reload the npmrc files with the user file included
fullNpmrc, err = ini.LoadSources(opts, builtinFile, userFile, projectFile, envVarOpts)
if err != nil {
return npmrcConfig{}, err
}
var globalFile string
// global npmrc is either set as globalconfig, prefix/etc/npmrc, ${PREFIX}/etc/npmrc
// cannot be set within the global npmrc itself
switch {
case fullNpmrc.Section("").HasKey("globalconfig"):
globalFile = os.ExpandEnv(fullNpmrc.Section("").Key("globalconfig").String())
// TODO: Windows
case fullNpmrc.Section("").HasKey("prefix"):
prefix := os.ExpandEnv(fullNpmrc.Section("").Key("prefix").String())
globalFile, _ = filepath.Abs(filepath.Join(prefix, "etc", "npmrc"))
case os.Getenv("PREFIX") != "":
globalFile, _ = filepath.Abs(filepath.Join(os.Getenv("PREFIX"), "etc", "npmrc"))
default:
globalFile = filepath.Join("/etc", "npmrc") // TODO: what should this be actually?
}
// return final joined config, with correct overriding order
fullNpmrc, err = ini.LoadSources(opts, builtinFile, globalFile, userFile, projectFile, envVarOpts)
if err != nil {
return npmrcConfig{}, err
}
return npmrcConfig{fullNpmrc.Section("")}, nil
}
func envVarNpmrc() ([]byte, error) {
// parse npm config settings that were set in environment variables,
// returns a ini.Load()-able byte array of the values
iniFile := ini.Empty()
// npm config environment variables seem to be case-insensitive, interpreted in lowercase
// get all the matching environment variables and their values
const envPrefix = "npm_config_"
for _, env := range os.Environ() {
split := strings.SplitN(env, "=", 2)
k := strings.ToLower(split[0])
v := split[1]
if s, ok := strings.CutPrefix(k, envPrefix); ok {
if _, err := iniFile.Section("").NewKey(s, v); err != nil {
return nil, err
}
}
}
var buf bytes.Buffer
_, err := iniFile.WriteTo(&buf)
return buf.Bytes(), err
}
func builtinNpmrc() string {
// builtin is always at /path/to/npm/npmrc
npmExec, err := exec.LookPath("npm")
if err != nil {
return ""
}
npmExec, err = filepath.EvalSymlinks(npmExec)
if err != nil {
return ""
}
npmrc := filepath.Join(filepath.Dir(npmExec), "..", "npmrc")
npmrc, err = filepath.Abs(npmrc)
if err != nil {
return ""
}
return npmrc
}
type npmRegistryAuthInfo struct {
authToken string
auth string
username string
password string
// TODO: certfile, keyfile
}
func (authInfo npmRegistryAuthInfo) AddToHeader(header http.Header) {
switch {
case authInfo.authToken != "":
header.Set("Authorization", "Bearer "+authInfo.authToken)
case authInfo.auth != "":
header.Set("Authorization", "Basic "+authInfo.auth)
case authInfo.username != "" && authInfo.password != "":
// auth is base64-encoded "username:password"
// password is stored already base64-encoded
authBytes := []byte(authInfo.username + ":")
b, err := base64.StdEncoding.DecodeString(authInfo.password)
if err != nil {
// TODO: mimic the behaviour of node's Buffer.from(s, 'base64').toString()
// e.g. ignore invalid characters, stop parsing after first '=', just never throw an error
panic(fmt.Sprintf("Unable to decode registry password: %v", err))
}
authBytes = append(authBytes, b...)
auth := base64.StdEncoding.EncodeToString(authBytes)
header.Set("Authorization", "Basic "+auth)
}
}
// Implementation of npm registry auth matching, adapted from npm-registry-fetch
// https://github.com/npm/npm-registry-fetch/blob/237d33b45396caa00add61e0549cf09fbf9deb4f/lib/auth.js
type NpmRegistryAuthOpts map[string]string
var npmAuthFields = [...]string{":_authToken", ":_auth", ":username", ":_password"} // reference of the relevant config key suffixes
func (opts NpmRegistryAuthOpts) getRegAuth(regKey string) (npmRegistryAuthInfo, bool) {
if token, ok := opts[regKey+":_authToken"]; ok {
return npmRegistryAuthInfo{authToken: token}, true
}
if auth, ok := opts[regKey+":_auth"]; ok {
return npmRegistryAuthInfo{auth: auth}, true
}
if user, ok := opts[regKey+":username"]; ok {
if pass, ok := opts[regKey+":_password"]; ok {
return npmRegistryAuthInfo{username: user, password: pass}, true
}
}
// TODO: certfile / keyfile
return npmRegistryAuthInfo{}, false
}
func (opts NpmRegistryAuthOpts) GetAuth(uri string) npmRegistryAuthInfo {
parsed, err := url.Parse(uri)
if err != nil {
return npmRegistryAuthInfo{}
}
regKey := "//" + parsed.Host + parsed.EscapedPath()
for regKey != "//" {
if authInfo, ok := opts.getRegAuth(regKey); ok {
return authInfo
}
// can be either //host/some/path/:_auth or //host/some/path:_auth
// walk up by removing EITHER what's after the slash OR the slash itself
var found bool
if regKey, found = strings.CutSuffix(regKey, "/"); !found {
regKey = regKey[:strings.LastIndex(regKey, "/")+1]
}
}
return npmRegistryAuthInfo{}
}
// urlPathEscapeLower is url.PathEscape but with lowercase letters in hex codes (matching npm's behaviour)
// e.g. "@reg/pkg" -> "@reg%2fpkg"
func urlPathEscapeLower(s string) string {
escaped := url.PathEscape(s)
re := cachedregexp.MustCompile(`%[0-9A-F]{2}`)
return re.ReplaceAllStringFunc(escaped, strings.ToLower)
}
type NpmRegistryConfig struct {
ScopeURLs map[string]string // map of @scope to registry URL
RegOpts NpmRegistryAuthOpts // the full key-value pairs of relevant npmrc config options.
}
func LoadNpmRegistryConfig(workdir string) (NpmRegistryConfig, error) {
npmrc, err := loadNpmrc(workdir)
if err != nil {
return NpmRegistryConfig{}, err
}
return parseNpmRegistryInfo(npmrc), nil
}
// BuildRequest creates the http request to the corresponding npm registry api
// urlComponents should be (package) or (package, version)
func (r NpmRegistryConfig) BuildRequest(ctx context.Context, urlComponents ...string) (*http.Request, error) {
if len(urlComponents) == 0 {
return nil, errors.New("no package specified in npm request")
}
// find the corresponding registryInfo for the package's scope
pkg := urlComponents[0]
scope := ""
if strings.HasPrefix(pkg, "@") {
scope, _, _ = strings.Cut(pkg, "/")
}
baseURL, ok := r.ScopeURLs[scope]
if !ok {
// no specific rules for this scope, use the default scope
baseURL = r.ScopeURLs[""]
}
for i := range urlComponents {
urlComponents[i] = urlPathEscapeLower(urlComponents[i])
}
reqURL, err := url.JoinPath(baseURL, urlComponents...)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
r.RegOpts.GetAuth(reqURL).AddToHeader(req.Header)
return req, nil
}
func parseNpmRegistryInfo(npmrc npmrcConfig) NpmRegistryConfig {
config := NpmRegistryConfig{
ScopeURLs: map[string]string{"": "https://registry.npmjs.org/"}, // set the default registry
RegOpts: make(NpmRegistryAuthOpts),
}
for _, k := range npmrc.Keys() {
name := k.Name()
value := os.ExpandEnv(k.String())
// TODO: npm config replaces only ${VAR}, not $VAR
// and if VAR is unset, it will leave the string as "${VAR}"
if name == "registry" {
config.ScopeURLs[""] = value
continue
}
if scope, ok := strings.CutSuffix(name, ":registry"); ok {
config.ScopeURLs[scope] = value
continue
}
for _, f := range npmAuthFields {
if strings.HasSuffix(name, f) {
config.RegOpts[name] = value
}
}
}
return config
}