Skip to content

fix(subset): stop eq comparator's prerelease tuple leaking into isolated gt/lt check - #911

Open
afonsojanu wants to merge 1 commit into
npm:mainfrom
afonsojanu:fix/subset-eq-prerelease-tuple-leak
Open

afonsojanu wants to merge 1 commit into
npm:mainfrom
afonsojanu:fix/subset-eq-prerelease-tuple-leak

Conversation

@afonsojanu

Copy link
Copy Markdown

Summary

semver.subset() can return true for a range pair where it should return false, when the sub range is a simple (AND'd) range that combines a bare/prerelease equality comparator with a >/>=/</<= comparator sharing the same major.minor.patch tuple.

Repro:

const semver = require('semver')

semver.subset('>1.0.0 1.2.3-0', '1.2.3')
// => true (wrong)

semver.satisfies('1.2.3-0', '>1.0.0 1.2.3-0')
// => true  (1.2.3-0 is in the sub range)
semver.satisfies('1.2.3-0', '1.2.3')
// => false (1.2.3-0 is NOT in the dom range)

Since a version in the sub range is not in the dom range, subset() returning true is incorrect by definition.

Another minimal case:

semver.subset('<2.0.0 1.2.3-0', '1.2.3') // true, should be false

Root cause

In ranges/subset.js, simpleSubset() checks whether the bare eq comparator (e.g. 1.2.3-0) is compatible with the range's gt/lt bound like this:

if (gt && !satisfies(eq, String(gt), options)) {
  return null
}

String(gt) turns the gt comparator into a standalone one-comparator range string (e.g. '>1.0.0'). Passing that to satisfies() re-applies semver's "a prerelease version only satisfies a range if some comparator in that range shares its tuple and carries a prerelease tag" rule against that single comparator alone, losing the fact that eq (also part of the same simple range) already supplies a comparator with the matching tuple and a prerelease tag. So satisfies(eq, String(gt)) incorrectly returns false, and the function returns null (its "null set" sentinel), which subset() then treats as "this simple range contributes nothing to consider" — ultimately causing it to report true for a sub/dom pair that isn't actually a subset relationship.

Fix

Use gt.test(eq) / lt.test(eq) instead, which does a direct version comparison without re-deriving prerelease eligibility from a freshly-built single-comparator string. This matches how the rest of the same function already checks dom comparators a few lines down (c.test(gt.semver), c.test(lt.semver)).

Test plan

  • Added 3 regression cases to test/ranges/subset.js reproducing the false positive (verified they fail against the pre-fix code and pass with the fix).
  • npx tap test/ranges/subset.js — all 86 assertions pass, 100% coverage maintained.
  • npx tap test/ — full suite passes, no regressions.
  • npm run lint — clean.
  • Ran a differential fuzz check (subset() vs. brute-force satisfies() over an enumerated set of ~2300 versions and thousands of randomly generated simple/complex ranges, both with and without includePrerelease) before and after the fix; the false-positive class this PR targets (subset() saying true while satisfies() finds a counterexample) no longer occurs in 100k+ generated pairs.

…ted gt/lt check

simpleSubset() checks a bare eq comparator (e.g. `1.2.3-0`) against a
gt/lt comparator in the same simple range by calling
satisfies(eq, String(gt)). Converting gt to a standalone range string
loses the fact that eq itself supplies a matching prerelease tuple, so
satisfies() wrongly excludes the prerelease and the whole simple range
gets misclassified as a null set. That makes subset() report `true`
even when a version exists that matches the sub range but not the dom
range, e.g.:

  semver.subset('>1.0.0 1.2.3-0', '1.2.3') // true, should be false
  semver.satisfies('1.2.3-0', '>1.0.0 1.2.3-0') // true
  semver.satisfies('1.2.3-0', '1.2.3') // false

Using gt.test(eq) / lt.test(eq) instead does a direct version
comparison without re-deriving prerelease eligibility from a
one-comparator string, matching how the rest of this function already
checks dom comparators (see the surrounding c.test(...) calls).

Added regression cases to test/ranges/subset.js; full suite and
coverage stay green.
@afonsojanu
afonsojanu requested a review from a team as a code owner September 29, 2026 22:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant