diff --git a/go/osv/ecosystem/ecosystem.go b/go/osv/ecosystem/ecosystem.go index 5046f66d273..d53fe71c529 100644 --- a/go/osv/ecosystem/ecosystem.go +++ b/go/osv/ecosystem/ecosystem.go @@ -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], diff --git a/go/osv/ecosystem/provider.go b/go/osv/ecosystem/provider.go index 7e0ee713d1e..2b12d7e6968 100644 --- a/go/osv/ecosystem/provider.go +++ b/go/osv/ecosystem/provider.go @@ -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 } diff --git a/go/osv/ecosystem/tuxcare.go b/go/osv/ecosystem/tuxcare.go new file mode 100644 index 00000000000..4fca590fba6 --- /dev/null +++ b/go/osv/ecosystem/tuxcare.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 ( + "fmt" + "strings" + + "github.com/ossf/osv-schema/bindings/go/osvconstants" +) + +// tuxcareEcosystem represents "TuxCare:" 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 +} + +// 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 +} diff --git a/go/osv/ecosystem/tuxcare_test.go b/go/osv/ecosystem/tuxcare_test.go new file mode 100644 index 00000000000..954617c2ff1 --- /dev/null +++ b/go/osv/ecosystem/tuxcare_test.go @@ -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) + } +} diff --git a/osv/ecosystems/_ecosystems.py b/osv/ecosystems/_ecosystems.py index 47ce042f629..b664e0fc623 100644 --- a/osv/ecosystems/_ecosystems.py +++ b/osv/ecosystems/_ecosystems.py @@ -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, @@ -72,6 +75,7 @@ 'RubyGems': RubyGems, 'SUSE': RPM, 'SwiftURL': SemverEcosystem, + 'TuxCare': TuxCareEcosystem, 'Ubuntu': Ubuntu, 'VSCode': SemverLike, 'Wolfi': APK, @@ -86,7 +90,6 @@ 'Linux': None, 'OSS-Fuzz': None, 'Photon OS': None, - 'TuxCare': None, } @@ -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 @@ -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: diff --git a/osv/ecosystems/_ecosystems_test.py b/osv/ecosystems/_ecosystems_test.py index ab7feb0cf1e..71ed79c8051 100644 --- a/osv/ecosystems/_ecosystems_test.py +++ b/osv/ecosystems/_ecosystems_test.py @@ -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: 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')) diff --git a/osv/ecosystems/tuxcare.py b/osv/ecosystems/tuxcare.py new file mode 100644 index 00000000000..6554edd6075 --- /dev/null +++ b/osv/ecosystems/tuxcare.py @@ -0,0 +1,65 @@ +# Copyright 2025 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. +"""TuxCare ecosystem helper.""" + +from typing import Any + +from .ecosystems_base import OrderedEcosystem + +_TUXCARE = 'TuxCare' + + +class TuxCareEcosystem(OrderedEcosystem): + """TuxCare advisories use the form "TuxCare:" (e.g. + "TuxCare:Red Hat", "TuxCare:Alpine:v3.16", "TuxCare:npm") and delegate + version handling to the inner ecosystem. + + A bare "TuxCare" or a nested "TuxCare:TuxCare:..." is malformed and + produces an instance with `_inner = None`; calling sort/coarse methods + on such an instance raises ValueError. + """ + + def __init__(self, suffix: str | None): + super().__init__(suffix) + if not _is_valid_inner(suffix): + self._inner = None + return + # Lazy import to avoid circular dependency with _ecosystems. + from ._ecosystems import get + self._inner = get(suffix) + + @classmethod + def is_known_inner(cls, suffix: str | None) -> bool: + """Whether a TuxCare suffix names a known inner ecosystem.""" + if not _is_valid_inner(suffix): + return False + from ._ecosystems import is_known + return is_known(suffix) + + def _sort_key(self, version: str) -> Any: + if self._inner is None: + raise ValueError('TuxCare ecosystem has no resolvable inner ecosystem') + return self._inner._sort_key(version) # pylint: disable=protected-access + + def coarse_version(self, version: str) -> str: + if self._inner is None: + raise ValueError('TuxCare ecosystem has no resolvable inner ecosystem') + return self._inner.coarse_version(version) + + +def _is_valid_inner(suffix: str | None) -> bool: + """Reject empty suffix and nested TuxCare.""" + if not suffix: + return False + return suffix.partition(':')[0] != _TUXCARE