Skip to content

Fix version comparison and parsing inconsistencies in maven-artifact - #12947

Open
slachiewicz wants to merge 3 commits into
apache:masterfrom
slachiewicz:pr/version-comparison-master
Open

Fix version comparison and parsing inconsistencies in maven-artifact#12947
slachiewicz wants to merge 3 commits into
apache:masterfrom
slachiewicz:pr/version-comparison-master

Conversation

@slachiewicz

Copy link
Copy Markdown
Member

Three independent fixes in maven-artifact's version handling.

  • Release-qualifier ordering. 1-ga1 compared equal to 1, while 1-ga1 < 1-ga2. That breaks the transitivity contract Comparable requires, and contradicts the intent stated in the code's own comment (1-rc1 < 1, 1-ga1 > 1). CombinationItem.compareTo(null) consulted only the string part and never the digit. Includes a VersionRange-level regression test: an exact range [1.2.3] no longer matches 1.2.3-ga2.
  • hashCode/equals consistency. DefaultArtifactVersion.equals is defined via compareTo, but hashCode was structural, so two order-equal versions could hash differently. HashSet and TreeSet built from the same pair disagreed on size. equals and compareTo are unchanged, so no resolution outcome moves.
  • Parsing bound. ComparableVersion now rejects version strings longer than 256 characters. Nested - separators recurse per level and long digit runs cost quadratic time in BigInteger; on a reduced worker-thread stack the cold overflow floor is a few hundred levels. Nothing in this repository has a version string over 40 characters. Measurements are in the commit body.

Each fix is a separate commit and independently revertible.

Every '-' in a version string nests another list item, so comparison,
equality, hash code and canonicalization recurse one frame per level;
very long, deeply hyphenated version strings can exhaust the stack, and
very long digit runs cost quadratic time to parse into BigInteger.

Cap parseVersion's input at 256 characters, far beyond any real-world
version identifier, so all three costs stay bounded in one place.

Measurements behind the 256-character bound (this platform: macOS/
aarch64, one representative JDK; plausibly JDK- and platform-dependent,
worth re-checking on the Linux CI JVM before treating these thresholds
as authoritative elsewhere):
  - JIT-warmed, deep-vs-deep hashCode/equals/getCanonical overflow
    around 500 nested levels at a 256-500k thread stack size. (A
    comparison against a short, non-nested version such as "1" is not
    representative: it returns early on the first mismatched item type
    and never recurses, so it understates the risk.)
  - Cold, interpreter-only execution (-Xint, one shot per data point,
    representative of a freshly started JVM rather than a warmed-up
    long-running one, which matches how this code is normally invoked)
    against the smallest thread stack size the JVM will start at all on
    this platform (208k) overflows at as few as ~240 nested levels -
    below the ~256 levels a 512-character cap would allow. That
    combination is realistic enough (Maven runs as a fresh process, and
    embedders can and do configure small thread stacks) that a
    512-character cap could not be called clearly safe.
  - The same cold/interpreted measurement at a 228k stack overflows
    around 320-330 levels, giving the 256-character cap (~128 levels)
    roughly 2.4x headroom there, and ~1.8x headroom even at the 208k
    floor.

No compatibility cost identified: nothing in this repository's own
source, tests or POMs uses a version string longer than 40 characters,
and known long-form real-world version schemes (git-describe output,
Debian native versions, Eclipse OSGi qualifiers, timestamp-based
builds) stay well under 100.
DefaultArtifactVersion.equals is defined as compareTo() == 0 (so, for
example, "1-ga" equals "1"), but its hashCode delegated to
ComparableVersion's structural hash, which does not agree: "1-ga" and
"1" parse to different item trees and therefore hash differently,
violating the equals/hashCode contract and breaking hash-based
collections built over repository version lists.

Add ComparableVersion.orderingHashCode(), a hash derived from the same
comparison rules compareTo() already uses (trailing null-equivalent
items dropped, qualifiers normalized), and have DefaultArtifactVersion
use it instead of the structural hash.
… version

CombinationItem.compareTo(null) only consulted the string part, so a
release-equivalent qualifier ("ga", "final", "release") always compared
equal to null regardless of any trailing digit: "1-ga1" compared equal
to "1", and to "1-ga2" as well, while "1-ga1" itself sorts strictly
before "1-ga2". That breaks compareTo's transitivity contract and lets
a range or exact-version restriction match a differently-spelled
version it did not intend to: an exact pin [1.2.3] matched "1.2.3-ga2".

When the string part is release-equivalent, fall through to the digit
part so "1-ga1" sorts strictly after "1", matching the ordering the
surrounding comment already documents. Plain qualifiers with no digit
("1-ga") and a zero digit ("1-ga0") keep comparing equal to "1", as
documented elsewhere in this class.

Includes a range-level regression test demonstrating the exact-pin
scenario directly, alongside the ComparableVersion-level ordering test,
so a future cherry-pick of this fix cannot silently drop the coverage
that guards it.
@slachiewicz slachiewicz added the bug Something isn't working label Aug 30, 2026

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three fixes are correct, well-reasoned, and backed by comprehensive tests. High-quality contribution addressing genuine Comparable contract violations in core Maven infrastructure.

Fix-by-fix assessment:

  1. compareTo transitivity — Correct. Only changes behavior when the string part is release-equivalent AND a non-zero digit follows, matching the documented intent (1-rc1 < 1, 1-ga1 > 1). The exact-version range test ([1.2.3] no longer matches 1.2.3-ga2) is a valuable regression guard.

  2. hashCode/equals consistencyorderingHashCode() correctly mirrors compareTo semantics: trailing null-equivalent items stripped before hashing, release-equivalent qualifiers hash identically via comparableQualifier(), CombinationItem combines qualifier and digit hashes. Traced through multiple cases (1 vs 1-ga, 1-ga1 vs 1-final1, 1-SNAPSHOT vs 1.0-SNAPSHOT) — all produce consistent hashes.

  3. MAX_VERSION_LENGTH bound — Well-justified (commit message provides stack depth measurements). IllegalArgumentException is appropriate — failing fast with a clear message beats stack overflow or quadratic BigInteger parsing.

Pre-existing issue (not introduced by this PR):
CombinationItem.compareTo(StringItem) at ~line 466 unconditionally returns 1 when string parts are equal, without consulting the digit part. This means 1-ga0 > 1-ga even though both compare equal to 1 (same transitivity violation class as fix #1). Consider extending the fix to this case — or a follow-up PR.

📋 PR Metadata

Aspect Current Suggested
Milestone (none) 4.1.0

🔀 Backport Status

⚠️ All three fixes apply directly to maven-4.0.x (same CombinationItem code). Fixes #2 and #3 also affect maven-3.9.x and maven-3.10.x but would require adapted implementations since CombinationItem does not exist on those branches.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of Guillaume Nodet

@elharo elharo left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we impose a length restriction on version qualifiers we need to document that on the site, though I don't think you can make those changes in this PR since the repos are different. However, that PR should be ready to go.


/**
* Maximum accepted length of a version string. Version strings routinely come from external
* repository metadata; without a bound, every {@code -} separator nests another list whose

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metadata; without --> metadata. Without

/**
* Maximum accepted length of a version string. Version strings routinely come from external
* repository metadata; without a bound, every {@code -} separator nests another list whose
* comparison, equality, hash code and canonicalization recurse one frame per level, and digit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hash code, and

* at quadratic cost. 256 characters is far beyond any real-world version identifier while
* keeping the nesting depth (at most about half the length) and numeric items small.
*/
private static final int MAX_VERSION_LENGTH = 256;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. I'd probably take a 50-50 bet that there are longer version strings out there. Would it be feasible to go to 1024? or higher? At what length do we actually have problems?

return stringPart.compareTo(item);
int result = stringPart.compareTo(item);
if (result == 0) {
// the string part is equivalent to the release qualifier ("ga", "final", "release"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have we tested 1-ga2 vs 1-ga11?

}
yield hash;
}
// qualifiers that compare as equal ("ga", "final", "release" and the empty qualifier) must hash alike

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants