@@ -44,39 +44,179 @@ class SharedVersionFinder < Dependabot::Package::PackageLatestVersionFinder
4444
4545 MAVEN_SNAPSHOT_QUALIFIER = /-SNAPSHOT$/i
4646
47+ # Minimum and maximum lengths for Git SHAs
48+ MIN_GIT_SHA_LENGTH = 7
49+ MAX_GIT_SHA_LENGTH = 40
50+
51+ # Regex for a valid Git SHA
52+ # - Only hexadecimal characters (0-9, a-f)
53+ # - Case-insensitive
54+ # - At least one letter a-f to avoid purely numeric strings
55+ GIT_COMMIT = T . let (
56+ /\A (?=[0-9a-f]{#{ MIN_GIT_SHA_LENGTH } ,#{ MAX_GIT_SHA_LENGTH } }\z )(?=.*[a-f])/i ,
57+ Regexp
58+ )
59+
4760 sig { params ( comparison_version : Dependabot ::Version ) . returns ( T ::Boolean ) }
4861 def matches_dependency_version_type? ( comparison_version )
4962 return true unless dependency . version
5063
51- current_version_string = dependency . version
52- candidate_version_string = comparison_version . to_s
64+ current = dependency . version
65+ candidate = comparison_version . to_s
66+
67+ return true if pre_release_compatible? ( current , candidate )
5368
54- current_is_pre_release = current_version_string &.match? ( MAVEN_PRE_RELEASE_QUALIFIERS )
55- candidate_is_pre_release = candidate_version_string . match? ( MAVEN_PRE_RELEASE_QUALIFIERS )
69+ return true if upgrade_to_stable? ( current , candidate )
5670
57- # Pre-releases are only compatible with other pre-releases
58- # When this happens, the suffix does not need to match exactly
59- # This allows transitions between 1.0.0-RC1 and 1.0.0-CR2, for example
60- return true if current_is_pre_release && candidate_is_pre_release
71+ suffix_compatible? ( current , candidate )
72+ end
6173
62- current_is_snapshot = current_version_string &.match? ( MAVEN_SNAPSHOT_QUALIFIER )
63- # If the current version is a pre-release or a snapshot, allow upgrading to a stable release
64- # This can help move from pre-release to the stable version that supersedes it,
65- # but this should not happen vice versa as a stable release should not be downgraded to a pre-release
66- return true if ( current_is_pre_release || current_is_snapshot ) && !candidate_is_pre_release
74+ private
6775
68- current_suffix = extract_version_suffix ( current_version_string )
69- candidate_suffix = extract_version_suffix ( candidate_version_string )
76+ # Determines whether two versions have compatible suffixes.
77+ #
78+ # Suffix compatibility is evaluated based on the type of suffix present:
79+ #
80+ # - Java runtime suffixes (JRE/JDK): Must have matching major versions and
81+ # compatible runtime types (JRE can upgrade to JDK, but not vice versa)
82+ #
83+ # - Git commit SHAs: Both versions must contain SHAs (the actual SHA values
84+ # don't need to match, as different commits of the same version are compatible)
85+ #
86+ # - Other suffixes: Must match exactly (e.g., platform identifiers, build tags)
87+ #
88+ # - No suffix: Both versions must have no suffix
89+ #
90+ # @example Java runtime compatibility
91+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre8") # => true (same JRE version)
92+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jdk8") # => true (JRE → JDK upgrade)
93+ # suffix_compatible?("1.0.0.jdk8", "1.0.0.jre8") # => false (JDK → JRE downgrade)
94+ # suffix_compatible?("1.0.0.jre8", "1.0.0.jre11") # => false (version mismatch)
95+ #
96+ # @example Git SHA compatibility
97+ # suffix_compatible?("1.0-a1b2c3d", "1.0-e5f6789") # => true (both have SHAs)
98+ # suffix_compatible?("1.0-a1b2c3d", "1.0.0") # => false (SHA vs. no SHA)
99+ #
100+ # @example Exact suffix matching
101+ # suffix_compatible?("1.0.0-linux", "1.0.0-linux") # => true (exact match)
102+ # suffix_compatible?("1.0.0-linux", "1.0.0-win") # => false (different platform)
103+ # suffix_compatible?("1.0.0", "1.0.0") # => true (both have no suffix)
104+ # suffix_compatible?("1.0.0", "1.0.0-beta") # => false (suffix mismatch)
105+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
106+ def suffix_compatible? ( current , candidate )
107+ current_suffix = extract_version_suffix ( current )
108+ candidate_suffix = extract_version_suffix ( candidate )
70109
71110 if jre_or_jdk? ( current_suffix ) && jre_or_jdk? ( candidate_suffix )
72111 return compatible_java_runtime? ( T . must ( current_suffix ) , T . must ( candidate_suffix ) )
73112 end
74113
114+ return true if contains_git_sha? ( current_suffix ) && contains_git_sha? ( candidate_suffix )
115+
75116 # If both versions share the exact suffix or no suffix, they are compatible
76117 current_suffix == candidate_suffix
77118 end
78119
79- private
120+ # Determines whether a given string is a valid Git commit SHA.
121+ #
122+ # Accepts both short SHAs (7-40 characters) and full SHAs (40 characters).
123+ # Handles versions with a leading 'v' prefix (e.g., "v018aa6b0d3").
124+ #
125+ # @example Valid Git SHAs
126+ # git_sha?("a1b2c3d") # => true (7-char short SHA)
127+ # git_sha?("a1b2c3d4e5f6") # => true (12-char SHA)
128+ # git_sha?("a1b2c3d4e5f67890a1b2c3d4e5f67890a1b2c3d4") # => true (40-char full SHA)
129+ # git_sha?("v018aa6b0d3") # => true (with 'v' prefix)
130+ #
131+ # @example Invalid inputs
132+ # git_sha?("1.2.3") # => false (version number)
133+ # git_sha?("abc") # => false (too short, < 7 chars)
134+ # git_sha?("ghijklm") # => false (invalid hex characters)
135+ # git_sha?(nil) # => false (nil input)
136+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
137+ def git_sha? ( version )
138+ return false unless version
139+
140+ normalized = version . start_with? ( "v" ) ? version [ 1 ..-1 ] : version
141+ !!T . must ( normalized ) . match? ( GIT_COMMIT )
142+ end
143+
144+ # Determines whether a version string contains a Git commit SHA.
145+ #
146+ # This method checks if any part of a version string (when split by common
147+ # delimiters like '-', '.', or '_') is a valid Git SHA. It also handles
148+ # cases where delimiters within the SHA itself have been replaced with
149+ # underscores or other characters.
150+
151+ # @example Standard delimiter-separated SHAs
152+ # contains_git_sha?("1.0.0-a1b2c3d") # => true (SHA after hyphen)
153+ # contains_git_sha?("2.3.4.a1b2c3d4e5") # => true (SHA after dot)
154+ # contains_git_sha?("v1.2_a1b2c3d") # => true (SHA after underscore)
155+ #
156+ # @example Embedded SHAs with modified delimiters
157+ # contains_git_sha?("va_b_018a_a_6b_0d3") # => true (SHA with underscores replacing chars)
158+ # contains_git_sha?("1.0.a.1.b.2.c.3.d") # => true (SHA scattered across segments)
159+ #
160+ # @example Non-SHA versions
161+ # contains_git_sha?("1.2.3") # => false (regular version)
162+ # contains_git_sha?("abc") # => false (too short)
163+ # contains_git_sha?(nil) # => false (nil input)
164+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
165+ def contains_git_sha? ( version )
166+ return false unless version
167+
168+ # Check if any delimiter-separated part is a SHA
169+ version . split ( /[-._]/ ) . any? { |part | git_sha? ( part ) } ||
170+ # Check if removing delimiters reveals a SHA (e.g., "va_b_018a_a_6b_0d3")
171+ git_sha? ( version . gsub ( /[-._]/ , "" ) )
172+ end
173+
174+ # Determines whether two versions are compatible based on pre-release status.
175+ #
176+ # Two versions are considered compatible if both are pre-release versions.
177+ # This allows upgrades between different pre-release qualifiers of the same
178+ # base version (e.g., RC1 → CR2, ALPHA → BETA)
179+ #
180+ # @example Compatible pre-release transitions
181+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-RC2") # => true (same qualifier)
182+ # pre_release_compatible?("1.0.0-RC1", "1.0.0-CR2") # => true (different qualifier, same stage)
183+ # pre_release_compatible?("2.0.0-ALPHA", "2.0.0-BETA") # => true (progression)
184+ # pre_release_compatible?("1.5-M1", "1.5-MILESTONE2") # => true (equivalent qualifiers)
185+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
186+ def pre_release_compatible? ( current , candidate )
187+ pre_release? ( current ) && pre_release? ( candidate )
188+ end
189+
190+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
191+ def pre_release? ( version )
192+ version &.match? ( MAVEN_PRE_RELEASE_QUALIFIERS ) || false
193+ end
194+
195+ sig { params ( version : T . nilable ( String ) ) . returns ( T ::Boolean ) }
196+ def snapshot? ( version )
197+ version &.match? ( MAVEN_SNAPSHOT_QUALIFIER ) || false
198+ end
199+
200+ # This method allows upgrades from unstable versions (pre-releases or snapshots)
201+ # to stable releases, which is a common and expected upgrade path.
202+ # However, it prevents downgrades from stable releases back to pre-releases,
203+ # as this would violate semantic versioning expectations.
204+ #
205+ # @example Valid upgrades to stable
206+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0") # => true (pre-release → stable)
207+ # upgrade_to_stable?("2.0.0-SNAPSHOT", "2.0.0") # => true (snapshot → stable)
208+ # upgrade_to_stable?("1.5-BETA", "1.5") # => true (beta → stable)
209+ # upgrade_to_stable?("3.0.0-ALPHA2", "3.0.0-FINAL") # => true (pre-release → release qualifier)
210+ #
211+ # @example Invalid transitions (returns false)
212+ # upgrade_to_stable?("1.0.0", "1.0.1-RC1") # => false (stable → pre-release not allowed)
213+ # upgrade_to_stable?("2.0.0", "2.1.0") # => false (stable → stable, use other logic)
214+ # upgrade_to_stable?("1.0.0-RC1", "1.0.0-BETA") # => false (pre-release → pre-release)
215+ # upgrade_to_stable?(nil, "1.0.0") # => false (no current version)
216+ sig { params ( current : T . nilable ( String ) , candidate : String ) . returns ( T ::Boolean ) }
217+ def upgrade_to_stable? ( current , candidate )
218+ ( pre_release? ( current ) || snapshot? ( current ) ) && !pre_release? ( candidate )
219+ end
80220
81221 # Determines whether two Java runtime suffixes are compatible.
82222 #
@@ -182,8 +322,7 @@ def extract_version_suffix(version_string)
182322 # e.g., "1.0.0-1" or "1.0.0_2" are not considered to have a meaningful suffix
183323 return nil if suffix . match? ( /^_?\d +$/ )
184324
185- # Must contain a hyphen to be considered a valid suffix
186- return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" )
325+ return suffix if suffix . include? ( "-" ) || suffix . include? ( "_" ) || git_sha? ( suffix )
187326 end
188327
189328 nil
0 commit comments