github.com/google/osv-scalibr@v0.4.1/semantic/version-redhat.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  	"strings"
    19  )
    20  
    21  type redHatVersion struct {
    22  	epoch   string
    23  	version string
    24  	release string
    25  }
    26  
    27  // isOnlyDigits returns true if the given string contains only digits
    28  func isOnlyDigits(str string) bool {
    29  	for _, c := range str {
    30  		if !isASCIIDigit(c) {
    31  			return false
    32  		}
    33  	}
    34  	return true
    35  }
    36  
    37  // shouldBeTrimmed checks if the given rune should be trimmed when parsing redHatVersion components
    38  func shouldBeTrimmed(r rune) bool {
    39  	return !isASCIILetter(r) && !isASCIIDigit(r) && r != '~' && r != '^'
    40  }
    41  
    42  // compareRedHatComponents compares two components of a redHatVersion in the same
    43  // manner as rpmvercmp(8) does.
    44  func compareRedHatComponents(a, b string) int {
    45  	if a == "" && b != "" {
    46  		return -1
    47  	}
    48  	if a != "" && b == "" {
    49  		return +1
    50  	}
    51  
    52  	var ai, bi int
    53  
    54  	for {
    55  		// 1. Trim anything that’s not [A-Za-z0-9], a tilde (~), or a caret (^) from the front of both strings.
    56  		for ai < len(a) && shouldBeTrimmed(rune(a[ai])) {
    57  			ai++
    58  		}
    59  
    60  		for bi < len(b) && shouldBeTrimmed(rune(b[bi])) {
    61  			bi++
    62  		}
    63  
    64  		// 2. If both strings start with a tilde, discard it and move on to the next character.
    65  		aStartsWithTilde := ai < len(a) && a[ai] == '~'
    66  		bStartsWithTilde := bi < len(b) && b[bi] == '~'
    67  
    68  		if aStartsWithTilde && bStartsWithTilde {
    69  			ai++
    70  			bi++
    71  
    72  			continue
    73  		}
    74  
    75  		// 3. If string `a` starts with a tilde and string `b` does not, return -1 (string `a` is older); and the inverse if string `b` starts with a tilde and string `a` does not.
    76  		if aStartsWithTilde {
    77  			return -1
    78  		}
    79  		if bStartsWithTilde {
    80  			return +1
    81  		}
    82  
    83  		// 4. If both strings start with a caret, discard it and move on to the next character.
    84  		aStartsWithCaret := ai < len(a) && a[ai] == '^'
    85  		bStartsWithCaret := bi < len(b) && b[bi] == '^'
    86  
    87  		if aStartsWithCaret && bStartsWithCaret {
    88  			ai++
    89  			bi++
    90  
    91  			continue
    92  		}
    93  
    94  		// 5. if string `a` starts with a caret and string `b` does not, return -1 (string `a` is older) unless string `b` has reached zero length, in which case return +1 (string `a` is newer); and the inverse if string `b` starts with a caret and string `a` does not.
    95  		if aStartsWithCaret {
    96  			if bi == len(b) {
    97  				return +1
    98  			}
    99  
   100  			return -1
   101  		}
   102  		if bStartsWithCaret {
   103  			if ai == len(a) {
   104  				return -1
   105  			}
   106  
   107  			return +1
   108  		}
   109  
   110  		// 6. End the loop if either string has reached zero length.
   111  		if ai == len(a) || bi == len(b) {
   112  			break
   113  		}
   114  
   115  		// 7. If the first character of `a` is a digit, pop the leading chunk of continuous digits from each string (which may be "" for `b` if only one `a` starts with digits). If `a` begins with a letter, do the same for leading letters.
   116  		isDigit := isASCIIDigit(rune(a[ai]))
   117  
   118  		var isExpectedRunType func(r rune) bool
   119  		if isDigit {
   120  			isExpectedRunType = isASCIIDigit
   121  		} else {
   122  			isExpectedRunType = isASCIILetter
   123  		}
   124  
   125  		var asb, bsb strings.Builder
   126  
   127  		for _, c := range a[ai:] {
   128  			if !isExpectedRunType(c) {
   129  				break
   130  			}
   131  
   132  			asb.WriteRune(c)
   133  			ai++
   134  		}
   135  
   136  		for _, c := range b[bi:] {
   137  			if !isExpectedRunType(c) {
   138  				break
   139  			}
   140  
   141  			bsb.WriteRune(c)
   142  			bi++
   143  		}
   144  
   145  		// 8. If the segment from `b` had 0 length, return 1 if the segment from `a` was numeric, or -1 if it was alphabetic. The logical result of this is that if `a` begins with numbers and `b` does not, `a` is newer (return 1). If `a` begins with letters and `b` does not, then `a` is older (return -1). If the leading character(s) from `a` and `b` were both numbers or both letters, continue on.
   146  		if bsb.Len() == 0 {
   147  			if isDigit {
   148  				return +1
   149  			}
   150  
   151  			return -1
   152  		}
   153  
   154  		as := asb.String()
   155  		bs := bsb.String()
   156  
   157  		// 9. If the leading segments were both numeric, discard any leading zeros and whichever one is longer wins. If `a` is longer than `b` (without leading zeroes), return 1, and vice versa. If they’re of the same length, continue on.
   158  		if isDigit {
   159  			as = strings.TrimLeft(as, "0")
   160  			bs = strings.TrimLeft(bs, "0")
   161  
   162  			if len(as) > len(bs) {
   163  				return +1
   164  			}
   165  			if len(as) < len(bs) {
   166  				return -1
   167  			}
   168  		}
   169  
   170  		// 10. compare the leading segments with strcmp() (or <=> in Ruby). If that returns a non-zero value, then return that value. Else continue to the next iteration of the loop.
   171  		if diff := strings.Compare(as, bs); diff != 0 {
   172  			return diff
   173  		}
   174  	}
   175  
   176  	// If the loop ended (nothing has been returned yet, either both strings are totally the same or they’re the same up to the end of one of them, like with “1.2.3” and “1.2.3b”), then the longest wins - if what’s left of a is longer than what’s left of b, return 1. Vice-versa for if what’s left of b is longer than what’s left of a. And finally, if what’s left of them is the same length, return 0.
   177  	al := len(a) - ai
   178  	bl := len(b) - bi
   179  
   180  	if al > bl {
   181  		return +1
   182  	}
   183  	if al < bl {
   184  		return -1
   185  	}
   186  
   187  	return 0
   188  }
   189  
   190  func (v redHatVersion) compare(w redHatVersion) int {
   191  	if diff := compareRedHatComponents(v.epoch, w.epoch); diff != 0 {
   192  		return diff
   193  	}
   194  	if diff := compareRedHatComponents(v.version, w.version); diff != 0 {
   195  		return diff
   196  	}
   197  	if diff := compareRedHatComponents(v.release, w.release); diff != 0 {
   198  		return diff
   199  	}
   200  
   201  	return 0
   202  }
   203  
   204  func (v redHatVersion) CompareStr(str string) (int, error) {
   205  	return v.compare(parseRedHatVersion(str)), nil
   206  }
   207  
   208  // parseRedHatVersion parses a Red Hat version into a redHatVersion struct.
   209  //
   210  // A Red Hat version contains the following components:
   211  // - epoch, represented as "e"
   212  // - version, represented as "v"
   213  // - release, represented as "r"
   214  //
   215  // When all components are present, the version is represented as "e:v-r",
   216  // though only the version is actually required.
   217  func parseRedHatVersion(str string) redHatVersion {
   218  	epoch, vr, hasColon := strings.Cut(str, ":")
   219  
   220  	// if there's not a colon, or the "epoch" value has characters other than digits,
   221  	// then the string does not have an epoch value
   222  	if !hasColon || !isOnlyDigits(epoch) {
   223  		vr = str
   224  		epoch = ""
   225  	}
   226  
   227  	version, release, hasRelease := strings.Cut(vr, "-")
   228  
   229  	if hasRelease {
   230  		release = "-" + release
   231  	}
   232  
   233  	if epoch == "" {
   234  		epoch = "0"
   235  	}
   236  
   237  	return redHatVersion{epoch, version, release}
   238  }