github.com/google/osv-scalibr@v0.4.1/semantic/version-maven.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package semantic 16 17 import ( 18 "fmt" 19 "regexp" 20 "sort" 21 "strings" 22 ) 23 24 var ( 25 mavenDigitToNonDigitTransitionFinder = regexp.MustCompile(`\D\d`) 26 mavenNonDigitToDigitTransitionFinder = regexp.MustCompile(`\d\D`) 27 ) 28 29 type mavenVersionToken struct { 30 prefix string 31 value string 32 isNull bool 33 } 34 35 func (vt *mavenVersionToken) qualifierOrder() (int, error) { 36 _, err := convertToBigInt(vt.value) 37 38 if err == nil { 39 if vt.prefix == "-" { 40 return 2, nil 41 } 42 if vt.prefix == "." { 43 return 3, nil 44 } 45 } 46 47 if vt.prefix == "-" { 48 return 1, nil 49 } 50 if vt.prefix == "." { 51 return 0, nil 52 } 53 54 return 0, fmt.Errorf("%w: unknown prefix '%s'", ErrInvalidVersion, vt.prefix) 55 } 56 57 func (vt *mavenVersionToken) shouldTrim() bool { 58 return vt.value == "0" || vt.value == "" || vt.value == "final" || vt.value == "ga" 59 } 60 61 func (vt *mavenVersionToken) equal(wt mavenVersionToken) bool { 62 return vt.prefix == wt.prefix && vt.value == wt.value 63 } 64 65 var keywordOrder = []string{"alpha", "beta", "milestone", "rc", "snapshot", "", "sp"} 66 67 func findKeywordOrder(keyword string) int { 68 for i, k := range keywordOrder { 69 if k == keyword { 70 return i 71 } 72 } 73 74 return len(keywordOrder) 75 } 76 77 func (vt *mavenVersionToken) lessThan(wt mavenVersionToken) (bool, error) { 78 // if the prefix is the same, then compare the token: 79 if vt.prefix == wt.prefix { 80 vv, vErr := convertToBigInt(vt.value) 81 wv, wErr := convertToBigInt(wt.value) 82 83 // numeric tokens have the same natural order 84 if vErr == nil && wErr == nil { 85 return vv.Cmp(wv) == -1, nil 86 } 87 88 // The spec is unclear, but according to Maven's implementation, numerics 89 // sort after non-numerics, **unless it's a null value**. 90 // https://github.com/apache/maven/blob/965aaa53da5c2d814e94a41d37142d0d6830375d/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java#L443 91 if vErr == nil && !vt.isNull { 92 return false, nil 93 } 94 if wErr == nil && !wt.isNull { 95 return true, nil 96 } 97 98 // Non-numeric tokens ("qualifiers") have the alphabetical order, except 99 // for the following tokens which come first in _KEYWORD_ORDER. 100 // 101 // The spec is unclear, but according to Maven's implementation, unknown 102 // qualifiers sort after known qualifiers: 103 // https://github.com/apache/maven/blob/965aaa53da5c2d814e94a41d37142d0d6830375d/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java#L423 104 leftIdx := findKeywordOrder(vt.value) 105 rightIdx := findKeywordOrder(wt.value) 106 107 if leftIdx == len(keywordOrder) && rightIdx == len(keywordOrder) { 108 // Both are unknown qualifiers. Just do a lexical comparison. 109 return vt.value < wt.value, nil 110 } 111 112 return leftIdx < rightIdx, nil 113 } 114 115 // else ".qualifier" < "-qualifier" < "-number" < ".number" 116 return vt.lessThanByQualifier(wt) 117 } 118 119 func (vt *mavenVersionToken) lessThanByQualifier(wt mavenVersionToken) (bool, error) { 120 vo, err := vt.qualifierOrder() 121 if err != nil { 122 return false, err 123 } 124 125 wo, err := wt.qualifierOrder() 126 127 if err != nil { 128 return false, err 129 } 130 131 return vo < wo, nil 132 } 133 134 type mavenVersion struct { 135 tokens []mavenVersionToken 136 } 137 138 func (mv mavenVersion) equal(mw mavenVersion) bool { 139 if len(mv.tokens) != len(mw.tokens) { 140 return false 141 } 142 143 for i := range len(mv.tokens) { 144 if !mv.tokens[i].equal(mw.tokens[i]) { 145 return false 146 } 147 } 148 149 return true 150 } 151 152 func newMavenNullVersionToken(token mavenVersionToken) (mavenVersionToken, error) { 153 if token.prefix == "." { 154 value := "0" 155 156 // "sp" is the only qualifier that comes after an empty value, and because 157 // of the way the comparator is implemented, we have to express that here 158 if token.value == "sp" { 159 value = "" 160 } 161 162 return mavenVersionToken{".", value, true}, nil 163 } 164 if token.prefix == "-" { 165 return mavenVersionToken{"-", "", true}, nil 166 } 167 168 return mavenVersionToken{}, fmt.Errorf("%w: unknown prefix '%s' (value '%s')", ErrInvalidVersion, token.prefix, token.value) 169 } 170 171 func (mv mavenVersion) lessThan(mw mavenVersion) (bool, error) { 172 numberOfTokens := max(len(mv.tokens), len(mw.tokens)) 173 174 var left mavenVersionToken 175 var right mavenVersionToken 176 var err error 177 178 for i := range numberOfTokens { 179 // the shorter one padded with enough "null" values with matching prefix to 180 // have the same length as the longer one. Padded "null" values depend on 181 // the prefix of the other version: 0 for '.', "" for '-' 182 if i >= len(mv.tokens) { 183 left, err = newMavenNullVersionToken(mw.tokens[i]) 184 185 if err != nil { 186 return false, err 187 } 188 } else { 189 left = mv.tokens[i] 190 } 191 192 if i >= len(mw.tokens) { 193 right, err = newMavenNullVersionToken(mv.tokens[i]) 194 195 if err != nil { 196 return false, err 197 } 198 } else { 199 right = mw.tokens[i] 200 } 201 202 // continue padding until the versions are no longer equal, 203 // or are the same length in components 204 if left.equal(right) { 205 continue 206 } 207 208 return left.lessThan(right) 209 } 210 211 return false, nil 212 } 213 214 // Finds every point in a token where it transitions either from a digit to a non-digit or vis versa, 215 // which should be considered as being separated by a hyphen. 216 // 217 // According to Maven's implementation, any non-digit is a "character": 218 // https://github.com/apache/maven/blob/965aaa53da5c2d814e94a41d37142d0d6830375d/maven-artifact/src/main/java/org/apache/maven/artifact/versioning/ComparableVersion.java#L627 219 func mavenFindTransitions(token string) (ints []int) { 220 for _, span := range mavenDigitToNonDigitTransitionFinder.FindAllStringIndex(token, -1) { 221 ints = append(ints, span[0]+1) 222 } 223 224 for _, span := range mavenNonDigitToDigitTransitionFinder.FindAllStringIndex(token, -1) { 225 ints = append(ints, span[0]+1) 226 } 227 228 sort.Ints(ints) 229 230 return ints 231 } 232 233 func splitCharsInclusive(s, chars string) (out []string) { 234 for { 235 m := strings.IndexAny(s, chars) 236 if m < 0 { 237 break 238 } 239 out = append(out, s[:m], s[m:m+1]) 240 s = s[m+1:] 241 } 242 out = append(out, s) 243 244 return 245 } 246 247 func newMavenVersion(str string) mavenVersion { 248 var tokens []mavenVersionToken 249 250 // The Maven coordinate is split in tokens between dots ('.'), hyphens ('-') 251 // and transitions between digits and characters. The prefix is recorded 252 // and will have effect on the order. 253 254 // Split and keep the delimiter. 255 rawTokens := splitCharsInclusive(str, "-.") 256 257 var prefix string 258 259 for i := 0; i < len(rawTokens); i += 2 { 260 if i == 0 { 261 // first token has no preceding prefix 262 prefix = "" 263 } else { 264 // preceding prefix 265 prefix = rawTokens[i-1] 266 } 267 268 transitions := mavenFindTransitions(rawTokens[i]) 269 270 // add the last index so that our algorithm for splitting up the current token works. 271 transitions = append(transitions, len(rawTokens[i])) 272 273 prevIndex := 0 274 275 for j, transition := range transitions { 276 if j > 0 { 277 prefix = "-" 278 } 279 // The spec doesn't say this, but all qualifiers are case-insensitive. 280 current := strings.ToLower(rawTokens[i][prevIndex:transition]) 281 282 if current == "" { 283 // Empty rawTokens are replaced with "0" 284 current = "0" 285 } 286 287 // Normalize "cr" to "rc" for easier comparison since they are equal in precedence. 288 if current == "cr" { 289 current = "rc" 290 } 291 // Also do this for 'ga', 'final' which are equivalent to empty string. 292 // "release" is not part of the spec but is implemented by Maven. 293 if current == "ga" || current == "final" || current == "release" { 294 current = "" 295 } 296 297 // the "alpha", "beta" and "milestone" qualifiers can respectively be 298 // shortened to "a", "b" and "m" when directly followed by a number. 299 if transition != len(rawTokens[i]) { 300 if current == "a" { 301 current = "alpha" 302 } 303 304 if current == "b" { 305 current = "beta" 306 } 307 308 if current == "m" { 309 current = "milestone" 310 } 311 } 312 313 // remove any leading zeros 314 if d, err := convertToBigInt(current); err == nil { 315 current = d.String() 316 } 317 318 tokens = append(tokens, mavenVersionToken{prefix, current, false}) 319 prevIndex = transition 320 } 321 } 322 323 // Then, starting from the end of the version, the trailing "null" values 324 // (0, "", "final", "ga") are trimmed. 325 326 i := len(tokens) - 1 327 328 for i > 0 { 329 if tokens[i].shouldTrim() { 330 tokens = append(tokens[:i], tokens[i+1:]...) 331 i-- 332 333 continue 334 } 335 336 // This process is repeated at each remaining hyphen from end to start 337 for i >= 0 && tokens[i].prefix != "-" { 338 i-- 339 } 340 341 i-- 342 } 343 344 return mavenVersion{tokens} 345 } 346 func (mv mavenVersion) compare(w mavenVersion) (int, error) { 347 if mv.equal(w) { 348 return 0, nil 349 } 350 if lt, err := mv.lessThan(w); lt || err != nil { 351 if err != nil { 352 return 0, err 353 } 354 355 return -1, nil 356 } 357 358 return +1, nil 359 } 360 361 func (mv mavenVersion) CompareStr(str string) (int, error) { 362 return mv.compare(parseMavenVersion(str)) 363 } 364 365 func parseMavenVersion(str string) mavenVersion { 366 return newMavenVersion(str) 367 }