diff --git a/osv/ecosystems/_ecosystems.py b/osv/ecosystems/_ecosystems.py index d0f7c45e34c..ebf4a2964b2 100644 --- a/osv/ecosystems/_ecosystems.py +++ b/osv/ecosystems/_ecosystems.py @@ -23,6 +23,7 @@ from .echo import Echo from .haskell import Hackage, GHC from .hex import Hex +from .homebrew import Homebrew from .maven import Maven from .nuget import NuGet from .opam import Opam @@ -57,6 +58,7 @@ 'Go': SemverEcosystem, 'Hackage': Hackage, 'Hex': Hex, + 'Homebrew': Homebrew, 'Julia': SemverEcosystem, 'Mageia': RPM, 'Maven': Maven, @@ -118,6 +120,7 @@ def is_known(ecosystem: str) -> bool: 'Go': 'https://', 'Hackage': 'https://hackage.haskell.org/package/', 'Hex': 'https://hex.pm/packages/', + 'Homebrew': 'https://formulae.brew.sh/formula/', 'Mageia': 'https://madb.mageia.org/show?rpm=', 'npm': 'https://www.npmjs.com/package/', 'NuGet': 'https://www.nuget.org/packages/', diff --git a/osv/ecosystems/coarse_version_monotonicity_test.py b/osv/ecosystems/coarse_version_monotonicity_test.py index a713b1cdb65..c6e7d991f58 100644 --- a/osv/ecosystems/coarse_version_monotonicity_test.py +++ b/osv/ecosystems/coarse_version_monotonicity_test.py @@ -25,6 +25,7 @@ from . import cran from . import debian from . import haskell +from . import homebrew from . import maven from . import nuget from . import packagist @@ -59,6 +60,14 @@ # Matches Haskell versions: dot-separated integers (e.g. 1.2.3). hackage_version_strategy = st.from_regex(r'^[0-9]+(\.[0-9]+)*$') +# Matches Homebrew PkgVersions: dot/dash-separated numerics with an optional +# prerelease (alpha/beta/pre/rc) or patch (p/post) marker or trailing letter, +# and an optional `_N` revision suffix. +homebrew_version_strategy = st.from_regex( + r'^[0-9]+([.-][0-9]+)*' + r'(alpha[0-9]*|beta[0-9]*|pre[0-9]*|rc[0-9]*|-p[0-9]+|\.post[0-9]+|[a-z])?' + r'(_[0-9]+)?$') + # Matches Maven versions: flexible sequence of numbers or identifiers # separated by dots or dashes. maven_version_strategy = st.from_regex(r'^(([0-9]*|[A-Za-z+]*)[.-]?)*$') @@ -134,6 +143,12 @@ def test_dpkg(self, v1_str, v2_str): def test_hackage(self, v1_str, v2_str): check_coarse_version_monotonic(self, haskell.Hackage(), v1_str, v2_str) + @given(homebrew_version_strategy, homebrew_version_strategy) + @example('2.1.0-p194', '2.1-p195') + @example('1.81.6_5', '1.81.6_6') + def test_homebrew(self, v1_str, v2_str): + check_coarse_version_monotonic(self, homebrew.Homebrew(), v1_str, v2_str) + @given(maven_version_strategy, maven_version_strategy) def test_maven(self, v1_str, v2_str): check_coarse_version_monotonic(self, maven.Maven(), v1_str, v2_str) diff --git a/osv/ecosystems/homebrew.py b/osv/ecosystems/homebrew.py new file mode 100644 index 00000000000..74d1645d051 --- /dev/null +++ b/osv/ecosystems/homebrew.py @@ -0,0 +1,180 @@ +# Copyright 2026 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. +"""Homebrew ecosystem helper. + +Version comparison ports Homebrew's `PkgVersion` and `Version#<=>` +(https://github.com/Homebrew/brew/blob/HEAD/Library/Homebrew/version.rb, +https://github.com/Homebrew/brew/blob/HEAD/Library/Homebrew/pkg_version.rb): +tokenise into numeric / prerelease-marker / patch-marker / string parts and +compare with a two-pointer walk that treats a numeric zero as equal to a +missing token, then break ties on the numeric `_N` revision suffix. +""" + +import functools +import re + +from .ecosystems_base import OrderedEcosystem, coarse_version_generic + +# Token kinds. Negative kinds are prerelease markers (sort below a missing +# token); NUMERIC 0 is equal to a missing token; STRING/PATCH/POST sort above. +_ALPHA, _BETA, _PRE, _RC = -4, -3, -2, -1 +_NULL = 0 +_STRING, _PATCH, _POST, _NUMERIC = 1, 2, 3, 4 + +# Matches Homebrew's Version::SCAN_PATTERN. Order matters: prerelease/post +# markers must be tried before the generic numeric/string fallbacks. +_TOKEN_PATTERNS = ( + (_ALPHA, r'alpha[0-9]*|a[0-9]+'), + (_BETA, r'beta[0-9]*|b[0-9]+'), + (_PRE, r'pre[0-9]*'), + (_RC, r'rc[0-9]*'), + (_PATCH, r'p[0-9]*'), + (_POST, r'.post[0-9]+'), + (_NUMERIC, r'[0-9]+'), + (_STRING, r'[a-z]+'), +) +_SCAN_RE = re.compile('|'.join(f'({p})' for _, p in _TOKEN_PATTERNS), re.I) +_PKG_VERSION_RE = re.compile(r'\A(.+?)(?:_(\d+))?\Z') + +_NULL_TOKEN = (_NULL, 0) + + +def _classify(match: re.Match) -> tuple[int, int | str]: + """Return (kind, value) for a token regex match.""" + for i, (kind, _) in enumerate(_TOKEN_PATTERNS, start=1): + text = match.group(i) + if text is None: + continue + if kind == _NUMERIC: + return (_NUMERIC, int(text)) + if kind == _STRING: + return (_STRING, text.lower()) + # Composite tokens (alpha/beta/pre/rc/patch/post): compare by the + # trailing numeric part within the same kind. + m = re.search(r'[0-9]+', text) + return (kind, int(m.group(0)) if m else 0) + raise ValueError('unreachable') + + +def _tokenise(version: str) -> list[tuple[int, int | str]]: + return [_classify(m) for m in _SCAN_RE.finditer(version)] + + +def _cmp_token(a: tuple[int, int | str], b: tuple[int, int | str]) -> int: + """Port of Homebrew's per-token `<=>` for the same-shape (both numeric, + both non-numeric, or one side null) case.""" + ak, av = a + bk, bv = b + if ak == _NULL: + if bk == _NULL: + return 0 + if bk == _NUMERIC: + return 0 if bv == 0 else -1 + # Prerelease markers sort below release; string/patch/post above. + return 1 if bk < 0 else -1 + if bk == _NULL: + return -_cmp_token(b, a) # pylint: disable=arguments-out-of-order + if ak == bk: + return (av > bv) - (av < bv) + # Cross-kind for non-numeric composites falls through to string comparison + # in Homebrew (e.g. PatchToken vs PostToken); use kind rank as a total order + # over the marker kinds, which matches every case Homebrew's spec covers. + return (ak > bk) - (ak < bk) + + +def _cmp_version(lt: list, rt: list) -> int: + """Port of Homebrew's two-pointer `Version#<=>` walk. + + When one side is numeric and the other is not, a positive numeric wins + outright; a numeric zero is skipped so `2.1.0-p194` and `2.1-p194` align. + """ + n = max(len(lt), len(rt)) + li = ri = 0 + while li < n: + a = lt[li] if li < len(lt) else _NULL_TOKEN + b = rt[ri] if ri < len(rt) else _NULL_TOKEN + if a == b: + li += 1 + ri += 1 + continue + a_num = a[0] == _NUMERIC + b_num = b[0] == _NUMERIC + if a_num and not b_num: + if _cmp_token(a, _NULL_TOKEN) > 0: + return 1 + li += 1 + elif b_num and not a_num: + if _cmp_token(b, _NULL_TOKEN) > 0: + return -1 + ri += 1 + else: + return _cmp_token(a, b) + return 0 + + +@functools.total_ordering +class HomebrewPkgVersion: + """Comparable Homebrew `PkgVersion` (upstream version + `_N` revision).""" + + __slots__ = ('_raw', '_tokens', '_revision') + + def __init__(self, version: str): + m = _PKG_VERSION_RE.match(version) + if not m or not m.group(1): + raise ValueError(f'Invalid version: {version!r}') + self._raw = version + self._tokens = _tokenise(m.group(1)) + self._revision = int(m.group(2)) if m.group(2) else 0 + + def __repr__(self) -> str: + return f'HomebrewPkgVersion({self._raw!r})' + + def __eq__(self, other) -> bool: + if not isinstance(other, HomebrewPkgVersion): + return NotImplemented + return (_cmp_version(self._tokens, other._tokens) == 0 and + self._revision == other._revision) + + def __lt__(self, other) -> bool: + if not isinstance(other, HomebrewPkgVersion): + return NotImplemented + c = _cmp_version(self._tokens, other._tokens) + if c != 0: + return c < 0 + return self._revision < other._revision + + +class Homebrew(OrderedEcosystem): + """Homebrew ecosystem helper.""" + + def _sort_key(self, version): + return HomebrewPkgVersion(version) + + def coarse_version(self, version: str) -> str: + """Coarse version. + + Strips any `_N` revision suffix, then treats the version segment as + dot-separated with implicit digit/non-digit splits, truncating at the + first non-numeric token so prerelease/patch markers do not violate + monotonicity (e.g. `1.2.3rc1 < 1.2.3` while both coarse to + `00:00000001.00000002.00000003`). + """ + self._sort_key(version) + m = _PKG_VERSION_RE.match(version) + return coarse_version_generic( + m.group(1), + separators_regex=r'[._\-+~]', + truncate_regex=None, + implicit_split=True, + ) diff --git a/osv/ecosystems/homebrew_test.py b/osv/ecosystems/homebrew_test.py new file mode 100644 index 00000000000..2a84c8e78c2 --- /dev/null +++ b/osv/ecosystems/homebrew_test.py @@ -0,0 +1,153 @@ +# Copyright 2026 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. +"""Homebrew ecosystem helper tests. + +Comparison cases are taken from +https://github.com/Homebrew/brew/blob/HEAD/Library/Homebrew/test/version_spec.rb +plus PkgVersion `_N` revision-suffix cases. +""" + +import unittest + +from .. import ecosystems +from .homebrew import HomebrewPkgVersion + + +class HomebrewVersionCompareTest(unittest.TestCase): + """Homebrew Version#<=> parity tests.""" + + # (a, op, b) where op is '<', '>' or '=='. + _cases = [ + # basic ordering + ('0.1', '<', '0.2'), + ('1.2.3', '>', '1.2.2'), + ('1.2.4', '<', '1.2.4.1'), + # prerelease markers sort below the release + ('1.2.3', '>', '1.2.3alpha4'), + ('1.2.3', '>', '1.2.3beta2'), + ('1.2.3', '>', '1.2.3rc3'), + ('1.2.3alpha', '<', '1.2.3'), + # bare trailing letter is a StringToken, sorts above the release + ('1.2.3', '<', '1.2.3a'), + # patch marker sorts above the release + ('1.2.3', '<', '1.2.3-p34'), + ('1.2.3-p34', '>', '1.2.3'), + ('1.2.3-p34', '==', '1.2.3-P34'), + ('1.2.3-p34', '>', '1.2.3-p33'), + ('1.2.3-p34', '<', '1.2.3-p35'), + ('1.2.3-p34', '>', '1.2.3-p9'), + # alpha + ('1.2.3alpha4', '>', '1.2.3alpha3'), + ('1.2.3alpha4', '<', '1.2.3alpha5'), + ('1.2.3alpha4', '<', '1.2.3alpha10'), + ('1.2.3alpha4', '<', '1.2.3beta2'), + ('1.2.3alpha4', '<', '1.2.3rc3'), + ('1.2.3alpha4', '<', '1.2.3-p34'), + # beta + ('1.2.3beta2', '>', '1.2.3beta1'), + ('1.2.3beta2', '<', '1.2.3beta10'), + ('1.2.3beta2', '>', '1.2.3alpha4'), + ('1.2.3beta2', '<', '1.2.3rc3'), + ('1.2.3beta2', '<', '1.2.3-p34'), + # pre + ('1.2.3pre9', '>', '1.2.3pre8'), + ('1.2.3pre9', '<', '1.2.3pre10'), + ('1.2.3pre3', '>', '1.2.3alpha4'), + ('1.2.3pre3', '>', '1.2.3beta5'), + ('1.2.3pre3', '<', '1.2.3rc2'), + ('1.2.3pre3', '<', '1.2.3'), + ('1.2.3pre3', '<', '1.2.3-p2'), + # rc + ('1.2.3rc3', '>', '1.2.3rc2'), + ('1.2.3rc3', '<', '1.2.3rc10'), + ('1.2.3rc3', '>', '1.2.3beta2'), + ('1.2.3rc3', '<', '1.2.3-p34'), + # post + ('1.2.3.post34', '>', '1.2.3.post33'), + ('1.2.3.post34', '<', '1.2.3.post35'), + ('1.2.3.post34', '>', '1.2.3rc35'), + ('1.2.3.post34', '>', '1.2.3alpha35'), + ('1.2.3.post34', '>', '1.2.3'), + # zero-skip: unevenly-padded versions align + ('2.1.0-p194', '<', '2.1-p195'), + ('2.1-p195', '>', '2.1.0-p194'), + ('2.1-p194', '<', '2.1.0-p195'), + ('2.1.0-p195', '>', '2.1-p194'), + ('2-p194', '<', '2.1-p195'), + # PkgVersion revision suffix + ('1.81.6_5', '<', '1.81.6_6'), + ('1.81.6_6', '<', '1.82.0'), + ('1.81.6', '<', '1.81.6_1'), + ('1.81.6_0', '==', '1.81.6'), + ('6.0_8', '>', '6.0_6'), + ('0.12.20_1', '>', '0.12.20'), + ] + + def test_ordering(self): + """Compare each pair and its reverse.""" + for a, op, b in self._cases: + with self.subTest(f'{a} {op} {b}'): + va, vb = HomebrewPkgVersion(a), HomebrewPkgVersion(b) + if op == '<': + self.assertLess(va, vb) + self.assertGreater(vb, va) + elif op == '>': + self.assertGreater(va, vb) + self.assertLess(vb, va) + else: + self.assertEqual(va, vb) + self.assertEqual(vb, va) + + +class HomebrewEcosystemTest(unittest.TestCase): + """Homebrew OrderedEcosystem tests.""" + + def setUp(self): + self.ecosystem = ecosystems.get('Homebrew') + + def test_registered(self): + self.assertIsNotNone(self.ecosystem) + + def test_sort_key(self): + self.assertLess( + self.ecosystem.sort_key('1.81.6_5'), + self.ecosystem.sort_key('1.81.6_6')) + self.assertLess( + self.ecosystem.sort_key('0'), self.ecosystem.sort_key('0.12.20_1')) + self.assertTrue(self.ecosystem.sort_key('').is_invalid) + + def test_sort_versions(self): + versions = ['1.82.0', '1.81.6_6', '1.81.6', '1.81.6_5', '1.81.6rc1'] + self.ecosystem.sort_versions(versions) + self.assertEqual(versions, + ['1.81.6rc1', '1.81.6', '1.81.6_5', '1.81.6_6', '1.82.0']) + + def test_coarse_version(self): + """Test coarse_version output and local monotonicity.""" + self.assertEqual( + self.ecosystem.coarse_version('1.81.6_6'), + '00:00000001.00000081.00000006') + self.assertEqual( + self.ecosystem.coarse_version('6.0_8'), '00:00000006.00000000.00000000') + # Prerelease/patch markers truncate; must not exceed the release's coarse. + self.assertLessEqual( + self.ecosystem.coarse_version('1.2.3rc1'), + self.ecosystem.coarse_version('1.2.3')) + self.assertLessEqual( + self.ecosystem.coarse_version('1.2.3'), + self.ecosystem.coarse_version('1.2.3-p34')) + + +if __name__ == '__main__': + unittest.main() diff --git a/osv/purl_helpers.py b/osv/purl_helpers.py index 3c7beb64ca4..6aa2a4d5762 100644 --- a/osv/purl_helpers.py +++ b/osv/purl_helpers.py @@ -61,6 +61,8 @@ EcosystemPURL('hackage', None), 'Hex': EcosystemPURL('hex', None), + 'Homebrew': + EcosystemPURL('brew', None), 'Julia': EcosystemPURL('julia', None), # Linux diff --git a/osv/purl_helpers_test.py b/osv/purl_helpers_test.py index 139ed41704d..9cdb016fb3b 100644 --- a/osv/purl_helpers_test.py +++ b/osv/purl_helpers_test.py @@ -102,6 +102,9 @@ def tests_package_to_purl(self): self.assertEqual('pkg:hex/acme/foo', purl_helpers.package_to_purl('Hex', 'acme/foo')) + self.assertEqual('pkg:brew/openssl%403', + purl_helpers.package_to_purl('Homebrew', 'openssl@3')) + # Root ecosystem does not generate PURLs # Root packages are not published to public registries self.assertIsNone( @@ -244,6 +247,9 @@ def test_parse_purl(self): self.assertEqual(('Hex', 'acme/foo', '2.3.'), purl_helpers.parse_purl('pkg:hex/acme/foo@2.3.')) + self.assertEqual(('Homebrew', 'openssl@3', '3.5.0'), + purl_helpers.parse_purl('pkg:brew/openssl%403@3.5.0')) + self.assertEqual(('Julia', 'Example', None), purl_helpers.parse_purl('pkg:julia/Example'))