Skip to content

Commit 6e6f8e9

Browse files
committed
fix: properly parse non-url encoded file specs
Properly creates file package args that contain characters that need to be url encoded. There is also a refactor/cleanup in here - Removed the magic windows global for testing, fixing the tests to mock process.platform instead. - Moved inline regexes up to where the others are defined - Renamed a few variables to be more correct (i.e. isFilename to isFileType) - Refactored Result to be a proper Class instead of a function w/ prototypes Closes: #193
1 parent d45dabc commit 6e6f8e9

File tree

3 files changed

+220
-111
lines changed

3 files changed

+220
-111
lines changed

lib/npa.js

Lines changed: 168 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
11
'use strict'
2-
module.exports = npa
3-
module.exports.resolve = resolve
4-
module.exports.toPurl = toPurl
5-
module.exports.Result = Result
62

7-
const { URL } = require('url')
3+
const isWindows = process.platform === 'win32'
4+
5+
const { URL } = require('node:url')
6+
const path = isWindows ? require('node:path').win32 : require('node:path')
7+
const { homedir } = require('node:os')
88
const HostedGit = require('hosted-git-info')
99
const semver = require('semver')
10-
const path = global.FAKE_WINDOWS ? require('path').win32 : require('path')
1110
const validatePackageName = require('validate-npm-package-name')
12-
const { homedir } = require('os')
1311
const { log } = require('proc-log')
1412

15-
const isWindows = process.platform === 'win32' || global.FAKE_WINDOWS
1613
const hasSlashes = isWindows ? /\\|[/]/ : /[/]/
1714
const isURL = /^(?:git[+])?[a-z]+:/i
1815
const isGit = /^[^@]+@[^:.]+\.[^:]+:.+$/i
19-
const isFilename = /[.](?:tgz|tar.gz|tar)$/i
16+
const isFileType = /[.](?:tgz|tar.gz|tar)$/i
2017
const isPortNumber = /:[0-9]+(\/|$)/i
18+
const isWindowsFile = /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/
19+
const isPosixFile = /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
20+
const defaultRegistry = 'https://registry.npmjs.org'
2121

2222
function npa (arg, where) {
2323
let name
@@ -31,13 +31,14 @@ function npa (arg, where) {
3131
return npa(arg.raw, where || arg.where)
3232
}
3333
}
34-
const nameEndsAt = arg[0] === '@' ? arg.slice(1).indexOf('@') + 1 : arg.indexOf('@')
34+
const nameEndsAt = arg.indexOf('@', 1) // Skip possible leading @
3535
const namePart = nameEndsAt > 0 ? arg.slice(0, nameEndsAt) : arg
3636
if (isURL.test(arg)) {
3737
spec = arg
3838
} else if (isGit.test(arg)) {
3939
spec = `git+ssh://${arg}`
40-
} else if (namePart[0] !== '@' && (hasSlashes.test(namePart) || isFilename.test(namePart))) {
40+
// eslint-disable-next-line max-len
41+
} else if (!namePart.startsWith('@') && (hasSlashes.test(namePart) || isFileType.test(namePart))) {
4142
spec = arg
4243
} else if (nameEndsAt > 0) {
4344
name = namePart
@@ -54,7 +55,25 @@ function npa (arg, where) {
5455
return resolve(name, spec, where, arg)
5556
}
5657

57-
const isFilespec = isWindows ? /^(?:[.]|~[/]|[/\\]|[a-zA-Z]:)/ : /^(?:[.]|~[/]|[/]|[a-zA-Z]:)/
58+
function isFileSpec (spec) {
59+
if (!spec) {
60+
return false
61+
}
62+
if (spec.toLowerCase().startsWith('file:')) {
63+
return true
64+
}
65+
if (isWindows) {
66+
return isWindowsFile.test(spec)
67+
}
68+
return isPosixFile.test(spec)
69+
}
70+
71+
function isAliasSpec (spec) {
72+
if (!spec) {
73+
return false
74+
}
75+
return spec.toLowerCase().startsWith('npm:')
76+
}
5877

5978
function resolve (name, spec, where, arg) {
6079
const res = new Result({
@@ -65,12 +84,16 @@ function resolve (name, spec, where, arg) {
6584
})
6685

6786
if (name) {
68-
res.setName(name)
87+
res.name = name
6988
}
7089

71-
if (spec && (isFilespec.test(spec) || /^file:/i.test(spec))) {
90+
if (!where) {
91+
where = process.cwd()
92+
}
93+
94+
if (isFileSpec(spec)) {
7295
return fromFile(res, where)
73-
} else if (spec && /^npm:/i.test(spec)) {
96+
} else if (isAliasSpec(spec)) {
7497
return fromAlias(res, where)
7598
}
7699

@@ -82,15 +105,13 @@ function resolve (name, spec, where, arg) {
82105
return fromHostedGit(res, hosted)
83106
} else if (spec && isURL.test(spec)) {
84107
return fromURL(res)
85-
} else if (spec && (hasSlashes.test(spec) || isFilename.test(spec))) {
108+
} else if (spec && (hasSlashes.test(spec) || isFileType.test(spec))) {
86109
return fromFile(res, where)
87110
} else {
88111
return fromRegistry(res)
89112
}
90113
}
91114

92-
const defaultRegistry = 'https://registry.npmjs.org'
93-
94115
function toPurl (arg, reg = defaultRegistry) {
95116
const res = npa(arg)
96117

@@ -128,60 +149,59 @@ function invalidPurlType (type, raw) {
128149
return err
129150
}
130151

131-
function Result (opts) {
132-
this.type = opts.type
133-
this.registry = opts.registry
134-
this.where = opts.where
135-
if (opts.raw == null) {
136-
this.raw = opts.name ? opts.name + '@' + opts.rawSpec : opts.rawSpec
137-
} else {
138-
this.raw = opts.raw
152+
class Result {
153+
constructor (opts) {
154+
this.type = opts.type
155+
this.registry = opts.registry
156+
this.where = opts.where
157+
if (opts.raw == null) {
158+
this.raw = opts.name ? `${opts.name}@${opts.rawSpec}` : opts.rawSpec
159+
} else {
160+
this.raw = opts.raw
161+
}
162+
this.rawSpec = opts.rawSpec || ''
163+
this.saveSpec = opts.saveSpec
164+
this.fetchSpec = opts.fetchSpec
165+
if (opts.name) {
166+
this.setName(opts.name)
167+
}
168+
this.gitRange = opts.gitRange
169+
this.gitCommittish = opts.gitCommittish
170+
this.gitSubdir = opts.gitSubdir
171+
this.hosted = opts.hosted
139172
}
140173

141-
this.name = undefined
142-
this.escapedName = undefined
143-
this.scope = undefined
144-
this.rawSpec = opts.rawSpec || ''
145-
this.saveSpec = opts.saveSpec
146-
this.fetchSpec = opts.fetchSpec
147-
if (opts.name) {
148-
this.setName(opts.name)
149-
}
150-
this.gitRange = opts.gitRange
151-
this.gitCommittish = opts.gitCommittish
152-
this.gitSubdir = opts.gitSubdir
153-
this.hosted = opts.hosted
154-
}
174+
// TODO move this to a getter/setter in a semver major
175+
setName (name) {
176+
const valid = validatePackageName(name)
177+
if (!valid.validForOldPackages) {
178+
throw invalidPackageName(name, valid, this.raw)
179+
}
155180

156-
Result.prototype.setName = function (name) {
157-
const valid = validatePackageName(name)
158-
if (!valid.validForOldPackages) {
159-
throw invalidPackageName(name, valid, this.raw)
181+
this.name = name
182+
this.scope = name[0] === '@' ? name.slice(0, name.indexOf('/')) : undefined
183+
// scoped packages in couch must have slash url-encoded, e.g. @foo%2Fbar
184+
this.escapedName = name.replace('/', '%2f')
185+
return this
160186
}
161187

162-
this.name = name
163-
this.scope = name[0] === '@' ? name.slice(0, name.indexOf('/')) : undefined
164-
// scoped packages in couch must have slash url-encoded, e.g. @foo%2Fbar
165-
this.escapedName = name.replace('/', '%2f')
166-
return this
167-
}
168-
169-
Result.prototype.toString = function () {
170-
const full = []
171-
if (this.name != null && this.name !== '') {
172-
full.push(this.name)
173-
}
174-
const spec = this.saveSpec || this.fetchSpec || this.rawSpec
175-
if (spec != null && spec !== '') {
176-
full.push(spec)
188+
toString () {
189+
const full = []
190+
if (this.name != null && this.name !== '') {
191+
full.push(this.name)
192+
}
193+
const spec = this.saveSpec || this.fetchSpec || this.rawSpec
194+
if (spec != null && spec !== '') {
195+
full.push(spec)
196+
}
197+
return full.length ? full.join('@') : this.raw
177198
}
178-
return full.length ? full.join('@') : this.raw
179-
}
180199

181-
Result.prototype.toJSON = function () {
182-
const result = Object.assign({}, this)
183-
delete result.hosted
184-
return result
200+
toJSON () {
201+
const result = Object.assign({}, this)
202+
delete result.hosted
203+
return result
204+
}
185205
}
186206

187207
// sets res.gitCommittish, res.gitRange, and res.gitSubdir
@@ -228,25 +248,89 @@ function setGitAttrs (res, committish) {
228248
}
229249
}
230250

231-
function fromFile (res, where) {
232-
if (!where) {
233-
where = process.cwd()
251+
// Taken from: EncodePathChars and lookup_table in src/node_url.cc
252+
// url.pathToFileURL only returns absolute references. We can't use it to encode paths.
253+
// encodeURI mangles windows paths. We can't use it to encode paths.
254+
// Under the hood, url.pathToFileURL does a limited set of encoding, with an extra windows step, and then calls path.resolve.
255+
// The encoding node does without path.resolve is not available outside of the source, so we are recreating it here.
256+
const encodedPathChars = new Map([
257+
['\0', '%00'],
258+
['\t', '%09'],
259+
['\n', '%0A'],
260+
['\r', '%0D'],
261+
[' ', '%20'],
262+
['"', '%22'],
263+
['#', '%23'],
264+
['%', '%25'],
265+
['?', '%3F'],
266+
['[', '%5B'],
267+
['\\', isWindows ? '/' : '%5C'],
268+
[']', '%5D'],
269+
['^', '%5E'],
270+
['|', '%7C'],
271+
['~', '%7E'],
272+
])
273+
274+
function pathToFileURL (str) {
275+
let result = ''
276+
for (let i = 0; i < str.length; i++) {
277+
result = `${result}${encodedPathChars.get(str[i]) ?? str[i]}`
234278
}
235-
res.type = isFilename.test(res.rawSpec) ? 'file' : 'directory'
279+
return `file:${result}`
280+
}
281+
282+
/* parse file package args:
283+
*
284+
* /posix/path
285+
* ./posix/path
286+
* .dotfile
287+
* .dot/path
288+
* filename
289+
* filename.with.ext
290+
* C:\windows\path
291+
* path/with/no/leading/separator
292+
*
293+
* translates to relative ./path
294+
* - file:{path}
295+
*
296+
* translates to absolute /path
297+
* - file:/{path}
298+
* - file://{path} (this is not RFC compliant, but is supported for backward compatibility)
299+
* - file:///{path}
300+
*
301+
* file: specs are url encoded, bare paths are not
302+
*
303+
*/
304+
function fromFile (res, where) {
305+
res.type = isFileType.test(res.rawSpec) ? 'file' : 'directory'
236306
res.where = where
237307

238-
// always put the '/' on where when resolving urls, or else
239-
// file:foo from /path/to/bar goes to /path/to/foo, when we want
240-
// it to be /path/to/bar/foo
308+
let rawSpec = res.rawSpec
309+
if (!rawSpec.startsWith('file:')) {
310+
rawSpec = pathToFileURL(rawSpec)
311+
}
312+
313+
if (rawSpec.startsWith('file:/')) {
314+
// XXX backwards compatibility lack of compliance with RFC 8089
315+
316+
// turn file://path into file:/path
317+
if (/^file:\/\/[^/]/.test(rawSpec)) {
318+
rawSpec = `file:/${rawSpec.slice(5)}`
319+
}
320+
321+
// turn file:/../path into file:../path
322+
// for 1 or 3 leading slashes (2 is already ruled out from handling file:// explicitly above)
323+
if (/^\/{1,3}\.\.?(\/|$)/.test(rawSpec.slice(5))) {
324+
rawSpec = rawSpec.replace(/^file:\/{1,3}/, 'file:')
325+
}
326+
}
241327

242-
let specUrl
243328
let resolvedUrl
244-
const prefix = (!/^file:/.test(res.rawSpec) ? 'file:' : '')
245-
const rawWithPrefix = prefix + res.rawSpec
246-
let rawNoPrefix = rawWithPrefix.replace(/^file:/, '')
329+
let specUrl
247330
try {
248-
resolvedUrl = new URL(rawWithPrefix, `file://${path.resolve(where)}/`)
249-
specUrl = new URL(rawWithPrefix)
331+
// always put the '/' on "where", or else file:foo from /path/to/bar goes to /path/to/foo, when we want it to be /path/to/bar/foo
332+
resolvedUrl = new URL(rawSpec, `${pathToFileURL(path.resolve(where))}/`)
333+
specUrl = new URL(rawSpec)
250334
} catch (originalError) {
251335
const er = new Error('Invalid file: URL, must comply with RFC 8089')
252336
throw Object.assign(er, {
@@ -257,24 +341,6 @@ function fromFile (res, where) {
257341
})
258342
}
259343

260-
// XXX backwards compatibility lack of compliance with RFC 8089
261-
if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
262-
const rawSpec = res.rawSpec.replace(/^file:\/\//, 'file:///')
263-
resolvedUrl = new URL(rawSpec, `file://${path.resolve(where)}/`)
264-
specUrl = new URL(rawSpec)
265-
rawNoPrefix = rawSpec.replace(/^file:/, '')
266-
}
267-
// turn file:/../foo into file:../foo
268-
// for 1, 2 or 3 leading slashes since we attempted
269-
// in the previous step to make it a file protocol url with a leading slash
270-
if (/^\/{1,3}\.\.?(\/|$)/.test(rawNoPrefix)) {
271-
const rawSpec = res.rawSpec.replace(/^file:\/{1,3}/, 'file:')
272-
resolvedUrl = new URL(rawSpec, `file://${path.resolve(where)}/`)
273-
specUrl = new URL(rawSpec)
274-
rawNoPrefix = rawSpec.replace(/^file:/, '')
275-
}
276-
// XXX end RFC 8089 violation backwards compatibility section
277-
278344
// turn /C:/blah into just C:/blah on windows
279345
let specPath = decodeURIComponent(specUrl.pathname)
280346
let resolvedPath = decodeURIComponent(resolvedUrl.pathname)
@@ -288,7 +354,7 @@ function fromFile (res, where) {
288354
if (/^\/~(\/|$)/.test(specPath)) {
289355
res.saveSpec = `file:${specPath.substr(1)}`
290356
resolvedPath = path.resolve(homedir(), specPath.substr(3))
291-
} else if (!path.isAbsolute(rawNoPrefix)) {
357+
} else if (!path.isAbsolute(rawSpec.slice(5))) {
292358
res.saveSpec = `file:${path.relative(where, resolvedPath)}`
293359
} else {
294360
res.saveSpec = `file:${path.resolve(resolvedPath)}`
@@ -416,3 +482,8 @@ function fromRegistry (res) {
416482
}
417483
return res
418484
}
485+
486+
module.exports = npa
487+
module.exports.resolve = resolve
488+
module.exports.toPurl = toPurl
489+
module.exports.Result = Result

0 commit comments

Comments
 (0)