Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion go/osv/ecosystem/echo.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,19 @@

package ecosystem

import "strings"
import (
"regexp"
"strconv"
"strings"
)

// echoEcosystem is the Echo container security ecosystem.
//
// Echo provides secured packages across multiple ecosystems:
// - 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 {
Expand All @@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions go/osv/ecosystem/echo_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
33 changes: 33 additions & 0 deletions osv/ecosystems/_ecosystems_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
26 changes: 25 additions & 1 deletion osv/ecosystems/echo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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)