Skip to content

Commit

Permalink
Support NuGet versions and expansion (#296)
Browse files Browse the repository at this point in the history
Fixes #229.
  • Loading branch information
oliverchang authored Feb 14, 2022
1 parent a5a9ea5 commit 5e8ee35
Show file tree
Hide file tree
Showing 6 changed files with 226 additions and 2 deletions.
43 changes: 43 additions & 0 deletions lib/osv/ecosystems.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .third_party.univers.gem import GemVersion

from . import maven
from . import nuget
from . import semver_index


Expand Down Expand Up @@ -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)

Expand All @@ -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(),
}
Expand Down
11 changes: 11 additions & 0 deletions lib/osv/ecosystems_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down
72 changes: 72 additions & 0 deletions lib/osv/nuget.py
Original file line number Diff line number Diff line change
@@ -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)
86 changes: 86 additions & 0 deletions lib/osv/nuget_test.py
Original file line number Diff line number Diff line change
@@ -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()
14 changes: 12 additions & 2 deletions lib/osv/semver_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions lib/run_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 5e8ee35

Please sign in to comment.