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 }