Skip to content

Commit 717534e

Browse files
authoredJun 15, 2023
fix: better handling of whitespace (#564)
1 parent 2f738e9 commit 717534e

File tree

9 files changed

+136
-72
lines changed

9 files changed

+136
-72
lines changed
 

‎classes/comparator.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class Comparator {
1616
}
1717
}
1818

19+
comp = comp.trim().split(/\s+/).join(' ')
1920
debug('comparator', comp, options)
2021
this.options = options
2122
this.loose = !!options.loose
@@ -133,7 +134,7 @@ class Comparator {
133134
module.exports = Comparator
134135

135136
const parseOptions = require('../internal/parse-options')
136-
const { re, t } = require('../internal/re')
137+
const { safeRe: re, t } = require('../internal/re')
137138
const cmp = require('../functions/cmp')
138139
const debug = require('../internal/debug')
139140
const SemVer = require('./semver')

‎classes/range.js

+37-27
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,26 @@ class Range {
2626
this.loose = !!options.loose
2727
this.includePrerelease = !!options.includePrerelease
2828

29-
// First, split based on boolean or ||
29+
// First reduce all whitespace as much as possible so we do not have to rely
30+
// on potentially slow regexes like \s*. This is then stored and used for
31+
// future error messages as well.
3032
this.raw = range
31-
this.set = range
33+
.trim()
34+
.split(/\s+/)
35+
.join(' ')
36+
37+
// First, split on ||
38+
this.set = this.raw
3239
.split('||')
3340
// map the range to a 2d array of comparators
34-
.map(r => this.parseRange(r.trim()))
41+
.map(r => this.parseRange(r))
3542
// throw out any comparator lists that are empty
3643
// this generally means that it was not a valid range, which is allowed
3744
// in loose mode, but will still throw if the WHOLE range is invalid.
3845
.filter(c => c.length)
3946

4047
if (!this.set.length) {
41-
throw new TypeError(`Invalid SemVer Range: ${range}`)
48+
throw new TypeError(`Invalid SemVer Range: ${this.raw}`)
4249
}
4350

4451
// if we have any that are not the null set, throw out null sets.
@@ -64,9 +71,7 @@ class Range {
6471

6572
format () {
6673
this.range = this.set
67-
.map((comps) => {
68-
return comps.join(' ').trim()
69-
})
74+
.map((comps) => comps.join(' ').trim())
7075
.join('||')
7176
.trim()
7277
return this.range
@@ -77,8 +82,6 @@ class Range {
7782
}
7883

7984
parseRange (range) {
80-
range = range.trim()
81-
8285
// memoize range parsing for performance.
8386
// this is a very hot path, and fully deterministic.
8487
const memoOpts =
@@ -105,9 +108,6 @@ class Range {
105108
// `^ 1.2.3` => `^1.2.3`
106109
range = range.replace(re[t.CARETTRIM], caretTrimReplace)
107110

108-
// normalize spaces
109-
range = range.split(/\s+/).join(' ')
110-
111111
// At this point, the range is completely trimmed and
112112
// ready to be split into comparators.
113113

@@ -203,7 +203,7 @@ const Comparator = require('./comparator')
203203
const debug = require('../internal/debug')
204204
const SemVer = require('./semver')
205205
const {
206-
re,
206+
safeRe: re,
207207
t,
208208
comparatorTrimReplace,
209209
tildeTrimReplace,
@@ -257,10 +257,13 @@ const isX = id => !id || id.toLowerCase() === 'x' || id === '*'
257257
// ~1.2.3, ~>1.2.3 --> >=1.2.3 <1.3.0-0
258258
// ~1.2.0, ~>1.2.0 --> >=1.2.0 <1.3.0-0
259259
// ~0.0.1 --> >=0.0.1 <0.1.0-0
260-
const replaceTildes = (comp, options) =>
261-
comp.trim().split(/\s+/).map((c) => {
262-
return replaceTilde(c, options)
263-
}).join(' ')
260+
const replaceTildes = (comp, options) => {
261+
return comp
262+
.trim()
263+
.split(/\s+/)
264+
.map((c) => replaceTilde(c, options))
265+
.join(' ')
266+
}
264267

265268
const replaceTilde = (comp, options) => {
266269
const r = options.loose ? re[t.TILDELOOSE] : re[t.TILDE]
@@ -298,10 +301,13 @@ const replaceTilde = (comp, options) => {
298301
// ^1.2.0 --> >=1.2.0 <2.0.0-0
299302
// ^0.0.1 --> >=0.0.1 <0.0.2-0
300303
// ^0.1.0 --> >=0.1.0 <0.2.0-0
301-
const replaceCarets = (comp, options) =>
302-
comp.trim().split(/\s+/).map((c) => {
303-
return replaceCaret(c, options)
304-
}).join(' ')
304+
const replaceCarets = (comp, options) => {
305+
return comp
306+
.trim()
307+
.split(/\s+/)
308+
.map((c) => replaceCaret(c, options))
309+
.join(' ')
310+
}
305311

306312
const replaceCaret = (comp, options) => {
307313
debug('caret', comp, options)
@@ -358,9 +364,10 @@ const replaceCaret = (comp, options) => {
358364

359365
const replaceXRanges = (comp, options) => {
360366
debug('replaceXRanges', comp, options)
361-
return comp.split(/\s+/).map((c) => {
362-
return replaceXRange(c, options)
363-
}).join(' ')
367+
return comp
368+
.split(/\s+/)
369+
.map((c) => replaceXRange(c, options))
370+
.join(' ')
364371
}
365372

366373
const replaceXRange = (comp, options) => {
@@ -443,12 +450,15 @@ const replaceXRange = (comp, options) => {
443450
const replaceStars = (comp, options) => {
444451
debug('replaceStars', comp, options)
445452
// Looseness is ignored here. star is always as loose as it gets!
446-
return comp.trim().replace(re[t.STAR], '')
453+
return comp
454+
.trim()
455+
.replace(re[t.STAR], '')
447456
}
448457

449458
const replaceGTE0 = (comp, options) => {
450459
debug('replaceGTE0', comp, options)
451-
return comp.trim()
460+
return comp
461+
.trim()
452462
.replace(re[options.includePrerelease ? t.GTE0PRE : t.GTE0], '')
453463
}
454464

@@ -486,7 +496,7 @@ const hyphenReplace = incPr => ($0,
486496
to = `<=${to}`
487497
}
488498

489-
return (`${from} ${to}`).trim()
499+
return `${from} ${to}`.trim()
490500
}
491501

492502
const testSet = (set, version, options) => {

‎classes/semver.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const debug = require('../internal/debug')
22
const { MAX_LENGTH, MAX_SAFE_INTEGER } = require('../internal/constants')
3-
const { re, t } = require('../internal/re')
3+
const { safeRe: re, t } = require('../internal/re')
44

55
const parseOptions = require('../internal/parse-options')
66
const { compareIdentifiers } = require('../internal/identifiers')

‎functions/coerce.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const SemVer = require('../classes/semver')
22
const parse = require('./parse')
3-
const { re, t } = require('../internal/re')
3+
const { safeRe: re, t } = require('../internal/re')
44

55
const coerce = (version, options) => {
66
if (version instanceof SemVer) {

‎internal/re.js

+11
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,27 @@ exports = module.exports = {}
44

55
// The actual regexps go on exports.re
66
const re = exports.re = []
7+
const safeRe = exports.safeRe = []
78
const src = exports.src = []
89
const t = exports.t = {}
910
let R = 0
1011

1112
const createToken = (name, value, isGlobal) => {
13+
// Replace all greedy whitespace to prevent regex dos issues. These regex are
14+
// used internally via the safeRe object since all inputs in this library get
15+
// normalized first to trim and collapse all extra whitespace. The original
16+
// regexes are exported for userland consumption and lower level usage. A
17+
// future breaking change could export the safer regex only with a note that
18+
// all input should have extra whitespace removed.
19+
const safe = value
20+
.split('\\s*').join('\\s{0,1}')
21+
.split('\\s+').join('\\s')
1222
const index = R++
1323
debug(name, index, value)
1424
t[name] = index
1525
src[index] = value
1626
re[index] = new RegExp(value, isGlobal ? 'g' : undefined)
27+
safeRe[index] = new RegExp(safe, isGlobal ? 'g' : undefined)
1728
}
1829

1930
// The following Regular Expressions can be used for tokenizing,

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"range.bnf"
3838
],
3939
"tap": {
40-
"check-coverage": true,
40+
"timeout": 30,
4141
"coverage-map": "map.js",
4242
"nyc-arg": [
4343
"--exclude",

‎test/integration/whitespace.js

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
const { test } = require('tap')
2+
const Range = require('../../classes/range')
3+
const SemVer = require('../../classes/semver')
4+
const Comparator = require('../../classes/comparator')
5+
const validRange = require('../../ranges/valid')
6+
const minVersion = require('../../ranges/min-version')
7+
const minSatisfying = require('../../ranges/min-satisfying')
8+
const maxSatisfying = require('../../ranges/max-satisfying')
9+
10+
const s = (n = 500000) => ' '.repeat(n)
11+
12+
test('regex dos via range whitespace', (t) => {
13+
// a range with this much whitespace would take a few minutes to process if
14+
// any redos susceptible regexes were used. there is a global tap timeout per
15+
// file set in the package.json that will error if this test takes too long.
16+
const r = `1.2.3 ${s()} <1.3.0`
17+
18+
t.equal(new Range(r).range, '1.2.3 <1.3.0')
19+
t.equal(validRange(r), '1.2.3 <1.3.0')
20+
t.equal(minVersion(r).version, '1.2.3')
21+
t.equal(minSatisfying(['1.2.3'], r), '1.2.3')
22+
t.equal(maxSatisfying(['1.2.3'], r), '1.2.3')
23+
24+
t.end()
25+
})
26+
27+
test('semver version', (t) => {
28+
const v = `${s(125)}1.2.3${s(125)}`
29+
const tooLong = `${s()}1.2.3${s()}`
30+
t.equal(new SemVer(v).version, '1.2.3')
31+
t.throws(() => new SemVer(tooLong))
32+
t.end()
33+
})
34+
35+
test('comparator', (t) => {
36+
const c = `${s()}<${s()}1.2.3${s()}`
37+
t.equal(new Comparator(c).value, '<1.2.3')
38+
t.end()
39+
})

‎test/internal/re.js

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
const { test } = require('tap')
2-
const { src, re } = require('../../internal/re')
2+
const { src, re, safeRe } = require('../../internal/re')
33
const semver = require('../../')
44

55
test('has a list of src, re, and tokens', (t) => {
@@ -13,5 +13,11 @@ test('has a list of src, re, and tokens', (t) => {
1313
for (const i in semver.tokens) {
1414
t.match(semver.tokens[i], Number, 'tokens are numbers')
1515
}
16+
17+
safeRe.forEach(r => {
18+
t.notMatch(r.source, '\\s+', 'safe regex do not contain greedy whitespace')
19+
t.notMatch(r.source, '\\s*', 'safe regex do not contain greedy whitespace')
20+
})
21+
1622
t.end()
1723
})

‎test/map.js

+37-40
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,46 @@
11
const t = require('tap')
2+
const { resolve, join, relative, extname, dirname, basename } = require('path')
3+
const { statSync, readdirSync } = require('fs')
4+
const map = require('../map.js')
5+
const pkg = require('../package.json')
26

3-
// ensure that the coverage map maps all coverage
4-
const ignore = [
5-
'.git',
6-
'.github',
7-
'.commitlintrc.js',
8-
'.eslintrc.js',
9-
'.eslintrc.local.js',
10-
'node_modules',
11-
'coverage',
12-
'tap-snapshots',
13-
'test',
14-
'fixtures',
15-
]
7+
const ROOT = resolve(__dirname, '..')
8+
const TEST = join(ROOT, 'test')
9+
const IGNORE_DIRS = ['fixtures', 'integration']
1610

17-
const { statSync, readdirSync } = require('fs')
18-
const find = (folder, set = [], root = true) => {
19-
const ent = readdirSync(folder)
20-
set.push(...ent.filter(f => !ignore.includes(f) && /\.m?js$/.test(f)).map(f => folder + '/' + f))
21-
for (const e of ent.filter(f => !ignore.includes(f) && !/\.m?js$/.test(f))) {
22-
if (statSync(folder + '/' + e).isDirectory()) {
23-
find(folder + '/' + e, set, false)
11+
const getFile = (f) => {
12+
try {
13+
if (statSync(f).isFile()) {
14+
return extname(f) === '.js' ? [f] : []
2415
}
16+
} catch {
17+
return []
2518
}
26-
if (!root) {
27-
return
28-
}
29-
return set.map(f => f.slice(folder.length + 1)
30-
.replace(/\\/g, '/'))
31-
.sort((a, b) => a.localeCompare(b))
3219
}
3320

34-
const { resolve } = require('path')
35-
const root = resolve(__dirname, '..')
21+
const walk = (item, res = []) => getFile(item) || readdirSync(item)
22+
.map(f => join(item, f))
23+
.reduce((acc, f) => acc.concat(statSync(f).isDirectory() ? walk(f, res) : getFile(f)), [])
24+
.filter(Boolean)
3625

37-
const sut = find(root)
38-
const tests = find(root + '/test')
39-
t.strictSame(sut, tests, 'test files should match system files')
40-
const map = require('../map.js')
26+
const walkAll = (items, relativeTo) => items
27+
.reduce((acc, f) => acc.concat(walk(join(ROOT, f))), [])
28+
.map((f) => relative(relativeTo, f))
29+
.sort()
4130

42-
for (const testFile of tests) {
43-
t.test(testFile, t => {
44-
t.plan(1)
45-
// cast to an array, since map() can return a string or array
46-
const systemFiles = [].concat(map(testFile))
47-
t.ok(systemFiles.some(sys => sut.includes(sys)), 'test covers a file')
48-
})
49-
}
31+
t.test('tests match system', t => {
32+
const sut = walkAll([pkg.tap['coverage-map'], ...pkg.files], ROOT)
33+
const tests = walkAll([basename(TEST)], TEST)
34+
.filter(f => !IGNORE_DIRS.includes(dirname(f)))
35+
36+
t.strictSame(sut, tests, 'test files should match system files')
37+
38+
for (const f of tests) {
39+
t.test(f, t => {
40+
t.plan(1)
41+
t.ok(sut.includes(map(f)), 'test covers a file')
42+
})
43+
}
44+
45+
t.end()
46+
})

0 commit comments

Comments
 (0)
Please sign in to comment.