Skip to content

Commit be7af62

Browse files
Root data source (#4397)
Relevant tickets for reference: #4396 (comment) #3937 --------- Co-authored-by: Chai Tadmor <chai.tadmor@root.io> Co-authored-by: Jess Lowe <86962800+jess-lowe@users.noreply.github.com>
1 parent d108d6d commit be7af62

6 files changed

Lines changed: 177 additions & 1 deletion

File tree

docs/data.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Between the data served in OSV and the data converted to OSV the following ecosy
9898
- Python
9999
- R (CRAN and Bioconductor)
100100
- Rocky Linux
101+
- Root
101102
- RubyGems
102103
- SwiftURL
103104
- Ubuntu OS

osv/ecosystems/_ecosystems.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .pub import Pub
2929
from .pypi import PyPI
3030
from .redhat import RPM
31+
from .root import Root
3132
from .rubygems import RubyGems
3233
from .semver_ecosystem_helper import SemverEcosystem, SemverLike
3334
from .ubuntu import Ubuntu
@@ -61,6 +62,7 @@
6162
'PyPI': PyPI,
6263
'Red Hat': RPM,
6364
'Rocky Linux': RPM,
65+
'Root': Root,
6466
'RubyGems': RubyGems,
6567
'SUSE': RPM,
6668
'SwiftURL': SemverEcosystem,

osv/ecosystems/_ecosystems_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,41 @@ 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(
75+
root_alpine.sort_key('1.0.0-r10071'),
76+
root_alpine.sort_key('1.0.0-r10072'))
77+
self.assertLess(
78+
root_alpine.sort_key('1.0.0-r10071'),
79+
root_alpine.sort_key('2.0.0-r10071'))
80+
81+
# Python format: +root.io.X
82+
root_pypi = ecosystems.get('Root:PyPI')
83+
self.assertIsNotNone(root_pypi)
84+
self.assertLess(
85+
root_pypi.sort_key('1.0.0+root.io.1'),
86+
root_pypi.sort_key('1.0.0+root.io.2'))
87+
88+
# Other format: .root.io.X
89+
root_debian = ecosystems.get('Root:Debian:12')
90+
self.assertIsNotNone(root_debian)
91+
self.assertLess(
92+
root_debian.sort_key('1.0.0.root.io.1'),
93+
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: 7 additions & 1 deletion
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':
@@ -140,7 +142,11 @@ def package_to_purl(ecosystem: str, package_name: str) -> str | None:
140142
'BellSoft Hardened Containers'):
141143
suffix = '?arch=source'
142144

143-
return f'pkg:{purl_ecosystem}/{_url_encode(package_name)}{suffix}'
145+
# Encode package name: preserve '/' only when no namespace is defined
146+
safe_chars = '' if purl_namespace else '/'
147+
encoded_name = quote(package_name, safe=safe_chars)
148+
149+
return f'pkg:{purl_ecosystem}/{encoded_name}{suffix}'
144150

145151

146152
def parse_purl(purl_str: str) -> ParsedPURL | None:

osv/purl_helpers_test.py

Lines changed: 14 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,14 @@ def test_parse_purl(self):
285291
('Rocky Linux', 'test-package', '1.2.3'),
286292
purl_helpers.parse_purl('pkg:rpm/rocky-linux/test-package@1.2.3'))
287293

294+
self.assertEqual(
295+
('Root', 'root-nginx', '1.0.0-r10071'),
296+
purl_helpers.parse_purl('pkg:generic/root/root-nginx@1.0.0-r10071'))
297+
298+
self.assertEqual(
299+
('Root', '@root/lodash', '4.17.21'),
300+
purl_helpers.parse_purl('pkg:generic/root/%40root%2Flodash@4.17.21'))
301+
288302
self.assertEqual(('RubyGems', 'test-package', '1.2.3'),
289303
purl_helpers.parse_purl('pkg:gem/test-package@1.2.3'))
290304

0 commit comments

Comments
 (0)