From f691fd2d4931b5afa177ef1f74e39c0eea18aed5 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 10:39:06 +1300 Subject: [PATCH 01/33] feat: create redhat version parser --- internal/semantic/compare_test.go | 4 ++ .../semantic/fixtures/redhat-versions.txt | 72 +++++++++++++++++++ internal/semantic/parse.go | 2 + internal/semantic/version-redhat.go | 58 +++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 internal/semantic/fixtures/redhat-versions.txt create mode 100644 internal/semantic/version-redhat.go diff --git a/internal/semantic/compare_test.go b/internal/semantic/compare_test.go index 71f326a915..82e0ae5316 100644 --- a/internal/semantic/compare_test.go +++ b/internal/semantic/compare_test.go @@ -234,6 +234,10 @@ func TestVersion_Compare_Ecosystems(t *testing.T) { name: "Alpine", file: "alpine-versions-generated.txt", }, + { + name: "Red Hat", + file: "redhat-versions.txt", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt new file mode 100644 index 0000000000..683a7ca596 --- /dev/null +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -0,0 +1,72 @@ +1.0 = 1.0 +1.0 < 2.0 +2.0 > 1.0 +2.0.1 = 2.0.1 +2.0 < 2.0.1 +2.0.1 > 2.0 +2.0.1a = 2.0.1a +2.0.1a > 2.0.1 +2.0.1 < 2.0.1a +5.5p1 = 5.5p1 +5.5p1 < 5.5p2 +5.5p2 > 5.5p1 +5.5p10 = 5.5p10 +5.5p1 < 5.5p10 +5.5p10 > 5.5p1 +10xyz < 10.1xyz +10.1xyz > 10xyz +xyz10 = xyz10 +xyz10 < xyz10.1 +xyz10.1 > xyz10 +xyz.4 = xyz.4 +xyz.4 < 8 +8 > xyz.4 +xyz.4 < 2 +2 > xyz.4 +5.5p2 < 5.6p1 +5.6p1 > 5.5p2 +5.6p1 < 6.5p1 +6.5p1 > 5.6p1 +6.0.rc1 > 6.0 +6.0 < 6.0.rc1 +10b2 > 10a1 +10a2 < 10b2 +1.0aa = 1.0aa +1.0a < 1.0aa +1.0aa > 1.0a +10.0001 = 10.0001 +10.0001 = 10.1 +10.1 = 10.0001 +10.0001 < 10.0039 +10.0039 > 10.0001 +4.999.9 < 5.0 +5.0 > 4.999.9 +20101121 = 20101121 +20101121 < 20101122 +20101122 > 20101121 +2_0 = 2_0 +2.0 = 2_0 +2_0 = 2.0 +a = a +a+ = a+ +a+ = a_ +a_ = a+ ++a = +a ++a = _a +_a = +a ++_ = +_ +_+ = +_ +_+ = _+ ++ = _ +_ = + +1.0~rc1 = 1.0~rc1 + +1.0~rc1 < 1.0 +1.0 > 1.0~rc1 + +1.0~rc1 < 1.0~rc2 +1.0~rc2 > 1.0~rc1 + +1.0~rc1~git123 = 1.0~rc1~git123 +1.0~rc1~git123 < 1.0~rc1 +1.0~rc1 < 1.0arc1 diff --git a/internal/semantic/parse.go b/internal/semantic/parse.go index b507ce806e..b774bf4a70 100644 --- a/internal/semantic/parse.go +++ b/internal/semantic/parse.go @@ -48,6 +48,8 @@ func Parse(str string, ecosystem models.Ecosystem) (Version, error) { return parseSemverVersion(str), nil case "PyPI": return parsePyPIVersion(str), nil + case "Red Hat": + return parseRedHatVersion(str), nil case "RubyGems": return parseRubyGemsVersion(str), nil case "Ubuntu": diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go new file mode 100644 index 0000000000..de982b30e7 --- /dev/null +++ b/internal/semantic/version-redhat.go @@ -0,0 +1,58 @@ +package semantic + +import "strings" + +type RedHatVersion struct { + epoch string + version string + release string + arch string +} + +func (v RedHatVersion) CompareStr(str string) int { + panic("implement me") +} + +// ParseRedHatVersion parses a Red Hat version into a RedHatVersion struct. +// +// A Red Hat version contains the following components: +// - name (of the package), represented as "n" +// - epoch, represented as "e" +// - version, represented as "v" +// - release, represented as "r" +// - architecture, represented as "a" +// +// When all components are present, the version is represented as "n-e:v-r.a", +// though only the version is actually required. +func parseRedHatVersion(str string) RedHatVersion { + bf, af, hasColon := strings.Cut(str, ":") + + if !hasColon { + af, bf = bf, af + } + + // (note, we don't actually use the name) + name, epoch, hasName := strings.Cut(bf, "-") + + if !hasName { + epoch, name = name, epoch + } + + middle, arch, _ := strings.Cut(af, ".") + version, release, _ := strings.Cut(middle, "-") + + return RedHatVersion{ + epoch: epoch, + version: version, + release: release, + arch: arch, + } +} + +// - RPM package names are made up of five parts; the package name, epoch, version, release, and architecture (NEVRA) +// - The epoch is not always included; it is assumed to be zero (0) on any packages that lack it explicitly +// - The format for the whole string is n-e:v-r.a +// +// - parsing: +// - If there is a : in the string, everything before it is the epoch. If not, the epoch is zero. +// - If there is a - in the remaining string, everything before the first - is the version, and everything after it is the release. If there isn’t one, the release is considered null/nill/None/whatever. From cec48ac30c1f495750e804b59e9c284f31b6c361 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 13:42:29 +1300 Subject: [PATCH 02/33] feat: create redhat version comparer --- internal/semantic/version-redhat.go | 97 ++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index de982b30e7..0feb33e94d 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -1,6 +1,9 @@ package semantic -import "strings" +import ( + "strings" + "unicode" +) type RedHatVersion struct { epoch string @@ -10,6 +13,98 @@ type RedHatVersion struct { } func (v RedHatVersion) CompareStr(str string) int { + w := parseRedHatVersion(str) + vi := -1 + wi := -1 + + for { + vi++ + wi++ + + // todo: review this position + if vi == len(v.version) || wi == len(w.version) { + break + } + + a := v.version[vi] + b := w.version[wi] + + // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. + // ... + + // 2. If both strings start with a tilde, discard it and move on to the next character. + if a == '~' && b == '~' { + continue + } + + // 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. + if a == '~' { + return -1 + } + if b == '~' { + return +1 + } + + // 4. End the loop if either string has reached zero length. + // ... (see above) ... + + // 5. 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. + var iser func(r rune) bool + if unicode.IsDigit(rune(a)) { + iser = unicode.IsDigit + } else { + iser = unicode.IsLetter + } + + // isDigit := a >= 48 && a <= 57 + ac := "" + bc := "" + + for _, c := range v.version[:vi] { + if !iser(c) { + break + } + + ac += string(c) + vi++ + } + + for _, c := range w.version[:wi] { + if !iser(c) { + break + } + + bc += string(c) + wi++ + } + + // 6. 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. + if bc == "" { + if unicode.IsDigit(rune(a)) { + return +1 + } + + return -1 + } + + // 7. 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. + ac = strings.TrimLeft(ac, "0") + bc = strings.TrimLeft(bc, "0") + + // todo: double check if this length check also applies to alphabetic segments + if len(ac) > len(bc) { + return +1 + } + if len(bc) > len(ac) { + return -1 + } + + // 8. 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. + if diff := strings.Compare(ac, bc); diff != 0 { + return diff + } + } + panic("implement me") } From b75d772d95eea84b45f869194fe658d9532834e2 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 14:41:21 +1300 Subject: [PATCH 03/33] fix: give version priority over arch (and actually ignore arch entirely) --- internal/semantic/version-redhat.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 0feb33e94d..e643799230 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -9,7 +9,6 @@ type RedHatVersion struct { epoch string version string release string - arch string } func (v RedHatVersion) CompareStr(str string) int { @@ -130,18 +129,12 @@ func parseRedHatVersion(str string) RedHatVersion { name, epoch, hasName := strings.Cut(bf, "-") if !hasName { - epoch, name = name, epoch + epoch = name } - middle, arch, _ := strings.Cut(af, ".") - version, release, _ := strings.Cut(middle, "-") + version, release, _ := strings.Cut(af, "-") - return RedHatVersion{ - epoch: epoch, - version: version, - release: release, - arch: arch, - } + return RedHatVersion{epoch, version, release} } // - RPM package names are made up of five parts; the package name, epoch, version, release, and architecture (NEVRA) From a86d72f97daa615da9d294719e914489b8982cbe Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 14:43:22 +1300 Subject: [PATCH 04/33] fix: trim characters before continuing --- internal/semantic/version-redhat.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index e643799230..b392b4fff9 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -29,7 +29,21 @@ func (v RedHatVersion) CompareStr(str string) int { b := w.version[wi] // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. - // ... + for { + if unicode.IsLetter(rune(a)) || unicode.IsDigit(rune(a)) || a == '~' { + break + } + vi++ + a = v.version[vi] + } + + for { + if unicode.IsLetter(rune(b)) || unicode.IsDigit(rune(b)) || b == '~' { + break + } + wi++ + b = w.version[wi] + } // 2. If both strings start with a tilde, discard it and move on to the next character. if a == '~' && b == '~' { From a1c8f517d70aefb652f695176a9db0ed8b334153 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 14:49:22 +1300 Subject: [PATCH 05/33] fix: use the remainder of the version --- internal/semantic/version-redhat.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index b392b4fff9..ad566667be 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -73,7 +73,7 @@ func (v RedHatVersion) CompareStr(str string) int { ac := "" bc := "" - for _, c := range v.version[:vi] { + for _, c := range v.version[vi:] { if !iser(c) { break } @@ -82,7 +82,7 @@ func (v RedHatVersion) CompareStr(str string) int { vi++ } - for _, c := range w.version[:wi] { + for _, c := range w.version[wi:] { if !iser(c) { break } From 814c380998e6c38158d15ff61c88995733cbb19a Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 15:10:17 +1300 Subject: [PATCH 06/33] fix: roll the indexes back before ending subsegment parsing --- internal/semantic/version-redhat.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index ad566667be..3523fe59ef 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -75,6 +75,7 @@ func (v RedHatVersion) CompareStr(str string) int { for _, c := range v.version[vi:] { if !iser(c) { + vi-- break } @@ -84,6 +85,7 @@ func (v RedHatVersion) CompareStr(str string) int { for _, c := range w.version[wi:] { if !iser(c) { + wi-- break } From e11762dce3cd99a4267ba27fbd505f2ee2099cac Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 15:27:51 +1300 Subject: [PATCH 07/33] fix: handle reaching the end of a version string --- internal/semantic/version-redhat.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 3523fe59ef..f04e87f18a 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -21,7 +21,7 @@ func (v RedHatVersion) CompareStr(str string) int { wi++ // todo: review this position - if vi == len(v.version) || wi == len(w.version) { + if vi >= len(v.version) || wi >= len(w.version) { break } @@ -120,7 +120,16 @@ func (v RedHatVersion) CompareStr(str string) int { } } - panic("implement me") + // 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. + if len(v.version) > len(w.version) { + return +1 + } + + if len(v.version) < len(w.version) { + return -1 + } + + return 0 } // ParseRedHatVersion parses a Red Hat version into a RedHatVersion struct. From f49cce040b078a883eb5f588eecd30de88862b36 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Fri, 25 Oct 2024 15:29:44 +1300 Subject: [PATCH 08/33] fix: properly compare the remaining length of version strings --- internal/semantic/version-redhat.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index f04e87f18a..081af13928 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -121,11 +121,13 @@ func (v RedHatVersion) CompareStr(str string) int { } // 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. - if len(v.version) > len(w.version) { + vl := len(v.version) - vi + wl := len(w.version) - wi + + if vl > wl { return +1 } - - if len(v.version) < len(w.version) { + if vl < wl { return -1 } From 3fa3b337b4251cd0c5a1f629aa9728db11631e49 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 09:20:00 +1300 Subject: [PATCH 09/33] feat: focus on the version string index rather than the current character --- internal/semantic/version-redhat.go | 52 ++++++++++++++--------------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 081af13928..6ce31ce093 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -11,71 +11,70 @@ type RedHatVersion struct { release string } +func shouldBeTrimmed(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '~' +} + func (v RedHatVersion) CompareStr(str string) int { w := parseRedHatVersion(str) - vi := -1 - wi := -1 - - for { - vi++ - wi++ - // todo: review this position - if vi >= len(v.version) || wi >= len(w.version) { - break - } - - a := v.version[vi] - b := w.version[wi] + var vi, wi int + for { // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. for { - if unicode.IsLetter(rune(a)) || unicode.IsDigit(rune(a)) || a == '~' { + if vi == len(v.version) || !shouldBeTrimmed(rune(v.version[vi])) { break } vi++ - a = v.version[vi] } for { - if unicode.IsLetter(rune(b)) || unicode.IsDigit(rune(b)) || b == '~' { + if wi == len(w.version) || !shouldBeTrimmed(rune(w.version[wi])) { break } wi++ - b = w.version[wi] } // 2. If both strings start with a tilde, discard it and move on to the next character. - if a == '~' && b == '~' { + vStartsWithTilde := vi < len(v.version) && v.version[vi] == '~' + wStartsWithTilde := wi < len(w.version) && w.version[wi] == '~' + + if vStartsWithTilde && wStartsWithTilde { + vi++ + wi++ + continue } // 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. - if a == '~' { + if vStartsWithTilde { return -1 } - if b == '~' { + if wStartsWithTilde { return +1 } // 4. End the loop if either string has reached zero length. - // ... (see above) ... + if vi == len(v.version) || wi == len(w.version) { + break + } // 5. 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. + isDigit := unicode.IsDigit(rune(v.version[vi])) + var iser func(r rune) bool - if unicode.IsDigit(rune(a)) { + if isDigit { iser = unicode.IsDigit } else { iser = unicode.IsLetter } // isDigit := a >= 48 && a <= 57 - ac := "" - bc := "" + var ac, bc string for _, c := range v.version[vi:] { if !iser(c) { - vi-- break } @@ -85,7 +84,6 @@ func (v RedHatVersion) CompareStr(str string) int { for _, c := range w.version[wi:] { if !iser(c) { - wi-- break } @@ -95,7 +93,7 @@ func (v RedHatVersion) CompareStr(str string) int { // 6. 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. if bc == "" { - if unicode.IsDigit(rune(a)) { + if isDigit { return +1 } From 7892a7f1d68d259bda3cd46815ace209e3cdf75a Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 13:01:00 +1300 Subject: [PATCH 10/33] fix: compare epochs --- internal/semantic/fixtures/redhat-versions.txt | 17 +++++++++++++++++ internal/semantic/version-redhat.go | 11 +++++++++++ 2 files changed, 28 insertions(+) diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt index 683a7ca596..9a368612e8 100644 --- a/internal/semantic/fixtures/redhat-versions.txt +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -70,3 +70,20 @@ _ = + 1.0~rc1~git123 = 1.0~rc1~git123 1.0~rc1~git123 < 1.0~rc1 1.0~rc1 < 1.0arc1 + +# epochs +1:1 = 1:1 +0:1 < 1:1 +0:1 < 1:2 +0:1~1 < 1:2 +3:1~1 > 1:2 +1~1 < 1:2 +3 < 1:2 +13 < 14:2 +13:5 < 14:2 +13:5 > 04:2 +13:5 > 004:2 +013:5 > 004:2 +130:5 > 004:2 +184:2 > 177:5 +01:2 = 1:2 diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 6ce31ce093..305977df56 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -18,6 +18,13 @@ func shouldBeTrimmed(r rune) bool { func (v RedHatVersion) CompareStr(str string) int { w := parseRedHatVersion(str) + if v.epoch != w.epoch { + ve := convertToBigIntOrPanic(v.epoch) + we := convertToBigIntOrPanic(w.epoch) + + return ve.Cmp(we) + } + var vi, wi int for { @@ -159,6 +166,10 @@ func parseRedHatVersion(str string) RedHatVersion { version, release, _ := strings.Cut(af, "-") + if epoch == "" { + epoch = "0" + } + return RedHatVersion{epoch, version, release} } From c62d27d841e9934f7ab773968dbadd5dc73b668a Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 13:39:25 +1300 Subject: [PATCH 11/33] refactor: encapsulate logic for comparing each component --- internal/semantic/version-redhat.go | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 305977df56..9244fa6a1a 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -15,16 +15,18 @@ func shouldBeTrimmed(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '~' } -func (v RedHatVersion) CompareStr(str string) int { - w := parseRedHatVersion(str) +func (v RedHatVersion) compareEpoch(w RedHatVersion) int { + if v.epoch == w.epoch { + return 0 + } - if v.epoch != w.epoch { - ve := convertToBigIntOrPanic(v.epoch) - we := convertToBigIntOrPanic(w.epoch) + ve := convertToBigIntOrPanic(v.epoch) + we := convertToBigIntOrPanic(w.epoch) - return ve.Cmp(we) - } + return ve.Cmp(we) +} +func (v RedHatVersion) compareVersion(w RedHatVersion) int { var vi, wi int for { @@ -139,6 +141,19 @@ func (v RedHatVersion) CompareStr(str string) int { return 0 } +func (v RedHatVersion) CompareStr(str string) int { + w := parseRedHatVersion(str) + + if diff := v.compareEpoch(w); diff != 0 { + return diff + } + if diff := v.compareVersion(w); diff != 0 { + return diff + } + + return 0 +} + // ParseRedHatVersion parses a Red Hat version into a RedHatVersion struct. // // A Red Hat version contains the following components: From 63ea274034e97375e5df59858f35c41fbe1ef146 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 13:46:47 +1300 Subject: [PATCH 12/33] feat: add basic support for comparing by release --- internal/semantic/version-redhat.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 9244fa6a1a..a4bf8cd58e 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -141,6 +141,14 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { return 0 } +func (v RedHatVersion) compareRelease(w RedHatVersion) int { + if v.release == w.release { + return 0 + } + + panic("properly comparing Red Hat versions by release is not yet supported") +} + func (v RedHatVersion) CompareStr(str string) int { w := parseRedHatVersion(str) @@ -150,6 +158,9 @@ func (v RedHatVersion) CompareStr(str string) int { if diff := v.compareVersion(w); diff != 0 { return diff } + if diff := v.compareRelease(w); diff != 0 { + return diff + } return 0 } From 74bd05ce0ccf758b5fb209e3fe57f6937e98afcd Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 14:08:51 +1300 Subject: [PATCH 13/33] feat: support properly comparing releases --- .../semantic/fixtures/redhat-versions.txt | 9 ++ internal/semantic/version-redhat.go | 113 +++++++++++++++++- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt index 9a368612e8..0814c9a748 100644 --- a/internal/semantic/fixtures/redhat-versions.txt +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -87,3 +87,12 @@ _ = + 130:5 > 004:2 184:2 > 177:5 01:2 = 1:2 + +# releases +1-123 > 1-2 +1-1 = 1-1 +1-2 > 1-1 +1 < 1-1 +1 < 1-0 +1 < 1- +1- = 1- diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index a4bf8cd58e..2ad7f68593 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -146,7 +146,118 @@ func (v RedHatVersion) compareRelease(w RedHatVersion) int { return 0 } - panic("properly comparing Red Hat versions by release is not yet supported") + var vi, wi int + + for { + // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. + for { + if vi == len(v.release) || !shouldBeTrimmed(rune(v.release[vi])) { + break + } + vi++ + } + + for { + if wi == len(w.release) || !shouldBeTrimmed(rune(w.release[wi])) { + break + } + wi++ + } + + // 2. If both strings start with a tilde, discard it and move on to the next character. + vStartsWithTilde := vi < len(v.release) && v.release[vi] == '~' + wStartsWithTilde := wi < len(w.release) && w.release[wi] == '~' + + if vStartsWithTilde && wStartsWithTilde { + vi++ + wi++ + + continue + } + + // 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. + if vStartsWithTilde { + return -1 + } + if wStartsWithTilde { + return +1 + } + + // 4. End the loop if either string has reached zero length. + if vi == len(v.release) || wi == len(w.release) { + break + } + + // 5. 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. + isDigit := unicode.IsDigit(rune(v.release[vi])) + + var iser func(r rune) bool + if isDigit { + iser = unicode.IsDigit + } else { + iser = unicode.IsLetter + } + + // isDigit := a >= 48 && a <= 57 + var ac, bc string + + for _, c := range v.release[vi:] { + if !iser(c) { + break + } + + ac += string(c) + vi++ + } + + for _, c := range w.release[wi:] { + if !iser(c) { + break + } + + bc += string(c) + wi++ + } + + // 6. 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. + if bc == "" { + if isDigit { + return +1 + } + + return -1 + } + + // 7. 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. + ac = strings.TrimLeft(ac, "0") + bc = strings.TrimLeft(bc, "0") + + // todo: double check if this length check also applies to alphabetic segments + if len(ac) > len(bc) { + return +1 + } + if len(bc) > len(ac) { + return -1 + } + + // 8. 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. + if diff := strings.Compare(ac, bc); diff != 0 { + return diff + } + } + + // 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. + vl := len(v.release) - vi + wl := len(w.release) - wi + + if vl > wl { + return +1 + } + if vl < wl { + return -1 + } + + return 0 } func (v RedHatVersion) CompareStr(str string) int { From 6c66a34d1661c9aacf38360407e557815f900e46 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 14:23:34 +1300 Subject: [PATCH 14/33] fix: preserve the leading `-` in the release value to indicate a release was present --- internal/semantic/version-redhat.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 2ad7f68593..6cf5e50abb 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -145,6 +145,12 @@ func (v RedHatVersion) compareRelease(w RedHatVersion) int { if v.release == w.release { return 0 } + if v.release == "" && w.release != "" { + return -1 + } + if v.release != "" && w.release == "" { + return +1 + } var vi, wi int @@ -301,7 +307,11 @@ func parseRedHatVersion(str string) RedHatVersion { epoch = name } - version, release, _ := strings.Cut(af, "-") + version, release, hasRelease := strings.Cut(af, "-") + + if hasRelease { + release = "-" + release + } if epoch == "" { epoch = "0" From d9ba5ea871b1b812a28a01fc370a3bb1b3ede74c Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:32:02 +1300 Subject: [PATCH 15/33] test: add cases for architecture --- .../semantic/fixtures/redhat-versions.txt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt index 0814c9a748..d163e596bc 100644 --- a/internal/semantic/fixtures/redhat-versions.txt +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -96,3 +96,26 @@ _ = + 1 < 1-0 1 < 1- 1- = 1- + +1-123 > 1-1.el7 + +# arch +0:3.10.0-229.el7 < 0:3.10.0-229.1.2.ael7b +0:2.4.21-9.EL < 0:2.4.21-9.0.1.EL + +0:3.10.0-229.el7 < 0:3.10.0-229.1.2.ael7b +1-1.a > 1-1. +1-1. = 1-1. +1-1. = 1-1 +1-1 < 1-1.1 +1. = 1 +1-1.a = 1-1.a +1-1.a < 1-1.e +1-1.c > 1-1.b +1-1.4 > 1-1.3 +1-1.1 < 1-1.2 +1-1.ael7b < 1-1.el7 +0:3.10.0-229.1.2.ael7b < 0:3.10.0-229.1.2.el7 + +1 < 1- +0:3.10.0-229.1.2.ael7b < 0:3.10.0-229.4.2.ael7b From a1a8d73e6f044ff3e64c8cc6443a80c8cb9a2b62 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:32:44 +1300 Subject: [PATCH 16/33] fix: only do trimming and length comparing if segments are digits --- internal/semantic/version-redhat.go | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 6cf5e50abb..c2b9da9ef5 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -110,15 +110,16 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { } // 7. 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. - ac = strings.TrimLeft(ac, "0") - bc = strings.TrimLeft(bc, "0") + if isDigit { + ac = strings.TrimLeft(ac, "0") + bc = strings.TrimLeft(bc, "0") - // todo: double check if this length check also applies to alphabetic segments - if len(ac) > len(bc) { - return +1 - } - if len(bc) > len(ac) { - return -1 + if len(ac) > len(bc) { + return +1 + } + if len(bc) > len(ac) { + return -1 + } } // 8. 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. @@ -235,15 +236,16 @@ func (v RedHatVersion) compareRelease(w RedHatVersion) int { } // 7. 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. - ac = strings.TrimLeft(ac, "0") - bc = strings.TrimLeft(bc, "0") + if isDigit { + ac = strings.TrimLeft(ac, "0") + bc = strings.TrimLeft(bc, "0") - // todo: double check if this length check also applies to alphabetic segments - if len(ac) > len(bc) { - return +1 - } - if len(bc) > len(ac) { - return -1 + if len(ac) > len(bc) { + return +1 + } + if len(bc) > len(ac) { + return -1 + } } // 8. 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. From dee4bbe2c172989da725b8b4e1efea7e12487723 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:33:38 +1300 Subject: [PATCH 17/33] refactor: deduplicate code --- internal/semantic/version-redhat.go | 165 +++++----------------------- 1 file changed, 30 insertions(+), 135 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index c2b9da9ef5..9bf8540fc5 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -15,39 +15,30 @@ func shouldBeTrimmed(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '~' } -func (v RedHatVersion) compareEpoch(w RedHatVersion) int { - if v.epoch == w.epoch { - return 0 - } - - ve := convertToBigIntOrPanic(v.epoch) - we := convertToBigIntOrPanic(w.epoch) - - return ve.Cmp(we) -} - -func (v RedHatVersion) compareVersion(w RedHatVersion) int { +// compareRedHatComponents compares two components of a RedHatVersion in the same +// manner as rpmvercmp(8) does. +func compareRedHatComponents(a, b string) int { var vi, wi int for { // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. for { - if vi == len(v.version) || !shouldBeTrimmed(rune(v.version[vi])) { + if vi == len(a) || !shouldBeTrimmed(rune(a[vi])) { break } vi++ } for { - if wi == len(w.version) || !shouldBeTrimmed(rune(w.version[wi])) { + if wi == len(b) || !shouldBeTrimmed(rune(b[wi])) { break } wi++ } // 2. If both strings start with a tilde, discard it and move on to the next character. - vStartsWithTilde := vi < len(v.version) && v.version[vi] == '~' - wStartsWithTilde := wi < len(w.version) && w.version[wi] == '~' + vStartsWithTilde := vi < len(a) && a[vi] == '~' + wStartsWithTilde := wi < len(b) && b[wi] == '~' if vStartsWithTilde && wStartsWithTilde { vi++ @@ -65,12 +56,12 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { } // 4. End the loop if either string has reached zero length. - if vi == len(v.version) || wi == len(w.version) { + if vi == len(a) || wi == len(b) { break } // 5. 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. - isDigit := unicode.IsDigit(rune(v.version[vi])) + isDigit := unicode.IsDigit(rune(a[vi])) var iser func(r rune) bool if isDigit { @@ -82,7 +73,7 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { // isDigit := a >= 48 && a <= 57 var ac, bc string - for _, c := range v.version[vi:] { + for _, c := range a[vi:] { if !iser(c) { break } @@ -91,7 +82,7 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { vi++ } - for _, c := range w.version[wi:] { + for _, c := range b[wi:] { if !iser(c) { break } @@ -129,8 +120,8 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { } // 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. - vl := len(v.version) - vi - wl := len(w.version) - wi + vl := len(a) - vi + wl := len(b) - wi if vl > wl { return +1 @@ -142,6 +133,22 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { return 0 } + +func (v RedHatVersion) compareEpoch(w RedHatVersion) int { + if v.epoch == w.epoch { + return 0 + } + + ve := convertToBigIntOrPanic(v.epoch) + we := convertToBigIntOrPanic(w.epoch) + + return ve.Cmp(we) +} + +func (v RedHatVersion) compareVersion(w RedHatVersion) int { + return compareRedHatComponents(v.version, w.version) +} + func (v RedHatVersion) compareRelease(w RedHatVersion) int { if v.release == w.release { return 0 @@ -153,119 +160,7 @@ func (v RedHatVersion) compareRelease(w RedHatVersion) int { return +1 } - var vi, wi int - - for { - // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. - for { - if vi == len(v.release) || !shouldBeTrimmed(rune(v.release[vi])) { - break - } - vi++ - } - - for { - if wi == len(w.release) || !shouldBeTrimmed(rune(w.release[wi])) { - break - } - wi++ - } - - // 2. If both strings start with a tilde, discard it and move on to the next character. - vStartsWithTilde := vi < len(v.release) && v.release[vi] == '~' - wStartsWithTilde := wi < len(w.release) && w.release[wi] == '~' - - if vStartsWithTilde && wStartsWithTilde { - vi++ - wi++ - - continue - } - - // 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. - if vStartsWithTilde { - return -1 - } - if wStartsWithTilde { - return +1 - } - - // 4. End the loop if either string has reached zero length. - if vi == len(v.release) || wi == len(w.release) { - break - } - - // 5. 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. - isDigit := unicode.IsDigit(rune(v.release[vi])) - - var iser func(r rune) bool - if isDigit { - iser = unicode.IsDigit - } else { - iser = unicode.IsLetter - } - - // isDigit := a >= 48 && a <= 57 - var ac, bc string - - for _, c := range v.release[vi:] { - if !iser(c) { - break - } - - ac += string(c) - vi++ - } - - for _, c := range w.release[wi:] { - if !iser(c) { - break - } - - bc += string(c) - wi++ - } - - // 6. 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. - if bc == "" { - if isDigit { - return +1 - } - - return -1 - } - - // 7. 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. - if isDigit { - ac = strings.TrimLeft(ac, "0") - bc = strings.TrimLeft(bc, "0") - - if len(ac) > len(bc) { - return +1 - } - if len(bc) > len(ac) { - return -1 - } - } - - // 8. 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. - if diff := strings.Compare(ac, bc); diff != 0 { - return diff - } - } - - // 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. - vl := len(v.release) - vi - wl := len(w.release) - wi - - if vl > wl { - return +1 - } - if vl < wl { - return -1 - } - - return 0 + return compareRedHatComponents(v.release, w.release) } func (v RedHatVersion) CompareStr(str string) int { From d2a87bc2c0b9dd9cb7590d63d0ce6d306949a049 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:37:19 +1300 Subject: [PATCH 18/33] chore: clean up comments --- internal/semantic/version-redhat.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 9bf8540fc5..0359d8f516 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -70,7 +70,6 @@ func compareRedHatComponents(a, b string) int { iser = unicode.IsLetter } - // isDigit := a >= 48 && a <= 57 var ac, bc string for _, c := range a[vi:] { @@ -133,7 +132,7 @@ func compareRedHatComponents(a, b string) int { return 0 } - +// compareEpoch compares the epoch component of two RedHatVersion structs func (v RedHatVersion) compareEpoch(w RedHatVersion) int { if v.epoch == w.epoch { return 0 @@ -145,10 +144,12 @@ func (v RedHatVersion) compareEpoch(w RedHatVersion) int { return ve.Cmp(we) } +// compareVersion compares the version component of two RedHatVersion structs func (v RedHatVersion) compareVersion(w RedHatVersion) int { return compareRedHatComponents(v.version, w.version) } +// compareRelease compares the release component of two RedHatVersion structs func (v RedHatVersion) compareRelease(w RedHatVersion) int { if v.release == w.release { return 0 @@ -216,11 +217,3 @@ func parseRedHatVersion(str string) RedHatVersion { return RedHatVersion{epoch, version, release} } - -// - RPM package names are made up of five parts; the package name, epoch, version, release, and architecture (NEVRA) -// - The epoch is not always included; it is assumed to be zero (0) on any packages that lack it explicitly -// - The format for the whole string is n-e:v-r.a -// -// - parsing: -// - If there is a : in the string, everything before it is the epoch. If not, the epoch is zero. -// - If there is a - in the remaining string, everything before the first - is the version, and everything after it is the release. If there isn’t one, the release is considered null/nill/None/whatever. From 328933222c303fb33da3b00254a663084ecdf0a5 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:53:01 +1300 Subject: [PATCH 19/33] refactor: update order to be consistent --- internal/semantic/version-redhat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 0359d8f516..34796828f1 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -107,7 +107,7 @@ func compareRedHatComponents(a, b string) int { if len(ac) > len(bc) { return +1 } - if len(bc) > len(ac) { + if len(ac) < len(bc) { return -1 } } From 39bb66f6bde440b2b135be08f9355e6c13295694 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:57:51 +1300 Subject: [PATCH 20/33] refactor: move early exit into generic comparator --- internal/semantic/version-redhat.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 34796828f1..f402178ec3 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -18,6 +18,13 @@ func shouldBeTrimmed(r rune) bool { // compareRedHatComponents compares two components of a RedHatVersion in the same // manner as rpmvercmp(8) does. func compareRedHatComponents(a, b string) int { + if a == "" && b != "" { + return -1 + } + if a != "" && b == "" { + return +1 + } + var vi, wi int for { @@ -151,16 +158,6 @@ func (v RedHatVersion) compareVersion(w RedHatVersion) int { // compareRelease compares the release component of two RedHatVersion structs func (v RedHatVersion) compareRelease(w RedHatVersion) int { - if v.release == w.release { - return 0 - } - if v.release == "" && w.release != "" { - return -1 - } - if v.release != "" && w.release == "" { - return +1 - } - return compareRedHatComponents(v.release, w.release) } From 247093b5bc86bad4a639228e689ff2a8c738d366 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 29 Oct 2024 16:58:02 +1300 Subject: [PATCH 21/33] refactor: inline functions --- internal/semantic/version-redhat.go | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index f402178ec3..553f4000d3 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -151,26 +151,16 @@ func (v RedHatVersion) compareEpoch(w RedHatVersion) int { return ve.Cmp(we) } -// compareVersion compares the version component of two RedHatVersion structs -func (v RedHatVersion) compareVersion(w RedHatVersion) int { - return compareRedHatComponents(v.version, w.version) -} - -// compareRelease compares the release component of two RedHatVersion structs -func (v RedHatVersion) compareRelease(w RedHatVersion) int { - return compareRedHatComponents(v.release, w.release) -} - func (v RedHatVersion) CompareStr(str string) int { w := parseRedHatVersion(str) if diff := v.compareEpoch(w); diff != 0 { return diff } - if diff := v.compareVersion(w); diff != 0 { + if diff := compareRedHatComponents(v.version, w.version); diff != 0 { return diff } - if diff := v.compareRelease(w); diff != 0 { + if diff := compareRedHatComponents(v.release, w.release); diff != 0 { return diff } From fe8ffea55211427df6b558ff8fe0d504e5cf965b Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 07:18:51 +1300 Subject: [PATCH 22/33] fix: support non-numeric epochs --- internal/semantic/fixtures/redhat-versions.txt | 16 ++++++++++++++++ internal/semantic/version-redhat.go | 14 +------------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt index d163e596bc..fee2acec28 100644 --- a/internal/semantic/fixtures/redhat-versions.txt +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -88,6 +88,22 @@ _ = + 184:2 > 177:5 01:2 = 1:2 +a:1 = a:1 +a:1 < b:1 +a:1 < a:2 +a:1~1 < a:2 +c:1~1 > a:2 + +a1:1 = a1:1 +a1:1 < b1:1 +a1:1 < a1:2 +a2:1 > a1:2 + +b1:1 > a2:1 +a:1 < a1:1 +b:1 > a1:1 +b~1:1 > a1:1 + # releases 1-123 > 1-2 1-1 = 1-1 diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 553f4000d3..832e4c24cd 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -139,22 +139,10 @@ func compareRedHatComponents(a, b string) int { return 0 } -// compareEpoch compares the epoch component of two RedHatVersion structs -func (v RedHatVersion) compareEpoch(w RedHatVersion) int { - if v.epoch == w.epoch { - return 0 - } - - ve := convertToBigIntOrPanic(v.epoch) - we := convertToBigIntOrPanic(w.epoch) - - return ve.Cmp(we) -} - func (v RedHatVersion) CompareStr(str string) int { w := parseRedHatVersion(str) - if diff := v.compareEpoch(w); diff != 0 { + if diff := compareRedHatComponents(v.epoch, w.epoch); diff != 0 { return diff } if diff := compareRedHatComponents(v.version, w.version); diff != 0 { From 5c8b28f26a70aced54ca7b43a024c402e0c5456d Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 07:29:47 +1300 Subject: [PATCH 23/33] chore: fix function comment --- internal/semantic/version-redhat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 832e4c24cd..d389c39697 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -155,7 +155,7 @@ func (v RedHatVersion) CompareStr(str string) int { return 0 } -// ParseRedHatVersion parses a Red Hat version into a RedHatVersion struct. +// parseRedHatVersion parses a Red Hat version into a RedHatVersion struct. // // A Red Hat version contains the following components: // - name (of the package), represented as "n" From 866679f48def8b3236da97d68e2d7537f1bfea4f Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 07:53:19 +1300 Subject: [PATCH 24/33] fix: support the caret character --- .../semantic/fixtures/redhat-versions.txt | 24 ++++++++++++ internal/semantic/version-redhat.go | 39 ++++++++++++++++--- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt index fee2acec28..2a4a2c7444 100644 --- a/internal/semantic/fixtures/redhat-versions.txt +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -135,3 +135,27 @@ b~1:1 > a1:1 1 < 1- 0:3.10.0-229.1.2.ael7b < 0:3.10.0-229.4.2.ael7b + +1.0^ = 1.0^ +1.0^ > 1.0 +1.0 < 1.0^ +1.0^git1 = 1.0^git1 +1.0^git1 > 1.0 +1.0 < 1.0^git1 +1.0^git1 < 1.0^git2 +1.0^git2 > 1.0^git1 +1.0^git1 < 1.01 +1.01 > 1.0^git1 +1.0^20160101 = 1.0^20160101 +1.0^20160101 < 1.0.1 +1.0.1 > 1.0^20160101 +1.0^20160101^git1 = 1.0^20160101^git1 +1.0^20160102 > 1.0^20160101^git1 +1.0^20160101^git1 < 1.0^20160102 + +1.0~rc1^git1 = 1.0~rc1^git1 +1.0~rc1^git1 > 1.0~rc1 +1.0~rc1 < 1.0~rc1^git1 +1.0^git1~pre = 1.0^git1~pre +1.0^git1 > 1.0^git1~pre +1.0^git1~pre < 1.0^git1 diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index d389c39697..1a0a9f7d5f 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -12,7 +12,7 @@ type RedHatVersion struct { } func shouldBeTrimmed(r rune) bool { - return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '~' + return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '~' && r != '^' } // compareRedHatComponents compares two components of a RedHatVersion in the same @@ -62,12 +62,39 @@ func compareRedHatComponents(a, b string) int { return +1 } - // 4. End the loop if either string has reached zero length. + // 4. If both strings start with a caret, discard it and move on to the next character. + vStartsWithCaret := vi < len(a) && a[vi] == '^' + wStartsWithCaret := wi < len(b) && b[wi] == '^' + + if vStartsWithCaret && wStartsWithCaret { + vi++ + wi++ + + continue + } + + // 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. + if vStartsWithCaret { + if wi == len(b) { + return +1 + } + + return -1 + } + if wStartsWithCaret { + if vi == len(a) { + return -1 + } + + return +1 + } + + // 6. End the loop if either string has reached zero length. if vi == len(a) || wi == len(b) { break } - // 5. 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. + // 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. isDigit := unicode.IsDigit(rune(a[vi])) var iser func(r rune) bool @@ -97,7 +124,7 @@ func compareRedHatComponents(a, b string) int { wi++ } - // 6. 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. + // 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. if bc == "" { if isDigit { return +1 @@ -106,7 +133,7 @@ func compareRedHatComponents(a, b string) int { return -1 } - // 7. 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. + // 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. if isDigit { ac = strings.TrimLeft(ac, "0") bc = strings.TrimLeft(bc, "0") @@ -119,7 +146,7 @@ func compareRedHatComponents(a, b string) int { } } - // 8. 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. + // 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. if diff := strings.Compare(ac, bc); diff != 0 { return diff } From c2bc2b52b8de3e0756c7f7ebce4e5e8bd9259d1b Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 07:58:01 +1300 Subject: [PATCH 25/33] fix: treat non-ascii characters as the same --- .../semantic/fixtures/redhat-versions.txt | 11 +++++++++ internal/semantic/version-redhat.go | 24 +++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/internal/semantic/fixtures/redhat-versions.txt b/internal/semantic/fixtures/redhat-versions.txt index 2a4a2c7444..176c568144 100644 --- a/internal/semantic/fixtures/redhat-versions.txt +++ b/internal/semantic/fixtures/redhat-versions.txt @@ -159,3 +159,14 @@ b~1:1 > a1:1 1.0^git1~pre = 1.0^git1~pre 1.0^git1 > 1.0^git1~pre 1.0^git1~pre < 1.0^git1 + +1.1.α = 1.1.α +1.1.α = 1.1.β +1.1.β = 1.1.α +1.1.αα = 1.1.α +1.1.α = 1.1.ββ +1.1.ββ = 1.1.αα + +1.1.a < 1.2.ββ +1.1.a < 0:1.2.ββ +1.1.α < 1.1.a diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 1a0a9f7d5f..ce0eb533b1 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -2,7 +2,6 @@ package semantic import ( "strings" - "unicode" ) type RedHatVersion struct { @@ -11,8 +10,23 @@ type RedHatVersion struct { release string } +// isASCIIDigit returns true if the given rune is an ASCII digit. +// +// Unicode digits are not considered ASCII digits by this function. +func isASCIIDigit(c rune) bool { + return c >= 48 && c <= 57 +} + +// isASCIILetter returns true if the given rune is an ASCII letter. +// +// Unicode letters are not considered ASCII letters by this function. +func isASCIILetter(c rune) bool { + return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) +} + +// shouldBeTrimmed checks if the given rune should be trimmed when parsing RedHatVersion components func shouldBeTrimmed(r rune) bool { - return !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '~' && r != '^' + return !isASCIILetter(r) && !isASCIIDigit(r) && r != '~' && r != '^' } // compareRedHatComponents compares two components of a RedHatVersion in the same @@ -95,13 +109,13 @@ func compareRedHatComponents(a, b string) int { } // 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. - isDigit := unicode.IsDigit(rune(a[vi])) + isDigit := isASCIIDigit(rune(a[vi])) var iser func(r rune) bool if isDigit { - iser = unicode.IsDigit + iser = isASCIIDigit } else { - iser = unicode.IsLetter + iser = isASCIILetter } var ac, bc string From a0dd4ca0ad54bfddfed9f05a4ab33837adc7820b Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Thu, 31 Oct 2024 07:11:07 +1300 Subject: [PATCH 26/33] refactor: rename variables to match parameter names --- internal/semantic/version-redhat.go | 80 ++++++++++++++--------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index ce0eb533b1..3724c8fbeb 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -39,64 +39,64 @@ func compareRedHatComponents(a, b string) int { return +1 } - var vi, wi int + var ai, bi int for { // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. for { - if vi == len(a) || !shouldBeTrimmed(rune(a[vi])) { + if ai == len(a) || !shouldBeTrimmed(rune(a[ai])) { break } - vi++ + ai++ } for { - if wi == len(b) || !shouldBeTrimmed(rune(b[wi])) { + if bi == len(b) || !shouldBeTrimmed(rune(b[bi])) { break } - wi++ + bi++ } // 2. If both strings start with a tilde, discard it and move on to the next character. - vStartsWithTilde := vi < len(a) && a[vi] == '~' - wStartsWithTilde := wi < len(b) && b[wi] == '~' + aStartsWithTilde := ai < len(a) && a[ai] == '~' + bStartsWithTilde := bi < len(b) && b[bi] == '~' - if vStartsWithTilde && wStartsWithTilde { - vi++ - wi++ + if aStartsWithTilde && bStartsWithTilde { + ai++ + bi++ continue } // 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. - if vStartsWithTilde { + if aStartsWithTilde { return -1 } - if wStartsWithTilde { + if bStartsWithTilde { return +1 } // 4. If both strings start with a caret, discard it and move on to the next character. - vStartsWithCaret := vi < len(a) && a[vi] == '^' - wStartsWithCaret := wi < len(b) && b[wi] == '^' + aStartsWithCaret := ai < len(a) && a[ai] == '^' + bStartsWithCaret := bi < len(b) && b[bi] == '^' - if vStartsWithCaret && wStartsWithCaret { - vi++ - wi++ + if aStartsWithCaret && bStartsWithCaret { + ai++ + bi++ continue } // 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. - if vStartsWithCaret { - if wi == len(b) { + if aStartsWithCaret { + if bi == len(b) { return +1 } return -1 } - if wStartsWithCaret { - if vi == len(a) { + if bStartsWithCaret { + if ai == len(a) { return -1 } @@ -104,12 +104,12 @@ func compareRedHatComponents(a, b string) int { } // 6. End the loop if either string has reached zero length. - if vi == len(a) || wi == len(b) { + if ai == len(a) || bi == len(b) { break } // 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. - isDigit := isASCIIDigit(rune(a[vi])) + isDigit := isASCIIDigit(rune(a[ai])) var iser func(r rune) bool if isDigit { @@ -118,28 +118,28 @@ func compareRedHatComponents(a, b string) int { iser = isASCIILetter } - var ac, bc string + var as, bs string - for _, c := range a[vi:] { + for _, c := range a[ai:] { if !iser(c) { break } - ac += string(c) - vi++ + as += string(c) + ai++ } - for _, c := range b[wi:] { + for _, c := range b[bi:] { if !iser(c) { break } - bc += string(c) - wi++ + bs += string(c) + bi++ } // 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. - if bc == "" { + if bs == "" { if isDigit { return +1 } @@ -149,31 +149,31 @@ func compareRedHatComponents(a, b string) int { // 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. if isDigit { - ac = strings.TrimLeft(ac, "0") - bc = strings.TrimLeft(bc, "0") + as = strings.TrimLeft(as, "0") + bs = strings.TrimLeft(bs, "0") - if len(ac) > len(bc) { + if len(as) > len(bs) { return +1 } - if len(ac) < len(bc) { + if len(as) < len(bs) { return -1 } } // 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. - if diff := strings.Compare(ac, bc); diff != 0 { + if diff := strings.Compare(as, bs); diff != 0 { return diff } } // 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. - vl := len(a) - vi - wl := len(b) - wi + al := len(a) - ai + bl := len(b) - bi - if vl > wl { + if al > bl { return +1 } - if vl < wl { + if al < bl { return -1 } From 8daf7658c2652920a2fedf1ce5f8181023d520bd Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Thu, 31 Oct 2024 07:11:27 +1300 Subject: [PATCH 27/33] chore: update comment --- internal/semantic/version-redhat.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 3724c8fbeb..015818f85b 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -42,7 +42,7 @@ func compareRedHatComponents(a, b string) int { var ai, bi int for { - // 1. Trim anything that’s not [A-Za-z0-9] or tilde (~) from the front of both strings. + // 1. Trim anything that’s not [A-Za-z0-9], a tilde (~), or a caret (^) from the front of both strings. for { if ai == len(a) || !shouldBeTrimmed(rune(a[ai])) { break From ebc6e4193eb0d9f291eaff7350de15c302ef8c0f Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Thu, 31 Oct 2024 07:12:42 +1300 Subject: [PATCH 28/33] refactor: simplify trimming loops --- internal/semantic/version-redhat.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 015818f85b..91a1d30df5 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -43,17 +43,11 @@ func compareRedHatComponents(a, b string) int { for { // 1. Trim anything that’s not [A-Za-z0-9], a tilde (~), or a caret (^) from the front of both strings. - for { - if ai == len(a) || !shouldBeTrimmed(rune(a[ai])) { - break - } + for ai < len(a) && shouldBeTrimmed(rune(a[ai])) { ai++ } - for { - if bi == len(b) || !shouldBeTrimmed(rune(b[bi])) { - break - } + for bi < len(b) && shouldBeTrimmed(rune(b[bi])) { bi++ } From 4085480a5528f688316d82698bde948ca9ac65ff Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 5 Nov 2024 15:49:29 +1300 Subject: [PATCH 29/33] refactor: rename variable --- internal/semantic/version-redhat.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/semantic/version-redhat.go b/internal/semantic/version-redhat.go index 91a1d30df5..49d4a7a247 100644 --- a/internal/semantic/version-redhat.go +++ b/internal/semantic/version-redhat.go @@ -105,17 +105,17 @@ func compareRedHatComponents(a, b string) int { // 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. isDigit := isASCIIDigit(rune(a[ai])) - var iser func(r rune) bool + var isExpectedRunType func(r rune) bool if isDigit { - iser = isASCIIDigit + isExpectedRunType = isASCIIDigit } else { - iser = isASCIILetter + isExpectedRunType = isASCIILetter } var as, bs string for _, c := range a[ai:] { - if !iser(c) { + if !isExpectedRunType(c) { break } @@ -124,7 +124,7 @@ func compareRedHatComponents(a, b string) int { } for _, c := range b[bi:] { - if !iser(c) { + if !isExpectedRunType(c) { break } From 057538d70a61097a3df89e4f0c0833ce428cebd4 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 08:12:30 +1300 Subject: [PATCH 30/33] chore: create redhat versions fixture generator --- .../generators/generate-redhat-versions.py | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100755 scripts/generators/generate-redhat-versions.py diff --git a/scripts/generators/generate-redhat-versions.py b/scripts/generators/generate-redhat-versions.py new file mode 100755 index 0000000000..34a37ea63f --- /dev/null +++ b/scripts/generators/generate-redhat-versions.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 + +import json +import operator +import os +import subprocess +import sys +import urllib.request +import zipfile +from pathlib import Path + +# this requires being run on an OS that has a version of "rpm" installed which +# supports evaluating Lua expressions (most versions do); also make sure to consider +# the version of rpm being used in case there are changes to the comparing logic +# (last run with 1.19.7). +# +# note that both alpine and debian have a "rpm" package that supports this, which +# can be installed using "apk add rpm" and "apt install rpm" respectively. +# +# also note that because of the large amount of versions being used there is +# significant overhead in having to use a subprocess, so this generator caches +# the results of said subprocess calls; a typical no-cache run takes about 5+ +# minutes whereas with the cache it only takes seconds. + +# An array of version comparisons that are known to be unsupported and so +# should be commented out in the generated fixture. +# +# Generally this is because the native implementation has a suspected bug +# that causes the comparison to return incorrect results, and so supporting +# such comparisons in the detector would in fact be wrong. +UNSUPPORTED_COMPARISONS = [] + + +def is_unsupported_comparison(line): + return line in UNSUPPORTED_COMPARISONS + + +def uncomment(line): + if line.startswith("#"): + return line[1:] + if line.startswith("//"): + return line[2:] + return line + + +def download_redhat_db(): + urllib.request.urlretrieve("https://osv-vulnerabilities.storage.googleapis.com/Red%20Hat/all.zip", "redhat-db.zip") + + +def extract_packages_with_versions(osvs): + dict = {} + + for osv in osvs: + for affected in osv['affected']: + if 'package' not in affected or not affected['package']['ecosystem'].startswith('Red Hat'): + continue + + package = affected['package']['name'] + + if package not in dict: + dict[package] = [] + + for version in affected.get('versions', []): + dict[package].append(RedHatVersion(version)) + + for rang in affected.get('ranges', []): + for event in rang['events']: + if 'introduced' in event and event['introduced'] != '0': + dict[package].append(RedHatVersion(event['introduced'])) + if 'fixed' in event: + dict[package].append(RedHatVersion(event['fixed'])) + + # deduplicate and sort the versions for each package + for package in dict: + dict[package] = sorted(list(dict.fromkeys(dict[package]))) + + return dict + + +class RedHatVersionComparer: + def __init__(self, cache_path): + self.cache_path = Path(cache_path) + self.cache = {} + + self._load_cache() + + def _load_cache(self): + if self.cache_path: + self.cache_path.touch() + with open(self.cache_path, "r") as f: + lines = f.readlines() + + for line in lines: + line = line.strip() + key, result = line.split(",") + + if result == "True": + self.cache[key] = True + continue + if result == "False": + self.cache[key] = False + continue + + print(f"ignoring invalid cache entry '{line}'") + + def _save_to_cache(self, key, result): + self.cache[key] = result + if self.cache_path: + self.cache_path.touch() + with open(self.cache_path, "a") as f: + f.write(f"{key},{result}\n") + + def _compare1(self, a, op, b): + cmd = ["rpm", "--eval", f"%{{lua:print(rpm.vercmp('{a}', '{b}'))}}"] + out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + if out.returncode != 0 or out.stderr: + raise Exception(f"rpm did not like comparing {a} {op} {b}: {out.stderr.decode('utf-8')}") + + r = out.stdout.decode('utf-8').strip() + + if r == "0" and op == "=": + return True + elif r == "1" and op == ">": + return True + elif r == "-1" and op == "<": + return True + + return False + + def _compare2(self, a, op, b): + if op == "=": + op = "==" # lua uses == for equality + + cmd = ["rpm", "--eval", f"%{{lua:print(rpm.ver('{a}') {op} rpm.ver('{b}'))}}"] + out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + if out.returncode != 0 or out.stderr: + raise Exception(f"rpm did not like comparing {a} {op} {b}: {out.stderr.decode('utf-8')}") + + r = out.stdout.decode('utf-8').strip() + + if r == "true": + return True + elif r == "false": + return False + + raise Exception(f"unexpected result from rpm: {r}") + + + def compare(self, a, op, b): + key = f"{a} {op} {b}" + if key in self.cache: + return self.cache[key] + + r = self._compare1(a, op, b) + # r = self._compare2(a, op, b) + + self._save_to_cache(key, r) + return r + + +redhat_comparer = RedHatVersionComparer("/tmp/redhat-versions-generator-cache.csv") + + +class RedHatVersion: + def __str__(self): + return self.version + + def __hash__(self): + return hash(self.version) + + def __init__(self, version): + self.version = version + + def __lt__(self, other): + return redhat_comparer.compare(self.version, '<', other.version) + + def __gt__(self, other): + return redhat_comparer.compare(self.version, '>', other.version) + + def __eq__(self, other): + return redhat_comparer.compare(self.version, '=', other.version) + + +def compare(v1, relate, v2): + ops = {'<': operator.lt, '=': operator.eq, '>': operator.gt} + return ops[relate](v1, v2) + + +def compare_versions(lines, select="all"): + has_any_failed = False + + for line in lines: + line = line.strip() + + if line == "" or line.startswith('#') or line.startswith('//'): + maybe_unsupported = uncomment(line).strip() + + if is_unsupported_comparison(maybe_unsupported): + print(f"\033[96mS\033[0m: \033[93m{maybe_unsupported}\033[0m") + continue + + v1, op, v2 = line.strip().split(" ") + + r = compare(RedHatVersion(v1), op, RedHatVersion(v2)) + + if not r: + has_any_failed = r + + if select == "failures" and r: + continue + + if select == "successes" and not r: + continue + + color = '\033[92m' if r else '\033[91m' + rs = "T" if r else "F" + print(f"{color}{rs}\033[0m: \033[93m{line}\033[0m") + return has_any_failed + + +def compare_versions_in_file(filepath, select="all"): + with open(filepath) as f: + lines = f.readlines() + return compare_versions(lines, select) + + +def generate_version_compares(versions): + comparisons = [] + for i, version in enumerate(versions): + if i == 0: + continue + + comparison = f"{versions[i - 1]} < {version}\n" + + if is_unsupported_comparison(comparison.strip()): + comparison = "# " + comparison + comparisons.append(comparison) + return comparisons + + +def generate_package_compares(packages): + comparisons = [] + for package in packages: + versions = packages[package] + comparisons.extend(generate_version_compares(versions)) + + # return comparisons + return list(dict.fromkeys(comparisons)) + + +def fetch_packages_versions(): + download_redhat_db() + osvs = [] + + with zipfile.ZipFile('redhat-db.zip') as db: + for fname in db.namelist(): + with db.open(fname) as osv: + osvs.append(json.loads(osv.read().decode('utf-8'))) + + return extract_packages_with_versions(osvs) + + +outfile = "internal/semantic/fixtures/redhat-versions-generated.txt" + +packs = fetch_packages_versions() +with open(outfile, "w") as f: + f.writelines(generate_package_compares(packs)) + f.write("\n") + +# set this to either "failures" or "successes" to only have those comparison results +# printed; setting it to anything else will have all comparison results printed +show = os.environ.get("VERSION_GENERATOR_PRINT", "failures") + +did_any_fail = compare_versions_in_file(outfile, show) + +if did_any_fail: + sys.exit(1) From 360c1b080e67d7d96801eac4ece267ccb15e1905 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 08:46:09 +1300 Subject: [PATCH 31/33] test: use generated red hat versions fixture if it exists --- internal/semantic/compare_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/semantic/compare_test.go b/internal/semantic/compare_test.go index 82e0ae5316..b99f5774bb 100644 --- a/internal/semantic/compare_test.go +++ b/internal/semantic/compare_test.go @@ -2,6 +2,8 @@ package semantic_test import ( "bufio" + "errors" + "io/fs" "os" "strings" "testing" @@ -239,6 +241,23 @@ func TestVersion_Compare_Ecosystems(t *testing.T) { file: "redhat-versions.txt", }, } + + // we don't check the generated fixture for Red Hat in due to its size + // so we only add it if it exists, so that people can have it locally + // without needing to do a dance with git everytime they commit + _, err := os.Stat("fixtures/redhat-versions-generated.txt") + if err == nil { + tests = append(tests, struct { + name string + file string + }{ + name: "Red Hat", + file: "redhat-versions-generated.txt", + }) + } else if !errors.Is(err, fs.ErrNotExist) { + t.Fatalf("fixtures/redhat-versions-generated.txt exists but could not be read: %v", err) + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() From 29201f75b834f4f4e5ab8b6877d240e2884cb315 Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 08:48:16 +1300 Subject: [PATCH 32/33] chore: ignore the redhat versions generated file --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7d9ef27890..6de65fa0a1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ *.pprof .go-version node_modules + +# we don't want to check in this file as it's very very large +/internal/semantic/fixtures/redhat-versions-generated.txt From 502c6326fbddadc1d3e456a326234ad53fbd6a4e Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Wed, 30 Oct 2024 08:49:54 +1300 Subject: [PATCH 33/33] ci: add `semantic` job for generating redhat versions --- .github/workflows/semantic.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/semantic.yml b/.github/workflows/semantic.yml index fcd4273330..6c5e785a66 100644 --- a/.github/workflows/semantic.yml +++ b/.github/workflows/semantic.yml @@ -59,6 +59,39 @@ jobs: path: /tmp/debian-versions-generator-cache.csv key: ${{ runner.os }}-${{ hashFiles('debian-db.zip') }} + generate-redhat-versions: + permissions: + contents: read # to fetch code (actions/checkout) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + + - uses: actions/cache/restore@3624ceb22c1c5a301c8db4169662070a689d9ea8 # v4.1.1 + with: + path: /tmp/redhat-versions-generator-cache.csv + key: ${{ runner.os }}- + + - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1 + with: + persist-credentials: false + - uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0 + with: + python-version: "3.10" + - run: sudo apt install rpm + - run: rpm --version + - run: python3 scripts/generators/generate-redhat-versions.py + - run: git status + - run: stat redhat-db.zip + - uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 + with: + name: generated-redhat-versions + path: internal/semantic/fixtures/redhat-versions-generated.txt + + - uses: actions/cache/save@3624ceb22c1c5a301c8db4169662070a689d9ea8 # v4.1.1 + with: + path: /tmp/redhat-versions-generator-cache.csv + key: ${{ runner.os }}-${{ hashFiles('redhat-db.zip') }} + generate-packagist-versions: permissions: contents: read # to fetch code (actions/checkout) @@ -173,6 +206,7 @@ jobs: - generate-rubygems-versions - generate-maven-versions - generate-cran-versions + - generate-redhat-versions if: always() steps: - uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1