diff --git a/src/univers/versions.py b/src/univers/versions.py index 5d6101ac..eda159ba 100644 --- a/src/univers/versions.py +++ b/src/univers/versions.py @@ -4,6 +4,8 @@ # # Visit https://aboutcode.org and https://github.com/aboutcode-org/univers for support and download. +import re + import attr import semantic_version from packaging import version as packaging_version @@ -58,6 +60,64 @@ def is_valid_alpine_version(s): return str(i) == left +def _normalize_alpine_to_gentoo(string): + """ + Normalize an Alpine Linux version string to a Gentoo-compatible format + so that Gentoo's vercmp can compare it correctly. + + Alpine extends Gentoo-style versioning with additional patterns: + - A dot immediately before the revision marker ("0.12.5.-r0" -> "0.12.5-r0") + - Revision without a leading dash ("0.8.21.r2" -> "0.8.21-r2") + - Alpine-only suffix words: git, cvs, svn (mapped to alpha), rev/jdk (mapped to p) + - A single letter + digit directly after the dotted version ("1.9.5p2-r0" -> "1.9.5_p2-r0") + - A dash as a numeric version separator ("1.11-20-r0" -> "1.11.20-r0") + + For example: + >>> _normalize_alpine_to_gentoo("1.9.5p2-r0") + '1.9.5_p2-r0' + >>> _normalize_alpine_to_gentoo("3.3.3p1-r3") + '3.3.3_p1-r3' + >>> _normalize_alpine_to_gentoo("5.15.3_git20200401-r0") + '5.15.3_alpha20200401-r0' + >>> _normalize_alpine_to_gentoo("1.11-20-r0") + '1.11.20-r0' + >>> _normalize_alpine_to_gentoo("57-1-r2") + '57.1-r2' + >>> _normalize_alpine_to_gentoo("0.12.5.-r0") + '0.12.5-r0' + >>> _normalize_alpine_to_gentoo("0.8.21.r2") + '0.8.21-r2' + >>> _normalize_alpine_to_gentoo("1.2.3-r1") + '1.2.3-r1' + >>> _normalize_alpine_to_gentoo("1.2.3_alpha1-r1") + '1.2.3_alpha1-r1' + """ + # Handle ".rN" (no dash before revision): "0.8.21.r2" -> "0.8.21-r2" + string = re.sub(r"\.r(\d+)$", r"-r\1", string) + + # Handle trailing dot before revision: "0.12.5.-r0" -> "0.12.5-r0" + string = re.sub(r"\.-r(\d+)$", r"-r\1", string) + + # Map Alpine-only suffix words to Gentoo equivalents. + # Must be done before the letter+digit substitution below to avoid mangling them. + # _git, _cvs, _svn are snapshot/SCM builds -> treat as pre-release (alpha) + string = re.sub(r"_(git|cvs|svn)(\d*)", r"_alpha\2", string) + # _rev, _jdk -> treat as patch release (p) + string = re.sub(r"_(rev|jdk)(\d*)", r"_p\2", string) + + # Handle single letter+digit suffix: "1.9.5p2-r0" -> "1.9.5_p2-r0" + # Matches a letter immediately preceded by a digit, followed by one or more + # digits, just before the revision marker "-rN" or end of string. + string = re.sub(r"(?<=\d)([a-zA-Z])(\d+)(?=-r\d|$)", r"_\1\2", string) + + # Handle dash as a numeric version-component separator: "1.11-20-r0" -> "1.11.20-r0" + # Replaces "-N" only when N is a pure digit run not followed by the revision + # marker "rN" (i.e., skip "-r0", "-r1", …). + string = re.sub(r"-(?!r\d)(\d+)", r".\1", string) + + return string + + @attr.s(frozen=True, order=True, eq=True, hash=True) class Version: """ @@ -431,6 +491,11 @@ def __gt__(self, other): class AlpineLinuxVersion(GentooVersion): + @classmethod + def normalize(cls, string): + string = super().normalize(string) + return _normalize_alpine_to_gentoo(string) + @classmethod def is_valid(cls, string): return is_valid_alpine_version(string) and gentoo.is_valid(string) diff --git a/tests/test_alpine.py b/tests/test_alpine.py index 75574cce..4b5e92aa 100644 --- a/tests/test_alpine.py +++ b/tests/test_alpine.py @@ -39,6 +39,56 @@ def test_alpine_vers_cmp2(test_case): avc.assert_result() +@pytest.mark.parametrize( + ("version", "expected_value"), + [ + # dot immediately before revision marker (issue #59) + ("0.12.5.-r0", "0.12.5-r0"), + # dot instead of dash before revision number (issue #59) + ("0.8.21.r2", "0.8.21-r2"), + # dash as numeric version-component separator (issue #59) + ("1.11-20-r0", "1.11.20-r0"), + ("57-1-r2", "57.1-r2"), + # single letter + digit suffix, e.g. OpenSSH portable releases (issue #59) + ("1.9.5p2-r0", "1.9.5_p2-r0"), + ("3.3.3p1-r3", "3.3.3_p1-r3"), + ("6.6.2p1-r0", "6.6.2_p1-r0"), + ("6.6.4p1-r1", "6.6.4_p1-r1"), + ("6.7.1p1-r1", "6.7.1_p1-r1"), + # _git snapshot suffix mapped to _alpha for comparison (issue #59) + ("5.15.3_git20200401-r0", "5.15.3_alpha20200401-r0"), + ("5.15.3_git20210510-r0", "5.15.3_alpha20210510-r0"), + ], +) +def test_alpine_extended_version_formats(version, expected_value): + """Versions with Alpine-specific patterns must parse and normalise correctly.""" + v = AlpineLinuxVersion(version) + assert v.value == expected_value + + +@pytest.mark.parametrize( + ("smaller", "larger"), + [ + # portable-release ordering: p1 < p2 + ("1.9.5p1-r0", "1.9.5p2-r0"), + # git snapshot is a pre-release, comes before the stable release + ("5.15.3_git20200401-r0", "5.15.3-r0"), + # earlier git snapshot < later git snapshot + ("5.15.3_git20200401-r0", "5.15.3_git20210510-r0"), + # dash-separated version component ordering + ("1.11-20-r0", "1.11-21-r0"), + ("57-1-r2", "57-2-r0"), + # dot-r vs normal version + ("0.8.21.r2", "0.8.22-r0"), + ], +) +def test_alpine_extended_version_comparison(smaller, larger): + """Extended Alpine version formats must compare in the correct order.""" + v1 = AlpineLinuxVersion(smaller) + v2 = AlpineLinuxVersion(larger) + assert v1 < v2 + + @pytest.mark.parametrize( "test_case", [