Coverage for /home/benjarobin/Bootlin/projects/Schneider-Electric-Senux/sbom-cve-check/src/sbom_cve_check/vuln/version.py: 93%
207 statements
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-28 15:37 +0100
« prev ^ index » next coverage.py v7.11.1, created at 2025-11-28 15:37 +0100
1# -*- coding: utf-8 -*-
2# SPDX-License-Identifier: GPL-2.0-only
4import abc
5import dataclasses
6import functools
7import itertools
8import operator
9import re
10from collections.abc import Callable
11from enum import Enum
12from typing import Any, Final
14_VERSION_PATTERN = r"""
15 ^[rv]?
16 (?:[0-9]:)? # epoch
17 (?P<release> # release segment
18 [0-9]+(?:\.(?:[0-9]+|\*))+
19 |
20 [0-9]+(?:-[0-9]+)+
21 |
22 (?:[0-9]+|\*)
23 )
24 (?: # alphabetical segment
25 [._-]?(?P<letter>[a-z])
26 (?:$|(?=[._-]))
27 )?
28 (?: # patch segment
29 [._-]?
30 (p|patch)_?
31 (?P<patch_n>[0-9]+)
32 )?
33 (?: # pre-release
34 [._-]?
35 (?P<pre_l1>(alpha|beta|preview|pre|rc|dev))
36 [._-]?
37 (?P<pre_n1>[0-9]+)?
38 |
39 [._-]?
40 (?P<pre_l2>(a|b|c))
41 (?P<pre_n2>[0-9]+)
42 )?
43 (?: # post release
44 (?:-(?P<post_n1>[0-9]+))
45 |
46 (?:
47 [._-]?
48 (?:post|rev|r)
49 [._-]?
50 (?P<post_n2>[0-9]+)?
51 )
52 )?
53"""
55_version_regex = re.compile(_VERSION_PATTERN, re.VERBOSE | re.IGNORECASE)
58@functools.total_ordering
59class InfinityType:
60 def __repr__(self) -> str:
61 return "Infinity"
63 def __hash__(self) -> int:
64 return hash(repr(self))
66 def __eq__(self, other: object) -> bool:
67 return isinstance(other, InfinityType)
69 def __gt__(self, other: object) -> bool:
70 return not isinstance(other, InfinityType)
73INFINITY: Final = InfinityType()
76@functools.total_ordering
77class NegInfinityType:
78 def __repr__(self) -> str:
79 return "-Infinity"
81 def __hash__(self) -> int:
82 return hash(repr(self))
84 def __eq__(self, other: object) -> bool:
85 return isinstance(other, NegInfinityType)
87 def __lt__(self, other: object) -> bool:
88 return not isinstance(other, NegInfinityType)
91NEG_INFINITY: Final = NegInfinityType()
94def _parse_pre_release(
95 letter: str | None, number: str | None
96) -> tuple[str, int] | None:
97 if letter is None:
98 return None
100 # We consider there to be an implicit 0 in a pre-release if there is not a numeral
101 # associated with it.
102 num_val = 0 if number is None else int(number)
104 # We normalize any letters to their lower case form
105 letter = letter.lower()
107 # We consider some words to be alternate spellings of other words and in those
108 # cases we want to normalize the spellings to our preferred spelling.
109 if letter == "alpha":
110 letter = "a"
111 elif letter == "beta":
112 letter = "b"
113 elif letter in ["c", "pre", "preview"]:
114 letter = "rc"
116 return letter, num_val
119def _parse_vers_number(v: str | None) -> int | None:
120 if v is not None:
121 return int(v)
122 return None
125def _create_compare_keys(version: str) -> tuple[Any, ...]:
126 match = _version_regex.search(version)
127 if not match:
128 return ()
130 rel_match = match.group("release")
131 # If the version starts with a 'r', consider this is a simple CVS revision,
132 # so create a fake version: 0.0.0.r
133 if (version[0:1] == "r") and ("." not in rel_match):
134 rel_match = f"0.0.0.{rel_match}"
135 release = rel_match.replace("-", ".").split(".")
136 letter = match.group("letter")
137 patch = _parse_vers_number(match.group("patch_n"))
138 pre = _parse_pre_release(
139 match.group("pre_l1"), match.group("pre_n1")
140 ) or _parse_pre_release(match.group("pre_l2"), match.group("pre_n2"))
141 post = _parse_vers_number(match.group("post_n1") or match.group("post_n2"))
143 # When we compare a release version, we want to compare it with all the trailing
144 # zeros removed. So we'll reverse the list, drop all the now leading zeros until we
145 # come to something non-zero, then take the rest re-reverse it back into the
146 # correct order.
147 release_p = [INFINITY if v == "*" else int(v) for v in release]
148 _release = tuple(
149 reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release_p))))
150 )
152 # Versions without a letter (after release part) should sort before those with one.
153 _letter = NEG_INFINITY if letter is None else letter
155 # Versions without a patch segment should sort before those with one.
156 _patch = NEG_INFINITY if patch is None else patch
158 # Versions without a pre-release should sort after those with one.
159 _pre = INFINITY if pre is None else pre
161 # Versions without a post segment should sort before those with one.
162 _post = NEG_INFINITY if post is None else post
164 return _release, _letter, _patch, _pre, _post
167@functools.total_ordering
168class Version:
169 def __init__(self, v: str) -> None:
170 self._version = v.strip()
171 self._cmp_keys = _create_compare_keys(self._version)
173 def __repr__(self) -> str:
174 return self._version
176 @property
177 def original(self) -> str:
178 return self._version
180 def is_valid(self) -> bool:
181 return bool(self._cmp_keys)
183 def __hash__(self) -> int:
184 return hash(self._cmp_keys)
186 def __eq__(self, other: object) -> bool:
187 if not isinstance(other, Version):
188 return NotImplemented
189 return self._cmp_keys == other._cmp_keys
191 def __gt__(self, other: object) -> bool:
192 if not isinstance(other, Version):
193 return NotImplemented
194 return self._cmp_keys > other._cmp_keys
197_OPERATOR_TO_STR: Final[dict[Callable[[Version, Version], bool] | None, str]] = {
198 operator.lt: "<",
199 operator.le: "<=",
200 operator.eq: "=",
201 operator.ge: ">=",
202 operator.gt: ">",
203 None: "",
204}
207class VersRangeStatus(Enum):
208 NO_INFO = None
209 SMALLER = -1
210 IN_RANGE = 0
211 GREATER = 1
214@dataclasses.dataclass(frozen=True, kw_only=True)
215class VersionRange(abc.ABC):
216 vulnerable: bool | None
217 from_cpe: bool | None = None
218 from_cna: str | None = None # CNA organization identifier
220 def is_not_vuln_trusted(self) -> bool:
221 """
222 :return: True if we trust version range that are declared not vulnerable.
223 By default, do not trust it.
224 """
225 # Special case for kernel.org CNA
226 return self.from_cna == "416baaa9-dc9f-4396-8d5f-8c081fb06d67"
229@dataclasses.dataclass(frozen=True, kw_only=True)
230class _CmpVersion:
231 vers: Version
232 op: Callable[[Version, Version], bool]
234 def compare(self, pkg_version: Version) -> bool:
235 return self.op(pkg_version, self.vers)
238@dataclasses.dataclass(frozen=True, kw_only=True)
239class SemVerRange(VersionRange):
240 v_start: str | None
241 op_start: Callable[[Version, Version], bool] | None
242 v_end: str | None
243 op_end: Callable[[Version, Version], bool] | None
245 def __str__(self) -> str:
246 s = f"[{_OPERATOR_TO_STR[self.op_start]} {self.v_start}"
247 if self.v_end:
248 s = f"{s}, {_OPERATOR_TO_STR[self.op_end]} {self.v_end}"
249 return f"{s}]: vuln={self.vulnerable}"
251 @staticmethod
252 def build_single_vers(
253 vulnerable: bool, v_start: str, from_cpe: bool
254 ) -> "SemVerRange":
255 return SemVerRange(
256 vulnerable=vulnerable,
257 from_cpe=from_cpe,
258 v_start=v_start,
259 op_start=operator.eq,
260 v_end=None,
261 op_end=None,
262 )
264 def check_strictly_equal(self, pkg_version: str) -> bool:
265 """
266 For annotation, check that the specified version is strictly equal to the start
267 version. If this range does not specify a unique version, always return false
268 """
269 return (pkg_version == self.v_start) and (self.op_start == operator.eq)
271 def check_in_range(self, pkg_version: Version) -> VersRangeStatus:
272 """
273 Check if a component version is within specified range. If this is not the case,
274 indicate if the component version is smaller or greater than the specified
275 version range.
276 """
277 if not pkg_version.is_valid():
278 return VersRangeStatus.NO_INFO
280 cmp_start: _CmpVersion | None = None
281 if (self.v_start is not None) and (self.op_start is not None):
282 v = Version(self.v_start)
283 if v.is_valid():
284 cmp_start = _CmpVersion(vers=v, op=self.op_start)
286 cmp_end: _CmpVersion | None = None
287 if (self.v_end is not None) and (self.op_end is not None):
288 v = Version(self.v_end)
289 if v.is_valid():
290 cmp_end = _CmpVersion(vers=v, op=self.op_end)
292 if (cmp_start is not None) and (cmp_end is not None):
293 r_start = cmp_start.compare(pkg_version)
294 r_end = cmp_end.compare(pkg_version)
295 if r_start and r_end:
296 return VersRangeStatus.IN_RANGE
297 if not r_start:
298 return VersRangeStatus.SMALLER
299 return VersRangeStatus.GREATER
301 if cmp_start is not None:
302 r_start = cmp_start.compare(pkg_version)
303 if self.op_start == operator.eq:
304 if r_start:
305 return VersRangeStatus.IN_RANGE
306 if pkg_version < cmp_start.vers:
307 return VersRangeStatus.SMALLER
308 return VersRangeStatus.GREATER
309 if r_start:
310 return VersRangeStatus.IN_RANGE
311 return VersRangeStatus.SMALLER
313 if cmp_end is not None:
314 r_end = cmp_end.compare(pkg_version)
315 if r_end:
316 return VersRangeStatus.IN_RANGE
317 return VersRangeStatus.GREATER
319 return VersRangeStatus.NO_INFO
322@dataclasses.dataclass(frozen=True, kw_only=True)
323class GitVerRange(VersionRange):
324 # This git commit is that start version (included)
325 v_start: str
326 # This git commit is that end version (that may be included in the range)
327 v_end: str | None = None
328 end_included: bool = False
331class VersionRangeBuilder:
332 def __init__(
333 self,
334 *,
335 vulnerable: bool | None,
336 from_cpe: bool | None = None,
337 from_cna: str | None = None,
338 ) -> None:
339 self._vulnerable: bool | None = vulnerable
340 self._from_cpe: bool | None = from_cpe
341 self._from_cna: str | None = from_cna
342 self._v_start: str | None = None
343 self._start_including: bool | None = None
344 self._start_equal: bool | None = None
345 self._v_end: str | None = None
346 self._end_including: bool | None = None
348 def set_start_version(
349 self, version: str, *, including: bool = False, equal: bool = False
350 ) -> None:
351 self._v_start = version
352 self._start_including = including
353 self._start_equal = equal
355 def set_end_version(self, version: str, *, including: bool = False) -> None:
356 self._v_end = None if version == "*" else version
357 self._end_including = including
359 def is_valid(self) -> bool:
360 return (self._v_start is not None) or (self._v_end is not None)
362 @property
363 def sem_ver_range(self) -> SemVerRange:
364 op_start: Callable[[Version, Version], bool] | None = None
365 if self._v_start is not None:
366 if self._start_equal:
367 op_start = operator.eq
368 elif self._start_including:
369 op_start = operator.ge
370 else:
371 op_start = operator.gt
373 op_end = None
374 if self._v_end is not None:
375 op_end = operator.le if self._end_including else operator.lt
377 return SemVerRange(
378 vulnerable=self._vulnerable,
379 from_cpe=self._from_cpe,
380 from_cna=self._from_cna,
381 v_start=self._v_start,
382 op_start=op_start,
383 v_end=self._v_end,
384 op_end=op_end,
385 )
387 @property
388 def git_ver_range(self) -> GitVerRange:
389 if self._start_equal or not self._start_including:
390 raise ValueError(
391 'Start version for a git version range is always "including"'
392 )
394 if self._v_start is None:
395 raise ValueError("Missing start version")
397 return GitVerRange(
398 vulnerable=self._vulnerable,
399 from_cpe=self._from_cpe,
400 from_cna=self._from_cna,
401 v_start=self._v_start,
402 v_end=self._v_end,
403 end_included=bool(self._end_including),
404 )