Repository: google/osv.dev

Issue:
Version expansion for NuGet
NuGet is *almost* semver, but not exactly: https://docs.microsoft.com/en-us/nuget/concepts/package-versioning#where-nugetversion-diverges-from-semantic-versioning


Discussion:
NuGet's implementation: https://github.com/NuGet/NuGet.Client/blob/dev/src/NuGet.Core/NuGet.Versioning/VersionComparer.cs

Resolving patch:
diff --git a/lib/osv/ecosystems.py b/lib/osv/ecosystems.py
--- 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/nuget.py b/lib/osv/nuget.py
new file mode 100644
--- /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/semver_index.py b/lib/osv/semver_index.py
--- 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):


Test patch:
diff --git a/lib/osv/ecosystems_test.py b/lib/osv/ecosystems_test.py
--- 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_test.py b/lib/osv/nuget_test.py
new file mode 100644
--- /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/run_tests.sh b/lib/run_tests.sh
--- 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