Files
npm-node-semver/test/classes/range.js
T
dualfroz 2124e4b155 fix: reject loose ranges that collapse to match everything
In loose mode the comparators that fail to parse are filtered out of a
comparator list, and whatever is left becomes the range. When the only
survivor is the any comparator, a typo such as `* (4.1.x  4.1.2)` drops
`(4.1.x` and `4.1.2)` and collapses to the leftover `*`, so the range
silently satisfies every version instead of being rejected. Without
`loose` the same input throws `Invalid comparator: (4.1.x`.

Treat that comparator list as invalid instead, the same way a list with
no valid comparator at all is already treated: the list is dropped, so
other `||` branches still stand on their own and a range left with no
valid branch throws `Invalid SemVer Range`.

Junk sitting next to a comparator that does parse is still tolerated, so
`>=1.2.3 &&&` still yields `>=1.2.3` and `== 1.0.0` still yields
`1.0.0`.

Fixes #387
2026-09-04 22:16:53 +02:00

149 lines
4.8 KiB
JavaScript

'use strict'
const { test } = require('tap')
const Range = require('../../classes/range')
const Comparator = require('../../classes/comparator')
const satisfies = require('../../functions/satisfies')
const rangeIntersection = require('../fixtures/range-intersection.js')
const rangeInclude = require('../fixtures/range-include.js')
const rangeExclude = require('../fixtures/range-exclude.js')
const rangeParse = require('../fixtures/range-parse.js')
test('range tests', t => {
t.plan(rangeInclude.length)
rangeInclude.forEach(([range, ver, options]) => {
const r = new Range(range, options)
t.ok(r.test(ver), `${range} satisfied by ${ver}`)
})
})
test('range parsing', t => {
t.plan(rangeParse.length)
rangeParse.forEach(([range, expect, options]) =>
t.test(`${range} ${expect} ${JSON.stringify(options)}`, t => {
if (expect === null) {
t.throws(() => new Range(range, options), TypeError, `invalid range: ${range}`)
} else {
t.equal(new Range(range, options).range || '*', expect, `${range} => ${expect}`)
t.equal(new Range(range, options).range, new Range(expect).range,
'parsing both yields same result')
}
t.end()
}))
})
test('throw for empty comparator set, even in loose mode', t => {
t.throws(() => new Range('sadf||asdf', { loose: true }),
TypeError('Invalid SemVer Range: sadf||asdf'))
t.end()
})
test('loose mode does not drop comparators down to a match-everything range', t => {
// `(4.1.x` and `4.1.2)` used to be filtered out, collapsing the range to the
// `*` that was left over, so a typo silently matched every version
t.throws(() => new Range('* (4.1.x 4.1.2)', { loose: true }),
TypeError('Invalid SemVer Range: * (4.1.x 4.1.2)'))
t.equal(satisfies('9.9.9', '* (4.1.x 4.1.2)', { loose: true }), false,
'typo range does not match an unrelated version')
t.equal(satisfies('4.1.2', '* (4.1.x 4.1.2)', { loose: true }), false,
'typo range does not match the version it looks like it means')
// only the collapse to `*` is rejected. loose mode still tolerates junk
// sitting next to a comparator that does parse, and still tolerates a `||`
// branch that is invalid as a whole
t.equal(new Range('>=1.2.3 &&&', { loose: true }).range, '>=1.2.3',
'junk beside a valid comparator is still dropped')
t.equal(new Range('1.2.3 || (foo)', { loose: true }).range, '1.2.3',
'wholly invalid union branch is still tolerated')
t.end()
})
test('convert comparator to range', t => {
const c = new Comparator('>=1.2.3')
const r = new Range(c)
t.equal(r.raw, c.value, 'created range from comparator')
t.end()
})
test('range as argument to range ctor', t => {
const loose = new Range('1.2.3', { loose: true })
t.equal(new Range(loose, { loose: true }), loose, 'loose option')
t.equal(new Range(loose, true), loose, 'loose boolean')
t.not(new Range(loose), loose, 'created new range if not matched')
const incPre = new Range('1.2.3', { includePrerelease: true })
t.equal(new Range(incPre, { includePrerelease: true }), incPre,
'include prerelease, option match returns argument')
t.not(new Range(incPre), incPre,
'include prerelease, option mismatch does not return argument')
t.end()
})
test('negative range tests', t => {
t.plan(rangeExclude.length)
rangeExclude.forEach(([range, ver, options]) => {
const r = new Range(range, options)
t.notOk(r.test(ver), `${range} not satisfied by ${ver}`)
})
})
test('strict vs loose ranges', (t) => {
[
['>=01.02.03', '>=1.2.3'],
['~1.02.03beta', '>=1.2.3-beta <1.3.0-0'],
].forEach(([loose, comps]) => {
t.throws(() => new Range(loose))
t.equal(new Range(loose, true).range, comps)
})
t.end()
})
test('tostrings', (t) => {
t.equal(new Range('>= v1.2.3').toString(), '>=1.2.3')
t.end()
})
test('formatted value is calculated lazily and cached', (t) => {
const r = new Range('>= v1.2.3')
t.equal(r.formatted, undefined)
t.equal(r.format(), '>=1.2.3')
t.equal(r.formatted, '>=1.2.3')
t.equal(r.format(), '>=1.2.3')
t.end()
})
test('ranges intersect', (t) => {
rangeIntersection.forEach(([r0, r1, expect]) => {
t.test(`${r0} <~> ${r1}`, t => {
const range0 = new Range(r0)
const range1 = new Range(r1)
t.equal(range0.intersects(range1), expect,
`${r0} <~> ${r1} objects`)
t.equal(range1.intersects(range0), expect,
`${r1} <~> ${r0} objects`)
t.end()
})
})
t.end()
})
test('missing range parameter in range intersect', (t) => {
t.throws(() => {
new Range('1.0.0').intersects()
}, new TypeError('a Range is required'),
'throws type error')
t.end()
})
test('cache', (t) => {
const cached = Symbol('cached')
const r1 = new Range('1.0.0')
r1.set[0][cached] = true
const r2 = new Range('1.0.0')
t.equal(r1.set[0][cached], true)
t.equal(r2.set[0][cached], true) // Will be true, showing it's cached.
t.end()
})