diff --git a/src/utils/version.ts b/src/utils/version.ts index 4d4b6f2e..f7c3c0e3 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -2,29 +2,54 @@ export function looksLikeVersion(value: string): boolean { return /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][^/]+)?$/.test(value); } +// Matches a well-formed X.Y.Z version, capturing an optional dot-separated +// pre-release identifier list and discarding build metadata (semver §11.3/11.4). +// The pre-release character class matches looksLikeVersion's admission gate +// above (anything but '/' or '+') rather than a narrower ASCII set, so a +// pre-release looksLikeVersion accepts can never fail this match and fall +// through to the flat-token fallback, which would reintroduce the exact +// precedence bug this file fixes. +const VERSION_SHAPE = /^(\d+)\.(\d+)\.(\d+)(?:-([^/+]+))?(?:\+.*)?$/; + +interface ParsedVersion { + readonly release: readonly [number, number, number]; + readonly prerelease: readonly string[] | null; +} + // module-level; safe to grow unbounded — a single scan sees at most // a few hundred unique version strings, and the cache is discarded when the process exits. -const parsedVersionCache = new Map(); +const parsedVersionCache = new Map(); export function getVersionCacheSize(): number { return parsedVersionCache.size; } -function parseVersionTuple(v: string): readonly (number | string)[] { +function parseVersion(v: string): ParsedVersion | null { const cached = parsedVersionCache.get(v); - if (cached) return cached; - const parsed = v.split(/[.+-]/).map(n => { - const num = Number(n); - return Number.isFinite(num) ? num : n; - }); - Object.freeze(parsed); + if (cached !== undefined) return cached; + const match = VERSION_SHAPE.exec(v); + const parsed: ParsedVersion | null = match + ? { + release: [Number(match[1]), Number(match[2]), Number(match[3])], + prerelease: match[4] ? match[4].split(".") : null, + } + : null; parsedVersionCache.set(v, parsed); return parsed; } -export function compareVersions(a: string, b: string): number { - const pa = parseVersionTuple(a); - const pb = parseVersionTuple(b); +// Legacy flat-tuple comparison, used only when at least one operand doesn't +// match VERSION_SHAPE, so a malformed input on either side degrades to prior +// (non-throwing) behavior instead of comparing a semver-parsed shape against +// an unparsed one (KTD3). +function compareFallback(a: string, b: string): number { + const toTuple = (v: string): readonly (number | string)[] => + v.split(/[.+-]/).map(n => { + const num = Number(n); + return Number.isFinite(num) ? num : n; + }); + const pa = toTuple(a); + const pb = toTuple(b); const len = Math.max(pa.length, pb.length); for (let i = 0; i < len; i++) { const av = pa[i] ?? 0; @@ -40,6 +65,46 @@ export function compareVersions(a: string, b: string): number { return 0; } +const NUMERIC_IDENTIFIER = /^\d+$/; + +// Compares two dot-separated pre-release identifier lists per semver §11.4: +// numeric identifiers compare numerically, alphanumeric ones lexically, a +// numeric identifier always has lower precedence than an alphanumeric one, +// and (when the shared prefix is equal) a longer list outranks a shorter one. +function compareIdentifiers(a: string, b: string): number { + const aIsNumeric = NUMERIC_IDENTIFIER.test(a); + const bIsNumeric = NUMERIC_IDENTIFIER.test(b); + if (aIsNumeric && bIsNumeric) return Number(a) - Number(b); + if (aIsNumeric !== bIsNumeric) return aIsNumeric ? -1 : 1; + return a < b ? -1 : a > b ? 1 : 0; +} + +// A version with no pre-release outranks one with a pre-release at the same +// release; between two pre-releases, compare identifiers left to right. +function comparePrerelease(a: readonly string[] | null, b: readonly string[] | null): number { + if (a === null && b === null) return 0; + if (a === null) return 1; + if (b === null) return -1; + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + if (i >= a.length) return -1; + if (i >= b.length) return 1; + const cmp = compareIdentifiers(a[i], b[i]); + if (cmp !== 0) return cmp; + } + return 0; +} + +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + if (!pa || !pb) return compareFallback(a, b); + for (let i = 0; i < 3; i++) { + if (pa.release[i] !== pb.release[i]) return pa.release[i] - pb.release[i]; + } + return comparePrerelease(pa.prerelease, pb.prerelease); +} + export function parseExactManifestVersion(spec: string): string | null { const cleaned = spec.trim().replace(/^npm:/, ""); if (looksLikeVersion(cleaned)) return cleaned; diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts index 30c65870..cd0849e8 100644 --- a/tests/helpers.test.ts +++ b/tests/helpers.test.ts @@ -351,6 +351,58 @@ describe("version helpers", () => { expect(compareVersions("1.2.3-beta", "1.2.3-alpha")).toBeGreaterThan(0); }); + it("ranks a pre-release below its associated release (semver 11.3)", () => { + expect(compareVersions("1.2.3-beta.1", "1.2.3")).toBeLessThan(0); + expect(compareVersions("1.2.3", "1.2.3-beta.1")).toBeGreaterThan(0); + expect(compareVersions("2.0.0-rc.1", "2.0.0")).toBeLessThan(0); + }); + + it("compares pre-release identifiers per semver 11.4", () => { + // Numeric identifiers compare by value, not lexically. + expect(compareVersions("1.2.3-alpha.2", "1.2.3-alpha.10")).toBeLessThan(0); + // Numeric identifiers always have lower precedence than alphanumeric ones. + expect(compareVersions("1.2.3-1", "1.2.3-alpha")).toBeLessThan(0); + // A longer identifier list outranks a shorter one when the shared prefix is equal. + expect(compareVersions("1.2.3-beta.1", "1.2.3-beta")).toBeGreaterThan(0); + }); + + it("admits every pre-release character looksLikeVersion accepts (no bug-inversion via the fallback path)", () => { + // looksLikeVersion accepts any non-'/' suffix after the first '-'; the semver-aware + // parser must accept the same set, or an underscore (etc.) silently routes both + // operands to the legacy fallback comparator and reintroduces the ranking-inversion + // bug this file fixes (see issue #1077 and its adversarial review finding). + expect(looksLikeVersion("1.2.3-beta_1")).toBe(true); + expect(compareVersions("1.2.3-beta_1", "1.2.3")).toBeLessThan(0); + expect(compareVersions("1.2.3", "1.2.3-beta_1")).toBeGreaterThan(0); + }); + + it("reports a pre-release install as vulnerable when the fix landed in the release (issue #1077)", () => { + const introduced = "1.0.0"; + const fixed = "1.2.3"; + const lastAffected: string | null = null; + const versionMatchesRange = (version: string): boolean => { + if (introduced !== "0" && compareVersions(version, introduced) < 0) return false; + if (fixed && compareVersions(version, fixed) >= 0) return false; + if (lastAffected && compareVersions(version, lastAffected) > 0) return false; + return true; + }; + + expect(versionMatchesRange("1.2.2")).toBe(true); + expect(versionMatchesRange("1.2.3-beta.1")).toBe(true); + expect(versionMatchesRange("1.2.3-rc.2")).toBe(true); + expect(versionMatchesRange("1.2.3")).toBe(false); + }); + + it("falls back to flat-token comparison when either operand isn't a well-formed version (KTD3)", () => { + // ">=1.2.3" fails VERSION_SHAPE, so both operands route through the legacy + // flat-token comparator: tuple [1,2,3,"beta",1] vs [">=1",2,3] disagrees at + // the first segment (1 vs ">=1"), settled by localeCompare -- exact parity + // with the pre-fix behavior for malformed input, not a thrown error. + expect(compareVersions("1.2.3-beta.1", ">=1.2.3")).toBe(1); + expect(compareVersions("not-a-version", "1.2.3")).toBe(1); + expect(compareVersions("1.2.3", "1.2.3")).toBe(0); + }); + it("caches parsed version tuples so identical strings are only parsed once", () => { const a = "99.88.77-cache-test-a.10"; const b = "99.88.77-cache-test-b.2"; diff --git a/tests/lowest-safe-version.test.ts b/tests/lowest-safe-version.test.ts index 25383230..3d200d61 100644 --- a/tests/lowest-safe-version.test.ts +++ b/tests/lowest-safe-version.test.ts @@ -123,4 +123,15 @@ describe("resolvePublishedFixVersion", () => { expect(result.resolvedVersion).toBe("1.0.1"); expect(result.publishedAt).toBe("2026-01-01T00:00:00.000Z"); }); + + it("skips a pre-release below the fix hint and recommends the next stable release (issue #1077)", async () => { + // "1.2.3" (the advisory's fixed-version hint) is not published; the packument + // has a pre-release that sits below it and a stable release above it. + mockPackument(["1.2.3-beta.1", "1.2.4"]); + + const result = await resolvePublishedFixVersion("tar", "1.2.3"); + + expect(result.resolvedVersion).toBe("1.2.4"); + expect(result.note).toContain("using nearest published version 1.2.4"); + }); });