Skip to content

Commit 0b38054

Browse files
committed
Add Root Ecosystem
1 parent 670522d commit 0b38054

File tree

6 files changed

+166
-0
lines changed

6 files changed

+166
-0
lines changed

docs/data.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Between the data served in OSV and the data converted to OSV the following ecosy
100100
- Python
101101
- R (CRAN and Bioconductor)
102102
- Rocky Linux
103+
- Root
103104
- RubyGems
104105
- SwiftURL
105106
- Ubuntu OS

osv/ecosystems/_ecosystems.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .pub import Pub
2828
from .pypi import PyPI
2929
from .redhat import RPM
30+
from .root import Root
3031
from .rubygems import RubyGems
3132
from .semver_ecosystem_helper import SemverEcosystem, SemverLike
3233
from .ubuntu import Ubuntu
@@ -60,6 +61,7 @@
6061
'PyPI': PyPI,
6162
'Red Hat': RPM,
6263
'Rocky Linux': RPM,
64+
'Root': Root,
6365
'RubyGems': RubyGems,
6466
'SUSE': RPM,
6567
'SwiftURL': SemverEcosystem,

osv/ecosystems/_ecosystems_test.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,37 @@ def test_maybe_normalize_package_names(self):
5353

5454
actual = ecosystems.maybe_normalize_package_names(package_name, ecosystem)
5555
self.assertEqual(actual, expected)
56+
57+
def test_root_ecosystem(self):
58+
"""Test Root ecosystem"""
59+
# Test that Root ecosystem is recognized
60+
self.assertTrue(ecosystems.is_known('Root'))
61+
self.assertTrue(ecosystems.is_known('Root:Alpine:3.18'))
62+
self.assertTrue(ecosystems.is_known('Root:Debian:12'))
63+
self.assertTrue(ecosystems.is_known('Root:PyPI'))
64+
65+
# Test that Root ecosystem can be retrieved
66+
root = ecosystems.get('Root')
67+
self.assertIsNotNone(root)
68+
69+
# Test version sorting for different Root version formats
70+
root_alpine = ecosystems.get('Root:Alpine:3.18')
71+
self.assertIsNotNone(root_alpine)
72+
73+
# Alpine format: -rXXXXX
74+
self.assertLess(root_alpine.sort_key('1.0.0-r10071'),
75+
root_alpine.sort_key('1.0.0-r10072'))
76+
self.assertLess(root_alpine.sort_key('1.0.0-r10071'),
77+
root_alpine.sort_key('2.0.0-r10071'))
78+
79+
# Python format: +root.io.X
80+
root_pypi = ecosystems.get('Root:PyPI')
81+
self.assertIsNotNone(root_pypi)
82+
self.assertLess(root_pypi.sort_key('1.0.0+root.io.1'),
83+
root_pypi.sort_key('1.0.0+root.io.2'))
84+
85+
# Other format: .root.io.X
86+
root_debian = ecosystems.get('Root:Debian:12')
87+
self.assertIsNotNone(root_debian)
88+
self.assertLess(root_debian.sort_key('1.0.0.root.io.1'),
89+
root_debian.sort_key('1.0.0.root.io.2'))

osv/ecosystems/root.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
"""Root ecosystem helper."""
15+
16+
import re
17+
from .ecosystems_base import OrderedEcosystem
18+
19+
20+
class Root(OrderedEcosystem):
21+
"""Root container security ecosystem.
22+
23+
Root provides patched container images across multiple base distributions
24+
and application ecosystems. The ecosystem uses hierarchical variants:
25+
- Root:Alpine:3.18 - Alpine Linux 3.18 based images
26+
- Root:Debian:12 - Debian 12 based images
27+
- Root:Ubuntu:22.04 - Ubuntu 22.04 based images
28+
- Root:PyPI - Python packages
29+
- Root:npm - npm packages
30+
31+
Version formats:
32+
- Alpine: <version>-r<patch_number> (e.g., 1.0.0-r10071)
33+
- Python: <version>+root.io.<patch_number> (e.g., 1.0.0+root.io.1)
34+
- Others: <version>.root.io.<patch_number> (e.g., 1.0.0.root.io.1)
35+
"""
36+
37+
def _sort_key(self, version: str):
38+
"""Generate sort key for Root version strings.
39+
40+
Handles multiple version formats:
41+
- Alpine: 1.0.0-r10071
42+
- Python: 1.0.0+root.io.1
43+
- Others: 1.0.0.root.io.1
44+
45+
Args:
46+
version: Version string to parse
47+
48+
Returns:
49+
Tuple suitable for sorting
50+
"""
51+
# Try Alpine format: <version>-r<number>
52+
alpine_match = re.match(r'^(.+?)-r(\d+)$', version)
53+
if alpine_match:
54+
upstream = alpine_match.group(1)
55+
root_patch = int(alpine_match.group(2))
56+
return self._parse_upstream_version(upstream) + (root_patch,)
57+
58+
# Try Python format: <version>+root.io.<number>
59+
python_match = re.match(r'^(.+?)\+root\.io\.(\d+)$', version)
60+
if python_match:
61+
upstream = python_match.group(1)
62+
root_patch = int(python_match.group(2))
63+
return self._parse_upstream_version(upstream) + (root_patch,)
64+
65+
# Try other format: <version>.root.io.<number>
66+
other_match = re.match(r'^(.+?)\.root\.io\.(\d+)$', version)
67+
if other_match:
68+
upstream = other_match.group(1)
69+
root_patch = int(other_match.group(2))
70+
return self._parse_upstream_version(upstream) + (root_patch,)
71+
72+
# Fallback: treat as generic version
73+
return self._parse_upstream_version(version)
74+
75+
def _parse_upstream_version(self, version: str):
76+
"""Parse upstream version component.
77+
78+
Attempts to extract numeric and string components for sorting.
79+
80+
Args:
81+
version: Upstream version string
82+
83+
Returns:
84+
Tuple of parsed components
85+
"""
86+
parts = []
87+
88+
# Split on common delimiters
89+
components = re.split(r'[.-]', version)
90+
91+
for component in components:
92+
# Try to parse as integer
93+
try:
94+
parts.append(int(component))
95+
except ValueError:
96+
# If not numeric, use string comparison
97+
# Convert to tuple of character codes for consistent sorting
98+
parts.append(component)
99+
100+
return tuple(parts)
101+
102+
def sort_key(self, version: str):
103+
"""Public sort key method.
104+
105+
Args:
106+
version: Version string
107+
108+
Returns:
109+
Tuple for sorting
110+
"""
111+
try:
112+
return self._sort_key(version)
113+
except Exception:
114+
# Fallback to string comparison if parsing fails
115+
return (version,)

osv/purl_helpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@
8787
EcosystemPURL('rpm', 'redhat'),
8888
'Rocky Linux':
8989
EcosystemPURL('rpm', 'rocky-linux'),
90+
'Root':
91+
EcosystemPURL('generic', 'root'),
9092
'RubyGems':
9193
EcosystemPURL('gem', None),
9294
'SUSE':

osv/purl_helpers_test.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ def tests_package_to_purl(self):
134134
'pkg:rpm/rocky-linux/test-package',
135135
purl_helpers.package_to_purl('Rocky Linux', 'test-package'))
136136

137+
self.assertEqual('pkg:generic/root/root-nginx',
138+
purl_helpers.package_to_purl('Root', 'root-nginx'))
139+
140+
self.assertEqual('pkg:generic/root/%40root%2Flodash',
141+
purl_helpers.package_to_purl('Root', '@root/lodash'))
142+
137143
self.assertEqual('pkg:gem/test-package',
138144
purl_helpers.package_to_purl('RubyGems', 'test-package'))
139145

@@ -285,6 +291,12 @@ def test_parse_purl(self):
285291
('Rocky Linux', 'test-package', '1.2.3'),
286292
purl_helpers.parse_purl('pkg:rpm/rocky-linux/[email protected]'))
287293

294+
self.assertEqual(('Root', 'root-nginx', '1.0.0-r10071'),
295+
purl_helpers.parse_purl('pkg:generic/root/[email protected]'))
296+
297+
self.assertEqual(('Root', '@root/lodash', '4.17.21'),
298+
purl_helpers.parse_purl('pkg:generic/root/%40root%[email protected]'))
299+
288300
self.assertEqual(('RubyGems', 'test-package', '1.2.3'),
289301
purl_helpers.parse_purl('pkg:gem/[email protected]'))
290302

0 commit comments

Comments
 (0)