Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 76 additions & 11 deletions src/utils/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, readonly (number | string)[]>();
const parsedVersionCache = new Map<string, ParsedVersion | null>();

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;
Expand All @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions tests/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 11 additions & 0 deletions tests/lowest-safe-version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});