Skip to content

Commit c4d5751

Browse files
authored
feat: add support for comparing RedHat versions (#298)
1 parent d9688fa commit c4d5751

File tree

6 files changed

+739
-0
lines changed

6 files changed

+739
-0
lines changed

.github/workflows/semantic.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,39 @@ jobs:
5959
path: /tmp/debian-versions-generator-cache.csv
6060
key: ${{ runner.os }}-${{ hashFiles('debian-db.zip') }}
6161

62+
generate-redhat-versions:
63+
permissions:
64+
contents: read # to fetch code (actions/checkout)
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
68+
69+
- uses: actions/cache/restore@1bd1e32a3bdc45362d1e726936510720a7c30a57 # v4.2.0
70+
with:
71+
path: /tmp/redhat-versions-generator-cache.csv
72+
key: ${{ runner.os }}-
73+
74+
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
75+
with:
76+
persist-credentials: false
77+
- uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55 # v5.5.0
78+
with:
79+
python-version: '3.13'
80+
- run: sudo apt install rpm
81+
- run: rpm --version
82+
- run: python3 generators/generate-redhat-versions.py
83+
- run: git status
84+
- run: stat redhat-db.zip
85+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
86+
with:
87+
name: generated-redhat-versions
88+
path: pkg/semantic/fixtures/redhat-versions-generated.txt
89+
90+
- uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
91+
with:
92+
path: /tmp/redhat-versions-generator-cache.csv
93+
key: ${{ runner.os }}-${{ hashFiles('redhat-db.zip') }}
94+
6295
generate-packagist-versions:
6396
permissions:
6497
contents: read # to fetch code (actions/checkout)
@@ -168,6 +201,7 @@ jobs:
168201
runs-on: ubuntu-latest
169202
needs:
170203
- generate-debian-versions
204+
- generate-redhat-versions
171205
- generate-packagist-versions
172206
- generate-pypi-versions
173207
- generate-rubygems-versions
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import operator
5+
import os
6+
import subprocess
7+
import sys
8+
import urllib.request
9+
import zipfile
10+
from pathlib import Path
11+
12+
# this requires being run on an OS that has a version of "rpm" installed which
13+
# supports evaluating Lua expressions (most versions do); also make sure to consider
14+
# the version of rpm being used in case there are changes to the comparing logic
15+
# (last run with 1.19.7).
16+
#
17+
# note that both alpine and debian have a "rpm" package that supports this, which
18+
# can be installed using "apk add rpm" and "apt install rpm" respectively.
19+
#
20+
# also note that because of the large amount of versions being used there is
21+
# significant overhead in having to use a subprocess, so this generator caches
22+
# the results of said subprocess calls; a typical no-cache run takes about 5+
23+
# minutes whereas with the cache it only takes seconds.
24+
25+
# An array of version comparisons that are known to be unsupported and so
26+
# should be commented out in the generated fixture.
27+
#
28+
# Generally this is because the native implementation has a suspected bug
29+
# that causes the comparison to return incorrect results, and so supporting
30+
# such comparisons in the detector would in fact be wrong.
31+
UNSUPPORTED_COMPARISONS = []
32+
33+
34+
def is_unsupported_comparison(line):
35+
return line in UNSUPPORTED_COMPARISONS
36+
37+
38+
def uncomment(line):
39+
if line.startswith('#'):
40+
return line[1:]
41+
if line.startswith('//'):
42+
return line[2:]
43+
return line
44+
45+
46+
def download_redhat_db():
47+
urllib.request.urlretrieve('https://osv-vulnerabilities.storage.googleapis.com/Red%20Hat/all.zip', 'redhat-db.zip')
48+
49+
50+
def extract_packages_with_versions(osvs):
51+
dict = {}
52+
53+
for osv in osvs:
54+
for affected in osv['affected']:
55+
if 'package' not in affected or not affected['package']['ecosystem'].startswith('Red Hat'):
56+
continue
57+
58+
package = affected['package']['name']
59+
60+
if package not in dict:
61+
dict[package] = []
62+
63+
for version in affected.get('versions', []):
64+
dict[package].append(RedHatVersion(version))
65+
66+
for rang in affected.get('ranges', []):
67+
for event in rang['events']:
68+
if 'introduced' in event and event['introduced'] != '0':
69+
dict[package].append(RedHatVersion(event['introduced']))
70+
if 'fixed' in event:
71+
dict[package].append(RedHatVersion(event['fixed']))
72+
73+
# deduplicate and sort the versions for each package
74+
for package in dict:
75+
dict[package] = sorted(list(dict.fromkeys(dict[package])))
76+
77+
return dict
78+
79+
80+
class RedHatVersionComparer:
81+
def __init__(self, cache_path):
82+
self.cache_path = Path(cache_path)
83+
self.cache = {}
84+
85+
self._load_cache()
86+
87+
def _load_cache(self):
88+
if self.cache_path:
89+
self.cache_path.touch()
90+
with open(self.cache_path, 'r') as f:
91+
lines = f.readlines()
92+
93+
for line in lines:
94+
line = line.strip()
95+
key, result = line.split(',')
96+
97+
if result == 'True':
98+
self.cache[key] = True
99+
continue
100+
if result == 'False':
101+
self.cache[key] = False
102+
continue
103+
104+
print(f"ignoring invalid cache entry '{line}'")
105+
106+
def _save_to_cache(self, key, result):
107+
self.cache[key] = result
108+
if self.cache_path:
109+
self.cache_path.touch()
110+
with open(self.cache_path, 'a') as f:
111+
f.write(f'{key},{result}\n')
112+
113+
def _compare1(self, a, op, b):
114+
cmd = ['rpm', '--eval', f"%{{lua:print(rpm.vercmp('{a}', '{b}'))}}"]
115+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
116+
117+
if out.returncode != 0 or out.stderr:
118+
raise Exception(f'rpm did not like comparing {a} {op} {b}: {out.stderr.decode("utf-8")}')
119+
120+
r = out.stdout.decode('utf-8').strip()
121+
122+
if r == '0' and op == '=':
123+
return True
124+
elif r == '1' and op == '>':
125+
return True
126+
elif r == '-1' and op == '<':
127+
return True
128+
129+
return False
130+
131+
def _compare2(self, a, op, b):
132+
if op == '=':
133+
op = '==' # lua uses == for equality
134+
135+
cmd = ['rpm', '--eval', f"%{{lua:print(rpm.ver('{a}') {op} rpm.ver('{b}'))}}"]
136+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
137+
138+
if out.returncode != 0 or out.stderr:
139+
raise Exception(f'rpm did not like comparing {a} {op} {b}: {out.stderr.decode("utf-8")}')
140+
141+
r = out.stdout.decode('utf-8').strip()
142+
143+
if r == 'true':
144+
return True
145+
elif r == 'false':
146+
return False
147+
148+
raise Exception(f'unexpected result from rpm: {r}')
149+
150+
151+
def compare(self, a, op, b):
152+
key = f'{a} {op} {b}'
153+
if key in self.cache:
154+
return self.cache[key]
155+
156+
r = self._compare1(a, op, b)
157+
# r = self._compare2(a, op, b)
158+
159+
self._save_to_cache(key, r)
160+
return r
161+
162+
163+
redhat_comparer = RedHatVersionComparer('/tmp/redhat-versions-generator-cache.csv')
164+
165+
166+
class RedHatVersion:
167+
def __str__(self):
168+
return self.version
169+
170+
def __hash__(self):
171+
return hash(self.version)
172+
173+
def __init__(self, version):
174+
self.version = version
175+
176+
def __lt__(self, other):
177+
return redhat_comparer.compare(self.version, '<', other.version)
178+
179+
def __gt__(self, other):
180+
return redhat_comparer.compare(self.version, '>', other.version)
181+
182+
def __eq__(self, other):
183+
return redhat_comparer.compare(self.version, '=', other.version)
184+
185+
186+
def compare(v1, relate, v2):
187+
ops = {'<': operator.lt, '=': operator.eq, '>': operator.gt}
188+
return ops[relate](v1, v2)
189+
190+
191+
def compare_versions(lines, select='all'):
192+
has_any_failed = False
193+
194+
for line in lines:
195+
line = line.strip()
196+
197+
if line == '' or line.startswith('#') or line.startswith('//'):
198+
maybe_unsupported = uncomment(line).strip()
199+
200+
if is_unsupported_comparison(maybe_unsupported):
201+
print(f'\033[96mS\033[0m: \033[93m{maybe_unsupported}\033[0m')
202+
continue
203+
204+
v1, op, v2 = line.strip().split(' ')
205+
206+
r = compare(RedHatVersion(v1), op, RedHatVersion(v2))
207+
208+
if not r:
209+
has_any_failed = r
210+
211+
if select == 'failures' and r:
212+
continue
213+
214+
if select == 'successes' and not r:
215+
continue
216+
217+
color = '\033[92m' if r else '\033[91m'
218+
rs = 'T' if r else 'F'
219+
print(f'{color}{rs}\033[0m: \033[93m{line}\033[0m')
220+
return has_any_failed
221+
222+
223+
def compare_versions_in_file(filepath, select='all'):
224+
with open(filepath) as f:
225+
lines = f.readlines()
226+
return compare_versions(lines, select)
227+
228+
229+
def generate_version_compares(versions):
230+
comparisons = []
231+
for i, version in enumerate(versions):
232+
if i == 0:
233+
continue
234+
235+
comparison = f'{versions[i - 1]} < {version}\n'
236+
237+
if is_unsupported_comparison(comparison.strip()):
238+
comparison = '# ' + comparison
239+
comparisons.append(comparison)
240+
return comparisons
241+
242+
243+
def generate_package_compares(packages):
244+
comparisons = []
245+
for package in packages:
246+
versions = packages[package]
247+
comparisons.extend(generate_version_compares(versions))
248+
249+
# return comparisons
250+
return list(dict.fromkeys(comparisons))
251+
252+
253+
def fetch_packages_versions():
254+
download_redhat_db()
255+
osvs = []
256+
257+
with zipfile.ZipFile('redhat-db.zip') as db:
258+
for fname in db.namelist():
259+
with db.open(fname) as osv:
260+
osvs.append(json.loads(osv.read().decode('utf-8')))
261+
262+
return extract_packages_with_versions(osvs)
263+
264+
265+
outfile = 'pkg/semantic/fixtures/redhat-versions-generated.txt'
266+
267+
packs = fetch_packages_versions()
268+
with open(outfile, 'w') as f:
269+
f.writelines(generate_package_compares(packs))
270+
f.write('\n')
271+
272+
# set this to either "failures" or "successes" to only have those comparison results
273+
# printed; setting it to anything else will have all comparison results printed
274+
show = os.environ.get('VERSION_GENERATOR_PRINT', 'failures')
275+
276+
did_any_fail = compare_versions_in_file(outfile, show)
277+
278+
if did_any_fail:
279+
sys.exit(1)

pkg/semantic/compare_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package semantic_test
22

33
import (
44
"bufio"
5+
"errors"
6+
"io/fs"
57
"os"
68
"strings"
79
"testing"
@@ -234,7 +236,28 @@ func TestVersion_Compare_Ecosystems(t *testing.T) {
234236
name: "Alpine",
235237
file: "alpine-versions-generated.txt",
236238
},
239+
{
240+
name: "Red Hat",
241+
file: "redhat-versions.txt",
242+
},
243+
}
244+
245+
// we don't check the generated fixture for Red Hat in due to its size
246+
// so we only add it if it exists, so that people can have it locally
247+
// without needing to do a dance with git everytime they commit
248+
_, err := os.Stat("fixtures/redhat-versions-generated.txt")
249+
if err == nil {
250+
tests = append(tests, struct {
251+
name string
252+
file string
253+
}{
254+
name: "Red Hat",
255+
file: "redhat-versions-generated.txt",
256+
})
257+
} else if !errors.Is(err, fs.ErrNotExist) {
258+
t.Fatalf("fixtures/redhat-versions-generated.txt exists but could not be read: %v", err)
237259
}
260+
238261
for _, tt := range tests {
239262
t.Run(tt.name, func(t *testing.T) {
240263
t.Parallel()

0 commit comments

Comments
 (0)