Skip to content

Commit 3bd3a2a

Browse files
committed
Add support for NormalizedVersionRanges
- support normalization of range expression from GitHub, Snyk, GitLab - Discrete range normalization for OSV, DEPS, VulerableCode Signed-off-by: Keshav Priyadarshi <git@keshav.space>
1 parent d38d9fd commit 3bd3a2a

2 files changed

Lines changed: 813 additions & 0 deletions

File tree

src/univers/normalized_range.py

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
#
2+
# Copyright (c) nexB Inc. and others.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
# Visit https://aboutcode.org and https://github.com/nexB/univers for support and download.
6+
7+
8+
import operator
9+
import re
10+
from typing import List
11+
from typing import Union
12+
13+
import attr
14+
15+
from univers.span import Span
16+
from univers.version_range import VersionRange
17+
from univers.versions import AlpineLinuxVersion
18+
from univers.versions import ArchLinuxVersion
19+
from univers.versions import ComposerVersion
20+
from univers.versions import DebianVersion
21+
from univers.versions import GenericVersion
22+
from univers.versions import GentooVersion
23+
from univers.versions import GolangVersion
24+
from univers.versions import MavenVersion
25+
from univers.versions import NginxVersion
26+
from univers.versions import NugetVersion
27+
from univers.versions import OpensslVersion
28+
from univers.versions import PypiVersion
29+
from univers.versions import RpmVersion
30+
from univers.versions import RubygemsVersion
31+
from univers.versions import SemverVersion
32+
33+
34+
@attr.s(frozen=True, order=False, eq=True, hash=True)
35+
class NormalizedVersionRanges:
36+
"""
37+
A NormalizedVersionRange represents a list of VersionRange resolved
38+
from diffrent datsource.
39+
"""
40+
41+
# A tuple of VersionRange
42+
version_ranges = attr.ib(type=tuple, default=attr.Factory(tuple))
43+
44+
def __str__(self):
45+
return "(" + ", ".join([f"'{str(vers)}'" for vers in self.version_ranges]) + ")"
46+
47+
@staticmethod
48+
def get_span_boundry(comparator: str, version: str, version_map: dict):
49+
"""
50+
Return Span with Lower and Upper boundry limit.
51+
"""
52+
index = NormalizedVersionRanges.get_version_rank(version, version_map)
53+
resolved_operator = OPERATOR_BY_COMPRATOR.get(comparator, operator.eq)
54+
55+
if resolved_operator == operator.lt:
56+
return Span(1, index - 1)
57+
if resolved_operator == operator.gt:
58+
return Span(index + 1, len(version_map))
59+
if resolved_operator == operator.ge:
60+
return Span(index, len(version_map))
61+
if resolved_operator == operator.le:
62+
return Span(1, index)
63+
if resolved_operator == operator.eq:
64+
return Span(index)
65+
if resolved_operator == operator.ne:
66+
return Span(1, index - 1).union(Span(index + 1, len(version_map)))
67+
68+
@staticmethod
69+
def get_version_range_from_span(total_span: Span, purl_type: str, reverse_version_map: dict):
70+
"""
71+
Return list containg VersionRange for all subspans in a Span.
72+
"""
73+
version_ranges = []
74+
list_of_span = total_span.subspans()
75+
for span in list_of_span:
76+
lower_bound = reverse_version_map[span.start]
77+
upper_bound = reverse_version_map[span.end]
78+
vers_exp = (
79+
f"vers:{purl_type}/{lower_bound}"
80+
if lower_bound == upper_bound
81+
else f"vers:{purl_type}/>={lower_bound}|<={upper_bound}"
82+
)
83+
version_ranges.append(VersionRange.from_string(vers_exp))
84+
return version_ranges
85+
86+
@staticmethod
87+
def get_version_rank(version: str, version_map: dict):
88+
"""
89+
Return equivalent integer ranking for a version.
90+
"""
91+
try:
92+
return version_map[strip_leading_v(version)]
93+
except KeyError as err:
94+
err.args = (f"{version} doesn't exist.",)
95+
raise
96+
97+
@staticmethod
98+
def parse_constraint(constraint: str):
99+
"""
100+
Return operator and version from a constraint
101+
For example:
102+
>>> assert NormalizedVersionRanges.parse_constraint(">=7.0.0") == ('>=', '7.0.0')
103+
>>> assert NormalizedVersionRanges.parse_constraint("=7.0.0") == ('=', '7.0.0')
104+
>>> assert NormalizedVersionRanges.parse_constraint("[3.0.0") == ('[', '3.0.0')
105+
>>> assert NormalizedVersionRanges.parse_constraint("3.1.25]") == (']', '3.1.25')
106+
"""
107+
if constraint.startswith(("<=", ">=", "==", "!=")):
108+
return constraint[:2], constraint[2:]
109+
110+
if constraint.startswith(("<", ">", "=", "[", "(")):
111+
return constraint[0], constraint[1:]
112+
113+
if constraint.endswith(("]", ")")):
114+
return constraint[-1], constraint[:-1]
115+
return None, constraint
116+
117+
@staticmethod
118+
def get_version_map(versions: List, purl_type: str):
119+
"""
120+
Return dict mapping version to integer.
121+
"""
122+
if purl_type not in VERSIONS_BY_PACKAGE_TYPE:
123+
return
124+
125+
version_type = VERSIONS_BY_PACKAGE_TYPE.get(purl_type)
126+
sorted_versions = sorted([version_type(i) for i in versions])
127+
sorted_versions = [version.string for version in sorted_versions]
128+
index = list(range(1, len(sorted_versions) + 1, 1))
129+
return dict(zip(sorted_versions, index))
130+
131+
@classmethod
132+
def from_github(cls, range_expression: Union[str, List], purl_type: str, all_versions: List):
133+
"""
134+
Return NormalizedVersionRanges computed from GithHub version range expression.
135+
GitHub range_expression example::
136+
">= 10.4.0, < 10.4.1"
137+
"> 7.1.1"
138+
"""
139+
version_map = cls.get_version_map(all_versions, purl_type)
140+
reverse_version_map = {value: key for key, value in version_map.items()}
141+
142+
items = [range_expression] if isinstance(range_expression, str) else range_expression
143+
total_span = None
144+
for item in items:
145+
gh_constraints = item.strip().replace(" ", "")
146+
constraints = gh_constraints.split(",")
147+
local_span = None
148+
for constraint in constraints:
149+
if not constraint:
150+
continue
151+
gh_comparator, gh_version = cls.parse_constraint(constraint)
152+
span = cls.get_span_boundry(gh_comparator, strip_leading_v(gh_version), version_map)
153+
local_span = span if not local_span else local_span.intersection(span)
154+
155+
total_span = local_span if not total_span else total_span.union(local_span)
156+
157+
version_ranges = cls.get_version_range_from_span(total_span, purl_type, reverse_version_map)
158+
return cls(version_ranges=version_ranges)
159+
160+
@classmethod
161+
def from_snyk(cls, range_expression: Union[str, List], purl_type: str, all_versions: List):
162+
"""
163+
Return NormalizedVersionRanges computed from Snyk version range expression.
164+
Snyk range_expression example::
165+
">=4.0.0, <4.0.10.16"
166+
" >=4.1.0, <4.4.15.7"
167+
"[3.0.0,3.1.25)
168+
"""
169+
version_map = cls.get_version_map(all_versions, purl_type)
170+
reverse_version_map = {value: key for key, value in version_map.items()}
171+
172+
items = [range_expression] if isinstance(range_expression, str) else range_expression
173+
total_span = None
174+
for item in items:
175+
delimiter = "," if "," in item else " "
176+
if delimiter == ",":
177+
snyk_constraints = item.strip().replace(" ", "")
178+
constraints = snyk_constraints.split(",")
179+
else:
180+
snyk_constraints = item.strip()
181+
constraints = snyk_constraints.split(" ")
182+
local_span = None
183+
for constraint in constraints:
184+
if not constraint:
185+
continue
186+
snyk_comparator, snyk_version = cls.parse_constraint(constraint)
187+
if not snyk_version:
188+
continue
189+
span = cls.get_span_boundry(
190+
snyk_comparator, strip_leading_v(snyk_version), version_map
191+
)
192+
local_span = span if not local_span else local_span.intersection(span)
193+
194+
total_span = local_span if not total_span else total_span.union(local_span)
195+
196+
version_ranges = cls.get_version_range_from_span(total_span, purl_type, reverse_version_map)
197+
return cls(version_ranges=version_ranges)
198+
199+
@classmethod
200+
def from_gitlab(cls, range_expression: Union[str, List], purl_type: str, all_versions: List):
201+
"""
202+
Return NormalizedVersionRanges computed from GitLab version range expression.
203+
GitLab range_expression example::
204+
"[7.0.0,7.0.11),[7.2.0,7.2.4)"
205+
"[7.0.0,7.0.11),[7.2.0,7.2.4)"
206+
">=4.0,<4.3||>=5.0,<5.2"
207+
">=0.19.0 <0.30.0"
208+
">=1.5,<1.5.2"
209+
"""
210+
211+
version_map = cls.get_version_map(all_versions, purl_type)
212+
reverse_version_map = {value: key for key, value in version_map.items()}
213+
214+
items = [range_expression] if isinstance(range_expression, str) else range_expression
215+
global_span = None
216+
for item in items:
217+
gitlab_constraints = item.strip()
218+
if gitlab_constraints.startswith(("[", "(")):
219+
# transform "[7.0.0,7.0.11),[7.2.0,7.2.4)" -> [ "[7.0.0,7.0.11)", "[7.2.0,7.2.4)" ]
220+
splitted = gitlab_constraints.split(",")
221+
constraints = [f"{a},{b}" for a, b in zip(splitted[::2], splitted[1::2])]
222+
delimiter = ","
223+
224+
else:
225+
# transform ">=4.0,<4.3||>=5.0,<5.2" -> [ ">=4.0,<4.3", ">=5.0,<5.2" ]
226+
# transform ">=0.19.0 <0.30.0" -> [ ">=0.19.0 <0.30.0" ]
227+
# transform ">=1.5,<1.5.2" -> [ ">=1.5,<1.5.2" ]
228+
delimiter = "," if "," in gitlab_constraints else " "
229+
constraints = gitlab_constraints.split("||")
230+
total_span = None
231+
for constraint in constraints:
232+
local_span = None
233+
for subcontraint in constraint.strip().split(delimiter):
234+
if not subcontraint:
235+
continue
236+
gitlab_comparator, gitlab_version = cls.parse_constraint(subcontraint.strip())
237+
if not gitlab_version:
238+
continue
239+
span = cls.get_span_boundry(
240+
gitlab_comparator, strip_leading_v(gitlab_version), version_map
241+
)
242+
local_span = span if not local_span else local_span.intersection(span)
243+
244+
total_span = local_span if not total_span else total_span.union(local_span)
245+
global_span = total_span if not global_span else global_span.union(total_span)
246+
247+
version_ranges = cls.get_version_range_from_span(
248+
global_span, purl_type, reverse_version_map
249+
)
250+
return cls(version_ranges=version_ranges)
251+
252+
@classmethod
253+
def from_discrete(cls, range_expression: Union[str, List], purl_type: str, all_versions: List):
254+
"""
255+
Return NormalizedVersionRanges computed from discrete version range expression.
256+
Discrete range_expression example::
257+
["1.5","3.1.2","3.1-beta"]
258+
"""
259+
version_map = cls.get_version_map(all_versions, purl_type)
260+
reverse_version_map = {value: key for key, value in version_map.items()}
261+
262+
item = range_expression if isinstance(range_expression, str) else " ".join(range_expression)
263+
discrete_versions = re.split("[ ,\n]+", item)
264+
265+
rank_list = []
266+
for version in discrete_versions:
267+
try:
268+
rank_int = version_map[strip_leading_v(version)]
269+
rank_list.append(rank_int)
270+
except KeyError:
271+
pass
272+
273+
total_span = Span(rank_list)
274+
275+
version_ranges = cls.get_version_range_from_span(total_span, purl_type, reverse_version_map)
276+
return cls(version_ranges)
277+
278+
279+
def strip_leading_v(version: str):
280+
"""
281+
Return version without leading v.
282+
"""
283+
if not version.startswith("v"):
284+
return version
285+
return version[1:]
286+
287+
288+
VERSIONS_BY_PACKAGE_TYPE = {
289+
"alpine": AlpineLinuxVersion,
290+
"alpm": ArchLinuxVersion,
291+
"apache": SemverVersion,
292+
"cargo": SemverVersion,
293+
# "cocoapods": None,
294+
"composer": ComposerVersion,
295+
# "conan": None,
296+
# "conda": None,
297+
# "cran": None,
298+
"deb": DebianVersion,
299+
"ebuild": GentooVersion,
300+
"gem": RubygemsVersion,
301+
"generic": GenericVersion,
302+
"github": SemverVersion,
303+
"golang": GolangVersion,
304+
"hex": SemverVersion,
305+
"mattermost": SemverVersion,
306+
"maven": MavenVersion,
307+
"mozilla": SemverVersion,
308+
"nginx": NginxVersion,
309+
"npm": SemverVersion,
310+
"nuget": NugetVersion,
311+
"openssl": OpensslVersion,
312+
"pypi": PypiVersion,
313+
"rpm": RpmVersion,
314+
# "swift": None,
315+
}
316+
317+
OPERATOR_BY_COMPRATOR = {
318+
"<": operator.lt,
319+
">": operator.gt,
320+
"=": operator.eq,
321+
"<=": operator.le,
322+
">=": operator.ge,
323+
"==": operator.eq,
324+
"!=": operator.ne,
325+
")": operator.lt,
326+
"]": operator.le,
327+
"(": operator.gt,
328+
"[": operator.ge,
329+
}

0 commit comments

Comments
 (0)