From 6227a28cdb4e24ea4c03a1dbdcc02471bb83146b Mon Sep 17 00:00:00 2001 From: Laiza Angrest Date: Wed, 1 Jul 2026 23:45:01 +0300 Subject: [PATCH] feat(echo): add Echo:npm ecosystem with +echo.N ordering npm/SemVer excludes build metadata from precedence, so delegate Echo:npm to a SemVer sort key composed with the +echo.N build number. --- go/osv/ecosystem/echo.go | 78 ++++++++++++++++++++++++- go/osv/ecosystem/echo_test.go | 93 ++++++++++++++++++++++++++++++ osv/ecosystems/_ecosystems_test.py | 33 +++++++++++ osv/ecosystems/echo.py | 26 ++++++++- 4 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 go/osv/ecosystem/echo_test.go diff --git a/go/osv/ecosystem/echo.go b/go/osv/ecosystem/echo.go index 2783b0586c6..bbd93baaee3 100644 --- a/go/osv/ecosystem/echo.go +++ b/go/osv/ecosystem/echo.go @@ -14,7 +14,11 @@ package ecosystem -import "strings" +import ( + "regexp" + "strconv" + "strings" +) // echoEcosystem is the Echo container security ecosystem. // @@ -22,6 +26,7 @@ import "strings" // - Echo - Debian-based packages (dpkg versioning) // - Echo:PyPI - Python packages (PyPI/PEP 440 versioning) // - Echo:Maven - Maven packages (Maven versioning) +// - Echo:npm - npm packages (SemVer versioning, +echo.N aware) // // Versioning is delegated to the underlying ecosystem helper. type echoEcosystem struct { @@ -34,11 +39,82 @@ func echoFactory(p *Provider, suffix string) Ecosystem { return echoEcosystem{Ecosystem: pypiEcosystem{p: p}} case strings.EqualFold(suffix, "maven"): return echoEcosystem{Ecosystem: mavenEcosystem{p: p}} + case strings.EqualFold(suffix, "npm"): + return echoEcosystem{Ecosystem: echoSemverEcosystem{}} default: return echoEcosystem{Ecosystem: dpkgEcosystem{}} } } +// echoBuildRe matches Echo's `+echo.N` build suffix. +var echoBuildRe = regexp.MustCompile(`\+echo\.(\d+)`) + +// echoBuildNumber returns the `+echo.N` build number (0 if there is none). +func echoBuildNumber(version string) int { + m := echoBuildRe.FindStringSubmatch(version) + if m == nil { + return 0 + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } + + return n +} + +// echoSemverEcosystem orders Echo:npm packages. npm uses SemVer, which +// excludes build metadata from precedence, so Echo's `+echo.N` builds would +// otherwise compare equal to the base version and to each other. PyPI and +// Maven order `+echo.N` natively (local versions / qualifiers); npm does not, +// so we tie-break on the echo build number to keep +// `1.2.3 < 1.2.3+echo.1 < 1.2.3+echo.2 < 1.2.4`. +// +// It embeds semverLikeEcosystem (the ECOSYSTEM version type), matching how +// Echo advisories express their ranges. +type echoSemverEcosystem struct { + semverLikeEcosystem +} + +func (e echoSemverEcosystem) Parse(version string) (Version, error) { + inner, err := e.semverLikeEcosystem.Parse(version) + if err != nil { + return nil, err + } + + return echoSemverVersion{inner: inner, build: echoBuildNumber(version)}, nil +} + +// echoSemverVersion is a SemVer version paired with its `+echo.N` build number. +type echoSemverVersion struct { + inner Version + build int +} + +var _ Version = echoSemverVersion{} + +func (v echoSemverVersion) Compare(other Version) (int, error) { + otherV, ok := other.(echoSemverVersion) + if !ok { + return 0, ErrVersionEcosystemMismatch + } + + // SemVer precedence first (build metadata is ignored there); if equal, + // tie-break on the echo build number. + if c, err := v.inner.Compare(otherV.inner); err != nil || c != 0 { + return c, err + } + + switch { + case v.build < otherV.build: + return -1, nil + case v.build > otherV.build: + return 1, nil + default: + return 0, nil + } +} + func (e echoEcosystem) NormalizePackageName(name string) string { // We want to apply the normalization of the inner ecosystem. if norm, ok := e.Ecosystem.(PackageNameNormalizer); ok { diff --git a/go/osv/ecosystem/echo_test.go b/go/osv/ecosystem/echo_test.go new file mode 100644 index 00000000000..2398d955657 --- /dev/null +++ b/go/osv/ecosystem/echo_test.go @@ -0,0 +1,93 @@ +// 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. + +package ecosystem + +import ( + "testing" +) + +type echoTestCase struct { + v1 string + v2 string + cmp int +} + +func runEchoTest(t *testing.T, e Ecosystem, tests []echoTestCase) { + t.Helper() + + for _, tc := range tests { + v1, err := e.Parse(tc.v1) + if err != nil { + t.Fatalf("Parse(%q) error: %v", tc.v1, err) + } + v2, err := e.Parse(tc.v2) + if err != nil { + t.Fatalf("Parse(%q) error: %v", tc.v2, err) + } + + c, err := v1.Compare(v2) + if err != nil { + t.Fatalf("Compare(%q, %q) error: %v", tc.v1, tc.v2, err) + } + + if c != tc.cmp { + t.Errorf("Compare(%q, %q) = %d, want %d", tc.v1, tc.v2, c, tc.cmp) + } + } +} + +// SemVer excludes build metadata from precedence, so Echo:npm must tie-break +// on the +echo.N build number. This is the "smart" comparison that Maven/PyPI +// don't need (they order +echo.N natively). +func TestEchoEcosystem_NPM(t *testing.T) { + e := echoFactory(nil, "npm") + tests := []echoTestCase{ + // Base SemVer ordering (unchanged), including prereleases. + {"1.0.1", "1.0.0", 1}, + {"1.10.0", "1.9.0", 1}, + {"1.0.0", "1.0.0-rc.0", 1}, + {"1.0.0-beta.42", "1.0.0-alpha.1", 1}, + // +echo.N ordering: base < echo.1 < echo.2 < echo.10 < next patch. + {"2.14.2+echo.1", "2.14.2", 1}, + {"2.14.2+echo.2", "2.14.2+echo.1", 1}, + {"2.14.2+echo.10", "2.14.2+echo.2", 1}, + {"2.14.3", "2.14.2+echo.1", 1}, + {"2.14.2+echo.1", "2.14.2+echo.1", 0}, + // A +echo.N build of a prerelease still sorts before the final release. + {"19.0.0-next.3+echo.1", "19.0.0-next.3", 1}, + {"19.0.0", "19.0.0-next.3+echo.1", 1}, + } + runEchoTest(t, e, tests) +} + +func TestEchoEcosystem_Maven(t *testing.T) { + e := echoFactory(nil, "maven") + tests := []echoTestCase{ + {"3.1.1+echo.1", "3.1.1", 1}, + {"3.1.1+echo.2", "3.1.1+echo.1", 1}, + {"3.1.2", "3.1.1+echo.1", 1}, + } + runEchoTest(t, e, tests) +} + +func TestEchoEcosystem_PyPI(t *testing.T) { + e := echoFactory(nil, "pypi") + tests := []echoTestCase{ + {"2.14.2+echo.1", "2.14.2", 1}, + {"2.14.2+echo.2", "2.14.2+echo.1", 1}, + {"2.14.3", "2.14.2+echo.1", 1}, + } + runEchoTest(t, e, tests) +} diff --git a/osv/ecosystems/_ecosystems_test.py b/osv/ecosystems/_ecosystems_test.py index 257e4464bb7..e8c1e1bfbcc 100644 --- a/osv/ecosystems/_ecosystems_test.py +++ b/osv/ecosystems/_ecosystems_test.py @@ -100,6 +100,39 @@ def test_echo_maven_ecosystem(self): self.assertLess( echo_maven.sort_key('3.1.1+echo.1'), echo_maven.sort_key('3.1.2')) + def test_echo_npm_ecosystem(self): + """Test that Echo:npm uses SemVer ordering and is +echo.N aware""" + self.assertTrue(ecosystems.is_known('Echo:npm')) + + echo_npm = ecosystems.get('Echo:npm') + self.assertIsNotNone(echo_npm) + + # Base SemVer ordering (including prereleases). + self.assertLess(echo_npm.sort_key('1.0.0'), echo_npm.sort_key('1.0.1')) + self.assertLess(echo_npm.sort_key('1.9.0'), echo_npm.sort_key('1.10.0')) + self.assertLess(echo_npm.sort_key('1.0.0-rc.0'), echo_npm.sort_key('1.0.0')) + self.assertLess( + echo_npm.sort_key('1.0.0-alpha.1'), echo_npm.sort_key('1.0.0-beta.42')) + + # SemVer excludes build metadata from precedence, but Echo's +echo.N + # builds must still order: 2.14.2 < 2.14.2+echo.1 < ... < 2.14.3. + self.assertLess( + echo_npm.sort_key('2.14.2'), echo_npm.sort_key('2.14.2+echo.1')) + self.assertLess( + echo_npm.sort_key('2.14.2+echo.1'), echo_npm.sort_key('2.14.2+echo.2')) + self.assertLess( + echo_npm.sort_key('2.14.2+echo.2'), + echo_npm.sort_key('2.14.2+echo.10')) + self.assertLess( + echo_npm.sort_key('2.14.2+echo.1'), echo_npm.sort_key('2.14.3')) + + # A +echo.N build of a prerelease still sorts before the final release. + self.assertLess( + echo_npm.sort_key('19.0.0-next.3'), + echo_npm.sort_key('19.0.0-next.3+echo.1')) + self.assertLess( + echo_npm.sort_key('19.0.0-next.3+echo.1'), echo_npm.sort_key('19.0.0')) + def test_echo_base_ecosystem(self): """Test that plain Echo uses Debian version ordering""" echo = ecosystems.get('Echo') diff --git a/osv/ecosystems/echo.py b/osv/ecosystems/echo.py index f39255c90f3..da8604f0f6a 100644 --- a/osv/ecosystems/echo.py +++ b/osv/ecosystems/echo.py @@ -13,10 +13,22 @@ # limitations under the License. """Echo ecosystem helper.""" +import re + from .debian import DPKG from .ecosystems_base import OrderedEcosystem from .maven import Maven from .pypi import PyPI +from .semver_ecosystem_helper import SemverLike + +# Echo secured builds carry a `+echo.N` local/build suffix (e.g. 1.2.3+echo.1). +_ECHO_BUILD_RE = re.compile(r'\+echo\.(\d+)') + + +def _echo_build_number(version: str) -> int: + """The `+echo.N` build number for a version (0 if there is none).""" + match = _ECHO_BUILD_RE.search(version) + return int(match.group(1)) if match else 0 class Echo(OrderedEcosystem): @@ -26,6 +38,7 @@ class Echo(OrderedEcosystem): - Echo - Debian-based packages (dpkg versioning) - Echo:PyPI - Python packages (PyPI/PEP 440 versioning) - Echo:Maven - Maven packages (Maven versioning) + - Echo:npm - npm packages (SemVer versioning, +echo.N aware) """ def _delegate(self) -> OrderedEcosystem: @@ -34,10 +47,21 @@ def _delegate(self) -> OrderedEcosystem: return PyPI() if suffix == 'maven': return Maven() + if suffix == 'npm': + return SemverLike() return DPKG() def _sort_key(self, version: str): - return self._delegate()._sort_key(version) # pylint: disable=protected-access + delegate = self._delegate() + key = delegate._sort_key(version) # pylint: disable=protected-access + if isinstance(delegate, SemverLike): + # SemVer excludes build metadata from precedence, so `1.2.3`, + # `1.2.3+echo.1` and `1.2.3+echo.2` would all compare equal. PyPI and + # Maven order `+echo.N` natively (local versions / qualifiers); npm does + # not, so tie-break on the build number to keep + # `1.2.3 < 1.2.3+echo.1 < 1.2.3+echo.2 < 1.2.4`. + return (key, _echo_build_number(version)) + return key def coarse_version(self, version: str) -> str: return self._delegate().coarse_version(version)