From 33b46304e23ce7f8a4d803d550002deeef8a856a Mon Sep 17 00:00:00 2001 From: Yarchik Date: Thu, 23 Jul 2026 14:05:30 +0100 Subject: [PATCH 1/2] fix: subset false-positive with a prerelease eq and a differing bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subset(sub, dom)` returned `true` when `sub` combined an exact prerelease comparator (`=X.Y.Z-pre`) with a `>`/`>=`/`<`/`<=` bound of a different `[major,minor,patch]` tuple, even though `sub` contains a version outside `dom`: subset('=1.1.2-alpha <3.1.0', '<1.0.0') // true, must be false satisfies('1.1.2-alpha', '=1.1.2-alpha <3.1.0') // true (in sub) satisfies('1.1.2-alpha', '<1.0.0') // false (not in dom) In `simpleSubset`, the eqSet-vs-bound checks used `satisfies(eq, String(gt), options)`, which rebuilds a full Range and re-applies node-semver's prerelease-exclusion gating, so a prerelease `eq` is judged not to satisfy a plain bound of another tuple. The code then treats the eqSet as inconsistent and returns `null` (null set), which `subset` reports as a subset of everything. Test the eq version against the raw bound comparator instead (`gt.test(eq)` / `lt.test(eq)`) — the same fix PR #867 applied to the dom-side checks, which this left in place on the eqSet side. --- ranges/subset.js | 8 ++++++-- test/ranges/subset.js | 5 +++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ranges/subset.js b/ranges/subset.js index a9498323..45efaeab 100644 --- a/ranges/subset.js +++ b/ranges/subset.js @@ -124,11 +124,15 @@ const simpleSubset = (sub, dom, options) => { // will iterate one or zero times for (const eq of eqSet) { - if (gt && !satisfies(eq, String(gt), options)) { + // test the eq version against the raw bound comparator; going through + // satisfies() rebuilds a full Range and re-applies prerelease gating, which + // wrongly rejects a prerelease eq against a plain bound of another tuple + // (the same fix PR #867 applied to the dom-side checks below) + if (gt && !gt.test(eq)) { return null } - if (lt && !satisfies(eq, String(lt), options)) { + if (lt && !lt.test(eq)) { return null } diff --git a/test/ranges/subset.js b/test/ranges/subset.js index c6de3570..a0781be1 100644 --- a/test/ranges/subset.js +++ b/test/ranges/subset.js @@ -13,6 +13,11 @@ const cases = [ ['1.2.3', '>1.2.0', true], ['1.2.3 2.3.4 || 2.3.4', '3', false], ['^1.2.3-pre.0', '1.x', false], + // a prerelease `=` comparator combined with a bound of a different tuple must + // not be treated as a null set (subset false-positive): 1.1.2-alpha is in sub + // but not in dom, so sub is not a subset of dom + ['=1.1.2-alpha <3.1.0', '<1.0.0', false], + ['<3.1.0-0 1.1.2-alpha', '~2.0', false], ['^1.2.3-pre.0', '1.x', true, { includePrerelease: true }], ['>2 <1', '3', true], ['1 || 2 || 3', '>=1.0.0', true], From 31a821443c46f00d39ed23cc2bf2c2d2c797d735 Mon Sep 17 00:00:00 2001 From: Yarchik Date: Fri, 14 Aug 2026 12:01:06 +0100 Subject: [PATCH 2/2] chore: add a property-based soundness test for subset() The existing table cases are green with and without the prerelease-eq fix, so they cannot guard this class of subset() false-positive. Assert the defining property over seeded, generated ranges instead: a true subset(a, b) never admits a version that satisfies a but not b. Independently rediscovered and proposed by @mrvonkalus. --- test/ranges/subset.js | 68 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/test/ranges/subset.js b/test/ranges/subset.js index a0781be1..ce332021 100644 --- a/test/ranges/subset.js +++ b/test/ranges/subset.js @@ -2,6 +2,7 @@ const t = require('tap') const subset = require('../../ranges/subset.js') +const satisfies = require('../../functions/satisfies.js') const Range = require('../../classes/range') // sub, dom, expect, [options] @@ -111,7 +112,7 @@ const cases = [ ['>2.0.0', '>=2.0.0', true], ] -t.plan(cases.length + 1) +t.plan(cases.length + 2) cases.forEach(([sub, dom, expect, options]) => { const msg = `${sub || "''"} ⊂ ${dom || "''"} = ${expect}` + (options ? ' ' + Object.keys(options).join(',') : '') @@ -148,3 +149,68 @@ t.test('range should be subset of itself in obj or string mode', t => { t.equal(subset(r, r4), true) t.end() }) + +// Property: a true subset(a, b) must never admit a version that satisfies a but +// not b. The table cases above cannot catch this class - the suite is green with +// and without the fix - so assert the invariant over generated ranges, in the +// false-positive direction only (a finite universe cannot refute a `false`). +// Independently rediscovered and proposed by @mrvonkalus (differential fuzzing). +t.test('a true subset() result never admits a version outside the superset', t => { + // seeded xorshift32, so any failure reproduces exactly and CI cannot flake + let seed = 0x1a2b3c4d + const rand = () => { + seed ^= seed << 13 + seed ^= seed >>> 17 + seed ^= seed << 5 + return (seed >>> 0) / 0x100000000 + } + const pick = arr => arr[Math.floor(rand() * arr.length)] + + const universe = [] + for (const major of [0, 1, 2, 3]) { + for (const minor of [0, 1, 2]) { + for (const patch of [0, 1, 2]) { + universe.push(`${major}.${minor}.${patch}`) + for (const pre of ['alpha.0', 'alpha.1', 'beta', '0']) { + universe.push(`${major}.${minor}.${patch}-${pre}`) + } + } + } + } + + const ops = ['>', '>=', '<', '<='] + const simple = () => { + const parts = [] + for (let i = 1 + Math.floor(rand() * 3); i > 0; i--) { + parts.push(rand() < 0.5 ? `${pick(ops)}${pick(universe)}` : pick(universe)) + } + return parts.join(' ') + } + const range = () => { + const parts = [] + for (let i = 1 + Math.floor(rand() * 2); i > 0; i--) { + parts.push(simple()) + } + return parts.join(' || ') + } + + let unsound = null + for (let i = 0; i < 10000 && !unsound; i++) { + const options = rand() < 0.5 ? {} : { includePrerelease: true } + const a = range() + const b = range() + if (!subset(a, b, options)) { + continue + } + for (const v of universe) { + if (satisfies(v, a, options) && !satisfies(v, b, options)) { + unsound = `subset(${JSON.stringify(a)}, ${JSON.stringify(b)}, ${ + JSON.stringify(options)}) === true, but ${v} satisfies the sub-range and not the super-range` + break + } + } + } + + t.equal(unsound, null, 'no unsound subset() result over generated ranges') + t.end() +})