diff --git a/lib/osv/ecosystems.py b/lib/osv/ecosystems.py index 99840861e4d..0ca09b18bce 100644 --- a/lib/osv/ecosystems.py +++ b/lib/osv/ecosystems.py @@ -21,6 +21,7 @@ from .third_party.univers.gem import GemVersion from . import maven +from . import nuget from . import semver_index @@ -206,6 +207,47 @@ def enumerate_versions(self, package, introduced, fixed, limits=None): response = response.json() versions = [entry['number'] for entry in response] + + self.sort_versions(versions) + return self._get_affected_versions(versions, introduced, fixed, limits) + + +class NuGet(Ecosystem): + """NuGet ecosystem.""" + + _API_PACKAGE_URL = ('https://api.nuget.org/v3/registration3/{package}/' + 'index.json') + + def sort_key(self, version): + """Sort key.""" + return nuget.Version.from_string(version) + + def enumerate_versions(self, package, introduced, fixed, limits=None): + """Enumerate versions.""" + url = self._API_PACKAGE_URL.format(package=package.lower()) + response = requests.get(url) + if response.status_code != 200: + raise RuntimeError( + f'Failed to get NuGet versions for {package} with: {response.text}') + + response = response.json() + + versions = [] + for page in response['items']: + if 'items' in page: + items = page['items'] + else: + items_response = requests.get(page['@id']) + if items_response.status_code != 200: + raise RuntimeError( + f'Failed to get NuGet versions page for {package} with: ' + f'{response.text}') + + items = items_response.json()['items'] + + for item in items: + versions.append(item['catalogEntry']['version']) + self.sort_versions(versions) return self._get_affected_versions(versions, introduced, fixed, limits) @@ -215,6 +257,7 @@ def enumerate_versions(self, package, introduced, fixed, limits=None): 'Go': Go(), 'Maven': Maven(), 'npm': NPM(), + 'NuGet': NuGet(), 'PyPI': PyPI(), 'RubyGems': RubyGems(), } diff --git a/lib/osv/ecosystems_test.py b/lib/osv/ecosystems_test.py index 262f8cae6d1..f76f6848e1a 100644 --- a/lib/osv/ecosystems_test.py +++ b/lib/osv/ecosystems_test.py @@ -43,6 +43,17 @@ def test_gems(self): self.assertEqual('5.0.0.racecar1', ecosystem.next_version('rails', '5.0.0.beta4')) + def test_nuget(self): + ecosystem = ecosystems.get('NuGet') + self.assertEqual('3.0.1', + ecosystem.next_version('NuGet.Server.Core', '3.0.0')) + self.assertEqual('3.0.0.4001', + ecosystem.next_version('Castle.Core', '3.0.0.3001')) + self.assertEqual('3.1.0-RC', + ecosystem.next_version('Castle.Core', '3.0.0.4001')) + self.assertEqual('2.1.0-dev-00668', + ecosystem.next_version('Serilog', '2.1.0-dev-00666')) + def test_semver(self): ecosystem = ecosystems.get('Go') self.assertEqual('1.0.1-0', ecosystem.next_version('blah', '1.0.0')) diff --git a/lib/osv/nuget.py b/lib/osv/nuget.py new file mode 100644 index 00000000000..5f4c7a3dc65 --- /dev/null +++ b/lib/osv/nuget.py @@ -0,0 +1,72 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""NuGet version parser.""" + +import functools +import re + +from . import semver_index + +# This relies on a strict SemVer implementation. +# Differences from SemVer are described at +# https://docs.microsoft.com/en-us/nuget/concepts/package-versioning +# - Optional 4th component (x.y.z.R). +# - Prerelease components are compared case insensitively. +# - Non-major version segments are optional. e.g. "1" is a valid version +# number. + + +def _extract_revision(str_version): + """Extract revision (4th component) from version number (if any).""" + # e.g. '1.0.0.0-prerelease' + pattern = re.compile(r'^(\d+)(\.\d+)(\.\d+)(\.\d+)(.*)') + match = pattern.match(str_version) + if not match: + return str_version, 0 + + return (''.join( + (match.group(1), match.group(2), match.group(3), match.group(5))), + int(match.group(4)[1:])) + + +@functools.total_ordering +class Version: + """NuGet version.""" + + def __init__(self, base_semver, revision): + self._base_semver = base_semver + if self._base_semver.prerelease: + self._base_semver = self._base_semver.replace( + prerelease=base_semver.prerelease.lower()) + self._revision = revision + + def __eq__(self, other): + return (self._base_semver == other._base_semver and + self._revision == other._revision) + + def __lt__(self, other): + if (self._base_semver.replace(prerelease='') == other._base_semver.replace( + prerelease='')): + # If the first three components are the same, compare the revision. + if self._revision != other._revision: + return self._revision < other._revision + + # Revision is the same, so ignore it for comparison purposes. + return self._base_semver < other._base_semver + + @classmethod + def from_string(cls, str_version): + str_version = semver_index.coerce(str_version) + str_version, revision = _extract_revision(str_version) + return Version(semver_index.parse(str_version), revision) diff --git a/lib/osv/nuget_test.py b/lib/osv/nuget_test.py new file mode 100644 index 00000000000..9800c3cde4d --- /dev/null +++ b/lib/osv/nuget_test.py @@ -0,0 +1,86 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# pylint: disable=line-too-long +# Many tests are ported from +# https://github.com/NuGet/NuGet.Client/blob/dev/test/NuGet.Core.Tests/NuGet.Versioning.Test/VersionComparerTests.cs +"""NuGet version parser tests.""" + +import unittest + +from . import nuget + + +class NuGetTest(unittest.TestCase): + """NuGet version tests.""" + + def setUp(self): + self.maxDiff = None # pylint: disable=invalid-name + + def check_order(self, comparison, first, second): + """Check order.""" + comparison( + nuget.Version.from_string(first), nuget.Version.from_string(second)) + + def test_equals(self): + """Test version equals.""" + self.check_order(self.assertEqual, '1.0.0', '1.0.0') + self.check_order(self.assertEqual, '1.0.0-BETA', '1.0.0-beta') + self.check_order(self.assertEqual, '1.0.0-BETA+AA', '1.0.0-beta+aa') + self.check_order(self.assertEqual, '1.0.0-BETA.X.y.5.77.0+AA', + '1.0.0-beta.x.y.5.77.0+aa') + self.check_order(self.assertEqual, '1.0.0', '1.0.0+beta') + + self.check_order(self.assertEqual, '1.0', '1.0.0.0') + self.check_order(self.assertEqual, '1.0+test', '1.0.0.0') + self.check_order(self.assertEqual, '1.0.0.1-1.2.A', '1.0.0.1-1.2.a+A') + self.check_order(self.assertEqual, '1.0.01', '1.0.1.0') + + def test_not_equals(self): + """Test version not equals.""" + self.check_order(self.assertNotEqual, '1.0', '1.0.0.1') + self.check_order(self.assertNotEqual, '1.0+test', '1.0.0.1') + self.check_order(self.assertNotEqual, '1.0.0.1-1.2.A', '1.0.0.1-1.2.a.A+A') + self.check_order(self.assertNotEqual, '1.0.01', '1.0.1.2') + self.check_order(self.assertNotEqual, '0.0.0', '1.0.0') + self.check_order(self.assertNotEqual, '1.1.0', '1.0.0') + self.check_order(self.assertNotEqual, '1.0.1', '1.0.0') + self.check_order(self.assertNotEqual, '1.0.0-BETA', '1.0.0-beta2') + self.check_order(self.assertNotEqual, '1.0.0+AA', '1.0.0-beta+aa') + self.check_order(self.assertNotEqual, '1.0.0-BETA.X.y.5.77.0+AA', + '1.0.0-beta.x.y.5.79.0+aa') + + def test_less(self): + """Test version less.""" + self.check_order(self.assertLess, '0.0.0', '1.0.0') + self.check_order(self.assertLess, '1.0.0', '1.1.0') + self.check_order(self.assertLess, '1.0.0', '1.0.1') + self.check_order(self.assertLess, '1.999.9999', '2.1.1') + self.check_order(self.assertLess, '1.0.0-BETA', '1.0.0-beta2') + self.check_order(self.assertLess, '1.0.0-beta+AA', '1.0.0+aa') + self.check_order(self.assertLess, '1.0.0-BETA', '1.0.0-beta.1+AA') + self.check_order(self.assertLess, '1.0.0-BETA.X.y.5.77.0+AA', + '1.0.0-beta.x.y.5.79.0+aa') + self.check_order(self.assertLess, '1.0.0-BETA.X.y.5.79.0+AA', + '1.0.0-beta.x.y.5.790.0+abc') + + self.check_order(self.assertLess, '1.0.0', '1.0.0.1') + self.check_order(self.assertLess, '1.0.0.1-alpha', '1.0.0.1-pre') + self.check_order(self.assertLess, '1.0.0-pre', '1.0.0.1-alpha') + self.check_order(self.assertLess, '1.0.0', '1.0.0.1-alpha') + self.check_order(self.assertLess, '0.9.9.1', '1.0.0') + + +if __name__ == '__main__': + unittest.main() diff --git a/lib/osv/semver_index.py b/lib/osv/semver_index.py index fa8fd9035bc..2f9e40d3161 100644 --- a/lib/osv/semver_index.py +++ b/lib/osv/semver_index.py @@ -31,15 +31,25 @@ def _strip_leading_v(version): return version +def _remove_leading_zero(component): + """Remove leading zeros from a component.""" + if component[0] == '.': + return '.' + str(int(component[1:])) + + return str(int(component)) + + def coerce(version): """Coerce a potentially invalid semver into valid semver.""" version = _strip_leading_v(version) - version_pattern = re.compile(r'^(\d+)(\.\d+)?(\.\d+)?$') + version_pattern = re.compile(r'^(\d+)(\.\d+)?(\.\d+)?(.*)$') match = version_pattern.match(version) if not match: return version - return match.group(1) + (match.group(2) or '.0') + (match.group(3) or '.0') + return (_remove_leading_zero(match.group(1)) + + _remove_leading_zero(match.group(2) or '.0') + + _remove_leading_zero(match.group(3) or '.0') + match.group(4)) def is_valid(version): diff --git a/lib/run_tests.sh b/lib/run_tests.sh index 7b940379e8f..bf26e482296 100755 --- a/lib/run_tests.sh +++ b/lib/run_tests.sh @@ -4,3 +4,5 @@ unset PIP_NO_BINARY pipenv sync pipenv run python -m unittest osv.bug_test pipenv run python -m unittest osv.ecosystems_test +pipenv run python -m unittest osv.maven.version_test +pipenv run python -m unittest osv.nuget_test