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  }