Skip to content

Commit 92132b3

Browse files
committed
fix: Make file: URLs (mostly) RFC 8909 compliant
The change made in e8c4301 was reasonable, but had the unfortunate side effect of breaking backwards compatibility for urls like 'file://.'. Further investigation showed that we really weren't complying with RFC 8909's specifications regarding file: URLs, and even further investigation than that showed that it'll be a breaking change if we do. This patch isolates the non-8909 compliance bits into a single block that can be disabled with an environment variable and easily removed when we are willing to take on that breaking change. The bug fixed by the commit that broke file://. is still fixed, and we obey RFC8909 otherwise.
1 parent bad5f17 commit 92132b3

File tree

3 files changed

+190
-62
lines changed

3 files changed

+190
-62
lines changed

npa.js

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ module.exports.Result = Result
66
const url = require('url')
77
const HostedGit = require('hosted-git-info')
88
const semver = require('semver')
9-
const path = require('path')
9+
const path = global.FAKE_WINDOWS ? require('path').win32 : require('path')
1010
const validatePackageName = require('validate-npm-package-name')
1111
const { homedir } = require('os')
1212

@@ -151,42 +151,86 @@ function setGitCommittish (res, committish) {
151151
return res
152152
}
153153

154-
const isAbsolutePath = /^[/]|^[A-Za-z]:/
155-
156-
function resolvePath (where, spec) {
157-
if (isAbsolutePath.test(spec))
158-
return spec
159-
return path.resolve(where, spec)
160-
}
161-
162-
function isAbsolute (dir) {
163-
if (dir[0] === '/')
164-
return true
165-
if (/^[A-Za-z]:/.test(dir))
166-
return true
167-
return false
168-
}
169-
170154
function fromFile (res, where) {
171155
if (!where)
172156
where = process.cwd()
173157
res.type = isFilename.test(res.rawSpec) ? 'file' : 'directory'
174158
res.where = where
175159

176-
const spec = res.rawSpec.replace(/\\/g, '/')
177-
.replace(/^file:[/]*([A-Za-z]:)/, '$1') // drive name paths on windows
178-
.replace(/^file:(?:[/]*(~\/|\.*\/|[/]))?/, '$1')
179-
if (/^~[/]/.test(spec)) {
180-
// this is needed for windows and for file:~/foo/bar
181-
res.fetchSpec = resolvePath(homedir(), spec.slice(2))
182-
res.saveSpec = 'file:' + spec
183-
} else {
184-
res.fetchSpec = resolvePath(where, spec)
185-
if (isAbsolute(spec))
186-
res.saveSpec = 'file:' + spec
187-
else
188-
res.saveSpec = 'file:' + path.relative(where, res.fetchSpec)
160+
// always put the '/' on where when resolving urls, or else
161+
// file:foo from /path/to/bar goes to /path/to/foo, when we want
162+
// it to be /path/to/foo/bar
163+
164+
let specUrl
165+
let resolvedUrl
166+
const prefix = (!/^file:/.test(res.rawSpec) ? 'file:' : '')
167+
const rawWithPrefix = prefix + res.rawSpec
168+
let rawNoPrefix = rawWithPrefix.replace(/^file:/, '')
169+
try {
170+
resolvedUrl = new url.URL(rawWithPrefix, `file://${path.resolve(where)}/`)
171+
specUrl = new url.URL(rawWithPrefix)
172+
} catch (originalError) {
173+
const er = new Error('Invalid file: URL, must comply with RFC 8909')
174+
throw Object.assign(er, {
175+
raw: res.rawSpec,
176+
spec: res,
177+
where,
178+
originalError,
179+
})
180+
}
181+
182+
// environment switch for testing
183+
if (process.env.NPM_PACKAGE_ARG_8909_STRICT !== '1') {
184+
// XXX backwards compatibility lack of compliance with 8909
185+
// Remove when we want a breaking change to come into RFC compliance.
186+
if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
187+
const rawSpec = res.rawSpec.replace(/^file:\/\//, 'file:///')
188+
resolvedUrl = new url.URL(rawSpec, `file://${path.resolve(where)}/`)
189+
specUrl = new url.URL(rawSpec)
190+
rawNoPrefix = rawSpec.replace(/^file:/, '')
191+
}
192+
// turn file:/../foo into file:../foo
193+
if (/^\/\.\.?(\/|$)/.test(rawNoPrefix)) {
194+
const rawSpec = res.rawSpec.replace(/^file:\//, 'file:')
195+
resolvedUrl = new url.URL(rawSpec, `file://${path.resolve(where)}/`)
196+
specUrl = new url.URL(rawSpec)
197+
rawNoPrefix = rawSpec.replace(/^file:/, '')
198+
}
199+
// XXX end 8909 violation backwards compatibility section
189200
}
201+
202+
// file:foo - relative url to ./foo
203+
// file:/foo - absolute path /foo
204+
// file:///foo - absolute path to /foo, no authority host
205+
// file://localhost/foo - absolute path to /foo, on localhost
206+
// file://foo - absolute path to / on foo host (error!)
207+
if (resolvedUrl.host && resolvedUrl.host !== 'localhost') {
208+
const msg = `Invalid file: URL, must be absolute if // present`
209+
throw Object.assign(new Error(msg), {
210+
raw: res.rawSpec,
211+
parsed: resolvedUrl,
212+
})
213+
}
214+
215+
// turn /C:/blah into just C:/blah on windows
216+
let specPath = decodeURIComponent(specUrl.pathname)
217+
let resolvedPath = decodeURIComponent(resolvedUrl.pathname)
218+
if (isWindows) {
219+
specPath = specPath.replace(/^\/+([a-z]:\/)/i, '$1')
220+
resolvedPath = resolvedPath.replace(/^\/+([a-z]:\/)/i, '$1')
221+
}
222+
223+
// replace ~ with homedir, but keep the ~ in the saveSpec
224+
// otherwise, make it relative to where param
225+
if (/^\/~(\/|$)/.test(specPath)) {
226+
res.saveSpec = `file:${specPath.substr(1)}`
227+
resolvedPath = path.resolve(homedir(), specPath.substr(3))
228+
} else if (!path.isAbsolute(rawNoPrefix))
229+
res.saveSpec = `file:${path.relative(where, resolvedPath)}`
230+
else
231+
res.saveSpec = `file:${path.resolve(resolvedPath)}`
232+
233+
res.fetchSpec = path.resolve(where, resolvedPath)
190234
return res
191235
}
192236

test/basic.js

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,24 @@
1-
var npa = require('../npa.js')
2-
var path = require('path')
3-
var os = require('os')
1+
const path = require('path').posix
2+
const os = require('os')
43

54
const normalizePath = p => p && p.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/')
65

6+
const cwd = normalizePath(process.cwd())
7+
process.cwd = () => cwd
78
const normalizePaths = spec => {
89
spec.saveSpec = normalizePath(spec.saveSpec)
910
spec.fetchSpec = normalizePath(spec.fetchSpec)
1011
return spec
1112
}
1213

13-
require('tap').test('basic', function (t) {
14+
const t = require('tap')
15+
const npa = t.mock('../npa.js', { path })
16+
t.on('bailout', () => process.exit(1))
17+
18+
t.test('basic', function (t) {
1419
t.setMaxListeners(999)
1520

16-
var tests = {
21+
const tests = {
1722
'foo@1.2': {
1823
name: 'foo',
1924
escapedName: 'foo',
@@ -472,6 +477,14 @@ require('tap').test('basic', function (t) {
472477
raw: 'file:////path/to/foo',
473478
},
474479

480+
'file://.': {
481+
name: null,
482+
escapedName: null,
483+
type: 'directory',
484+
saveSpec: 'file:',
485+
raw: 'file://.',
486+
},
487+
475488
'http://insecure.com/foo.tgz': {
476489
name: null,
477490
escapedName: null,
@@ -550,14 +563,14 @@ require('tap').test('basic', function (t) {
550563
}
551564

552565
Object.keys(tests).forEach(function (arg) {
553-
var res = normalizePaths(npa(arg, '/test/a/b'))
566+
const res = normalizePaths(npa(arg, '/test/a/b'))
554567
t.ok(res instanceof npa.Result, arg + ' is a result')
555568
Object.keys(tests[arg]).forEach(function (key) {
556569
t.match(res[key], tests[arg][key], arg + ' [' + key + ']')
557570
})
558571
})
559572

560-
var objSpec = { name: 'foo', rawSpec: '1.2.3' }
573+
let objSpec = { name: 'foo', rawSpec: '1.2.3' }
561574
t.equal(npa(objSpec, '/whatnot').toString(), 'foo@1.2.3', 'parsed object')
562575

563576
objSpec.where = '/whatnot'
@@ -569,19 +582,23 @@ require('tap').test('basic', function (t) {
569582
objSpec = { raw: './foo/bar', where: '/here' }
570583
t.equal(normalizePath(npa(objSpec).fetchSpec), '/here/foo/bar', '`where` is reused')
571584

572-
var res = new npa.Result({ name: 'bar', rawSpec: './foo/bar' })
585+
let res = new npa.Result({ name: 'bar', rawSpec: './foo/bar' })
573586
t.equal(res.toString(), 'bar@./foo/bar', 'toString with only rawSpec')
574587
res = new npa.Result({ rawSpec: './x/y' })
575588
t.equal(normalizePath(res.toString()), './x/y', 'toString with only rawSpec, no name')
576589
res = new npa.Result({ rawSpec: '' })
577590
t.equal(res.toString(), '', 'toString with nothing')
578591

579592
objSpec = { raw: './foo/bar', where: '/here' }
580-
t.equal(normalizePath(npa(objSpec, '/whatnot').fetchSpec), '/whatnot/foo/bar', '`where` arg overrides the one in the spec object')
593+
t.equal(
594+
normalizePath(npa(objSpec, '/whatnot').fetchSpec),
595+
'/whatnot/foo/bar',
596+
'`where` arg overrides the one in the spec object'
597+
)
581598

582599
t.equal(npa(npa('foo@1.2.3')).toString(), 'foo@1.2.3', 'spec is passthrough')
583600

584-
var parsedSpec = npa('./foo', './here')
601+
const parsedSpec = npa('./foo', './here')
585602
t.equal(npa(parsedSpec), parsedSpec, 'reused if no where')
586603
t.equal(npa(parsedSpec, './here'), parsedSpec, 'reused if where matches')
587604
t.not(npa(parsedSpec, './there'), parsedSpec, 'new instance if where does not match')
@@ -607,14 +624,72 @@ require('tap').test('basic', function (t) {
607624
npa('foo@npm:foo/bar')
608625
}, 'aliases only work for registry deps')
609626

610-
t.has(npa.resolve('foo', '^1.2.3', '/test/a/b'), { type: 'range' }, 'npa.resolve')
611-
t.has(normalizePaths(npa.resolve('foo', 'file:foo', '/test/a/b')), { type: 'directory', fetchSpec: '/test/a/b/foo' }, 'npa.resolve file:')
612-
t.has(npa.resolve('foo', '../foo/bar', '/test/a/b'), { type: 'directory' }, 'npa.resolve no protocol')
613-
t.has(npa.resolve('foo', 'file:../foo/bar', '/test/a/b'), { type: 'directory' }, 'npa.resolve file protocol')
614-
t.has(npa.resolve('foo', 'file:../foo/bar.tgz', '/test/a/b'), { type: 'file' }, 'npa.resolve file protocol w/ tgz')
615-
t.has(npa.resolve(null, '4.0.0', '/test/a/b'), { type: 'version', name: null }, 'npa.resolve with no name')
616-
t.has(npa.resolve('foo', 'file:abc'), { type: 'directory', raw: 'foo@file:abc' }, 'npa.resolve sets raw right')
617-
t.has(npa('./path/to/thing/package@1.2.3/'), { name: null, type: 'directory' }, 'npa with path in @ in it')
618-
t.has(npa('path/to/thing/package@1.2.3'), { name: null, type: 'directory' }, 'npa w/o leading or trailing slash')
627+
t.has(npa.resolve('foo', '^1.2.3', '/test/a/b'), {
628+
type: 'range',
629+
}, 'npa.resolve')
630+
t.has(normalizePaths(npa.resolve('foo', 'file:foo', '/test/a/b')), {
631+
type: 'directory',
632+
fetchSpec: '/test/a/b/foo',
633+
}, 'npa.resolve file:')
634+
t.has(npa.resolve('foo', '../foo/bar', '/test/a/b'), {
635+
type: 'directory',
636+
}, 'npa.resolve no protocol')
637+
t.has(npa.resolve('foo', 'file:../foo/bar', '/test/a/b'), {
638+
type: 'directory',
639+
}, 'npa.resolve file protocol')
640+
t.has(npa.resolve('foo', 'file:../foo/bar.tgz', '/test/a/b'), {
641+
type: 'file',
642+
}, 'npa.resolve file protocol w/ tgz')
643+
t.has(npa.resolve(null, '4.0.0', '/test/a/b'), {
644+
type: 'version',
645+
name: null,
646+
}, 'npa.resolve with no name')
647+
t.has(npa.resolve('foo', 'file:abc'), {
648+
type: 'directory',
649+
raw: 'foo@file:abc',
650+
}, 'npa.resolve sets raw right')
651+
t.has(npa('./path/to/thing/package@1.2.3/'), {
652+
name: null,
653+
type: 'directory',
654+
}, 'npa with path in @ in it')
655+
t.has(npa('path/to/thing/package@1.2.3'), {
656+
name: null,
657+
type: 'directory',
658+
}, 'npa w/o leading or trailing slash')
659+
t.end()
660+
})
661+
662+
t.test('strict 8909 compliance mode', t => {
663+
t.teardown(() => process.env.NPM_PACKAGE_ARG_8909_STRICT = '0')
664+
process.env.NPM_PACKAGE_ARG_8909_STRICT = '1'
665+
666+
t.throws(() => npa('file://.'), {
667+
message: 'Invalid file: URL, must be absolute if // present',
668+
raw: 'file://.',
669+
})
670+
671+
t.throws(() => npa('file://some/relative/path'), {
672+
message: 'Invalid file: URL, must be absolute if // present',
673+
raw: 'file://some/relative/path',
674+
})
675+
676+
// I cannot for the life of me figure out how to make new URL('file:...')
677+
// actually fail to parse. it seems like it accepts any garbage you can
678+
// throw at it. However, because it theoretically CAN throw, here's a test.
679+
t.throws(() => {
680+
const npa = t.mock('../npa.js', {
681+
url: {
682+
URL: class {
683+
constructor () {
684+
throw new Error('thansk i haet it')
685+
}
686+
},
687+
},
688+
})
689+
npa('file:thansk i haet it')
690+
}, {
691+
message: 'Invalid file: URL, must comply with RFC 8909',
692+
})
693+
619694
t.end()
620695
})

0 commit comments

Comments
 (0)