Skip to content
Open
1 change: 1 addition & 0 deletions go/osv/ecosystem/ecosystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ var ecosystems = map[osvconstants.Ecosystem]ecosystemFactory{
osvconstants.EcosystemRubyGems: func(p *Provider, _ string) Ecosystem { return rubyGemsEcosystem{p: p} },
osvconstants.EcosystemSUSE: statelessFactory[rpmEcosystem],
osvconstants.EcosystemSwiftURL: statelessFactory[semverEcosystem],
osvconstants.EcosystemTuxCare: tuxcareFactory,
osvconstants.EcosystemUbuntu: statelessFactory[dpkgEcosystem],
osvconstants.EcosystemVSCode: statelessFactory[semverLikeEcosystem],
osvconstants.EcosystemWolfi: statelessFactory[apkEcosystem],
Expand Down
4 changes: 4 additions & 0 deletions go/osv/ecosystem/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func (p *Provider) Get(ecosystem string) (Ecosystem, bool) {
return nil, false
}
e := f(p, suffix)
if e == nil {
// Factory rejected this ecosystem (e.g. malformed TuxCare).
return nil, false
}
if enum, ok := e.(Enumerable); ok {
return &enumerableWrapper{Enumerable: enum}, true
}
Expand Down
93 changes: 93 additions & 0 deletions go/osv/ecosystem/tuxcare.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 (
"fmt"
"strings"

"github.com/ossf/osv-schema/bindings/go/osvconstants"
)

// tuxcareEcosystem represents "TuxCare:<ecosystem>" advisories. It delegates
// version handling to the inner ecosystem, resolved lazily via the Provider
// so that this factory can be registered in the ecosystems map without
// creating a package-init cycle.
type tuxcareEcosystem struct {
p *Provider
suffix string
}

var _ Ecosystem = tuxcareEcosystem{}

func tuxcareFactory(p *Provider, suffix string) Ecosystem {
innerName, _, _ := strings.Cut(suffix, ":")
if suffix == "" || innerName == string(osvconstants.EcosystemTuxCare) {
// Bare "TuxCare" or nested "TuxCare:TuxCare:..." is malformed.
return nil
}

return tuxcareEcosystem{p: p, suffix: suffix}
}

// resolve looks up the inner ecosystem on demand. Inner is unwrapped to avoid
// double-wrapping the resulting Version (which would fail to compare against
// a singly-wrapped Version from the same inner ecosystem).
func (e tuxcareEcosystem) resolve() (Ecosystem, error) {
inner, ok := e.p.Get(e.suffix)
if !ok {
return nil, fmt.Errorf("TuxCare: unknown inner ecosystem %q", e.suffix)
}

return unwrap(inner), nil
}

func (e tuxcareEcosystem) Parse(version string) (Version, error) {
inner, err := e.resolve()
if err != nil {
return nil, err
}

return inner.Parse(version)
}

func (e tuxcareEcosystem) Coarse(version string) (string, error) {
inner, err := e.resolve()
if err != nil {
return "", err
}

return inner.Coarse(version)
}

// IsSemver always returns false: TuxCare advisories should not have their
// affected[].ranges[].type converted from ECOSYSTEM to SEMVER, regardless of
// the inner ecosystem's behavior.
func (e tuxcareEcosystem) IsSemver() bool {
return false
}
Comment thread
michaelkedar marked this conversation as resolved.

// unwrap strips the wrapper added by Provider.Get, so callers that wrap us
// again don't produce a doubly-wrapped Version.
func unwrap(e Ecosystem) Ecosystem {
switch w := e.(type) {
case *ecosystemWrapper:
return w.Ecosystem
case *enumerableWrapper:
return w.Enumerable
}

return e
}
132 changes: 132 additions & 0 deletions go/osv/ecosystem/tuxcare_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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"
)

func TestTuxCareEcosystem_DelegatesToInner(t *testing.T) {
p := NewProvider(nil)

cases := []struct {
name string
ecosystem string
}{
{"RedHat", "TuxCare:Red Hat"},
{"AlmaLinux", "TuxCare:AlmaLinux"},
{"Debian", "TuxCare:Debian:12"},
{"NPM", "TuxCare:npm"},
{"AlpineWithSuffix", "TuxCare:Alpine:v3.16"},
{"UbuntuMultiSegment", "TuxCare:Ubuntu:22.04:LTS"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, ok := p.Get(tc.ecosystem); !ok {
t.Fatalf("Provider.Get(%q) = ok=false, want true", tc.ecosystem)
}
})
}
}

func TestTuxCareEcosystem_Malformed(t *testing.T) {
p := NewProvider(nil)
cases := []string{
// Bare TuxCare with no suffix.
"TuxCare",
"TuxCare:",
// Nested TuxCare.
"TuxCare:TuxCare",
"TuxCare:TuxCare:Red Hat",
}
for _, ecosystem := range cases {
t.Run(ecosystem, func(t *testing.T) {
if e, ok := p.Get(ecosystem); ok {
t.Errorf("Provider.Get(%q) = (%v, true), want (_, false)", ecosystem, e)
}
})
}
}

// Unknown inner ecosystems are accepted by Get (the inner is resolved
// lazily, mirroring debianFactory which accepts any release suffix); the
// failure surfaces at Parse time.
func TestTuxCareEcosystem_UnknownInnerFailsAtParse(t *testing.T) {
p := NewProvider(nil)
e, ok := p.Get("TuxCare:NotARealEcosystem")
if !ok {
t.Fatalf("Provider.Get(TuxCare:NotARealEcosystem) = ok=false, want true")
}
if _, err := e.Parse("1.0.0"); err == nil {
t.Errorf("Parse on unknown inner ecosystem returned nil error, want non-nil")
}
}

func TestTuxCareEcosystem_SortMatchesInner(t *testing.T) {
p := NewProvider(nil)

tuxRPM, ok := p.Get("TuxCare:Red Hat")
if !ok {
t.Fatalf("TuxCare:Red Hat not found")
}
plainRPM, ok := p.Get("Red Hat")
if !ok {
t.Fatalf("Red Hat not found")
}

v1, err := tuxRPM.Parse("1.0.0-1")
if err != nil {
t.Fatalf("tuxRPM.Parse: %v", err)
}
v2, err := tuxRPM.Parse("1.0.1-1")
if err != nil {
t.Fatalf("tuxRPM.Parse: %v", err)
}
if c, err := v1.Compare(v2); err != nil || c != -1 {
t.Errorf("Compare(1.0.0-1, 1.0.1-1) = (%d, %v), want (-1, nil)", c, err)
}

// Sort behaviour matches the underlying RPM parser.
tv, err := tuxRPM.Parse("1.2.3-1.el8")
if err != nil {
t.Fatalf("tuxRPM.Parse: %v", err)
}
pv, err := plainRPM.Parse("1.2.3-1.el8")
if err != nil {
t.Fatalf("plainRPM.Parse: %v", err)
}
if c, err := tv.Compare(pv); err != nil || c != 0 {
t.Errorf("Compare(tuxRPM, plainRPM) = (%d, %v), want (0, nil)", c, err)
}
}

func TestTuxCareEcosystem_ZeroVersion(t *testing.T) {
p := NewProvider(nil)
e, ok := p.Get("TuxCare:Red Hat")
if !ok {
t.Fatalf("TuxCare:Red Hat not found")
}
zero, err := e.Parse("0")
if err != nil {
t.Fatalf("Parse(0): %v", err)
}
v, err := e.Parse("1.0.0-1")
if err != nil {
t.Fatalf("Parse(1.0.0-1): %v", err)
}
if c, err := zero.Compare(v); err != nil || c != -1 {
t.Errorf("Compare(0, 1.0.0-1) = (%d, %v), want (-1, nil)", c, err)
}
}
15 changes: 13 additions & 2 deletions osv/ecosystems/_ecosystems.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@
from .root import Root
from .rubygems import RubyGems
from .semver_ecosystem_helper import SemverEcosystem, SemverLike
from .tuxcare import TuxCareEcosystem
from .ubuntu import Ubuntu

_TUXCARE = 'TuxCare'

_ecosystems = {
'AlmaLinux': RPM,
'Alpaquita': APK,
Expand Down Expand Up @@ -72,6 +75,7 @@
'RubyGems': RubyGems,
'SUSE': RPM,
'SwiftURL': SemverEcosystem,
'TuxCare': TuxCareEcosystem,
'Ubuntu': Ubuntu,
'VSCode': SemverLike,
'Wolfi': APK,
Expand All @@ -86,7 +90,6 @@
'Linux': None,
'OSS-Fuzz': None,
'Photon OS': None,
'TuxCare': None,
}


Expand All @@ -98,7 +101,11 @@ def is_semver(ecosystem: str) -> bool:
def is_known(ecosystem: str) -> bool:
"""Returns whether an ecosystem is known to OSV
(even if ordering is not supported)."""
name, _, _ = ecosystem.partition(':')
name, _, suffix = ecosystem.partition(':')
if name == _TUXCARE:
if not TuxCareEcosystem.is_valid_suffix(suffix):
return False
return is_known(suffix)
return name in _ecosystems


Expand Down Expand Up @@ -133,10 +140,14 @@ def is_known(ecosystem: str) -> bool:

def get(name: str) -> OrderedEcosystem | EnumerableEcosystem | None:
"""Get ecosystem helpers for a given ecosystem."""
if not is_known(name):
return None
name, _, suffix = name.partition(':')
ecosys = _ecosystems.get(name)
if ecosys is None:
return None
if ecosys is TuxCareEcosystem:
return TuxCareEcosystem(suffix, inner=get(suffix))
return ecosys(suffix)


Expand Down
48 changes: 48 additions & 0 deletions osv/ecosystems/_ecosystems_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,51 @@ def test_root_ecosystem(self):
self.assertLess(
root_debian.sort_key('1.0.0.root.io.1'),
root_debian.sort_key('1.0.0.root.io.2'))

def test_tuxcare_ecosystem(self):
"""Test TuxCare ecosystem delegates to inner ecosystem parsers."""
# TuxCare:<ecosystem> should be recognized when the inner ecosystem is.
self.assertTrue(ecosystems.is_known('TuxCare:Red Hat'))
self.assertTrue(ecosystems.is_known('TuxCare:AlmaLinux'))
self.assertTrue(ecosystems.is_known('TuxCare:Debian'))
self.assertTrue(ecosystems.is_known('TuxCare:npm'))
self.assertTrue(ecosystems.is_known('TuxCare:Alpine:v3.16'))
self.assertTrue(ecosystems.is_known('TuxCare:Ubuntu:22.04:LTS'))
# Inner ecosystems known in the schema but without implementations are
# still "known".
self.assertTrue(ecosystems.is_known('TuxCare:Android'))
# Unknown inner ecosystem.
self.assertFalse(ecosystems.is_known('TuxCare:NotARealEcosystem'))
# Bare TuxCare is malformed.
self.assertFalse(ecosystems.is_known('TuxCare'))
self.assertFalse(ecosystems.is_known('TuxCare:'))
# Nested TuxCare is malformed (loop guard).
self.assertFalse(ecosystems.is_known('TuxCare:TuxCare'))
self.assertFalse(ecosystems.is_known('TuxCare:TuxCare:Red Hat'))

# get() returns the inner ecosystem helper.
tuxcare_rpm = ecosystems.get('TuxCare:Red Hat')
self.assertIsNotNone(tuxcare_rpm)
# Sort behaviour matches the underlying RPM parser.
plain_rpm = ecosystems.get('Red Hat')
self.assertEqual(
tuxcare_rpm.sort_key('1.2.3-1.el8'), plain_rpm.sort_key('1.2.3-1.el8'))
self.assertLess(
tuxcare_rpm.sort_key('1.0.0-1'), tuxcare_rpm.sort_key('1.0.1-1'))

# Suffixes pass through to the inner ecosystem.
tuxcare_alpine = ecosystems.get('TuxCare:Alpine:v3.16')
self.assertIsNotNone(tuxcare_alpine)
self.assertEqual(tuxcare_alpine.inner.suffix, 'v3.16')

# Inner ecosystem with multi-segment suffix (e.g. Ubuntu variants).
tuxcare_ubuntu = ecosystems.get('TuxCare:Ubuntu:Pro:18.04:LTS')
self.assertIsNotNone(tuxcare_ubuntu)
self.assertEqual(tuxcare_ubuntu.inner.suffix, 'Pro:18.04:LTS')

# Bare TuxCare returns None.
self.assertIsNone(ecosystems.get('TuxCare'))
self.assertIsNone(ecosystems.get('TuxCare:'))
# Nested TuxCare returns None (no infinite recursion).
self.assertIsNone(ecosystems.get('TuxCare:TuxCare'))
self.assertIsNone(ecosystems.get('TuxCare:TuxCare:Red Hat'))
Loading