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

func (e tuxcareEcosystem) IsSemver() bool {
inner, err := e.resolve()
if err != nil {
return false
}

return inner.IsSemver()
}
Comment on lines +75 to +82
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One small thing:
I'd prefer this always return false
IsSemver() is mostly only to be used to convert affected[].ranges[].type from ECOSYSTEM to SEMVER for those ecosystems, which I don't think we want to do with TuxCare.


// 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)
}
}
11 changes: 9 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,9 @@ 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:
return TuxCareEcosystem.is_known_inner(suffix)
return name in _ecosystems


Expand Down Expand Up @@ -133,6 +138,8 @@ 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:
Expand Down
49 changes: 49 additions & 0 deletions osv/ecosystems/_ecosystems_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,52 @@ 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