From 401f0003d58657081e0df2d3f45aaacd68c407be Mon Sep 17 00:00:00 2001 From: Vasily Kleschov Date: Mon, 4 May 2026 16:05:43 +0200 Subject: [PATCH 1/4] feat: Add proper version parsing mapping for TuxCare advisories Signed-off-by: Vasily Kleschov --- osv/ecosystems/_ecosystems.py | 32 +++++++++++++++++-- osv/ecosystems/_ecosystems_test.py | 49 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/osv/ecosystems/_ecosystems.py b/osv/ecosystems/_ecosystems.py index b66e28a730e..fc7c70acb57 100644 --- a/osv/ecosystems/_ecosystems.py +++ b/osv/ecosystems/_ecosystems.py @@ -85,9 +85,29 @@ 'Linux': None, 'OSS-Fuzz': None, 'Photon OS': None, - 'TuxCare': None, } +# 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. +_TUXCARE = 'TuxCare' + + +def _strip_tuxcare(ecosystem: str) -> str | None: + """Strip a leading TuxCare prefix and return the inner ecosystem, or None + if the input is not a TuxCare ecosystem. + + Returns the original string for non-TuxCare inputs. + Returns None for malformed TuxCare inputs (no suffix, or nested TuxCare). + """ + prefix, _, suffix = ecosystem.partition(':') + if prefix != _TUXCARE: + return ecosystem + if not suffix or suffix.partition(':')[0] == _TUXCARE: + return None + return suffix + def is_semver(ecosystem: str) -> bool: """Returns whether an ecosystem uses 'SEMVER' range types""" @@ -97,7 +117,10 @@ 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(':') + inner = _strip_tuxcare(ecosystem) + if inner is None: + return False + name, _, _ = inner.partition(':') return name in _ecosystems @@ -132,7 +155,10 @@ def is_known(ecosystem: str) -> bool: def get(name: str) -> OrderedEcosystem | EnumerableEcosystem | None: """Get ecosystem helpers for a given ecosystem.""" - name, _, suffix = name.partition(':') + inner = _strip_tuxcare(name) + if inner is None: + return None + name, _, suffix = inner.partition(':') ecosys = _ecosystems.get(name) if ecosys is None: return None diff --git a/osv/ecosystems/_ecosystems_test.py b/osv/ecosystems/_ecosystems_test.py index 0f6afaeb114..6469243c855 100644 --- a/osv/ecosystems/_ecosystems_test.py +++ b/osv/ecosystems/_ecosystems_test.py @@ -91,3 +91,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.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.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')) From edcd140bd3246b86384fc62db5085666231bf15d Mon Sep 17 00:00:00 2001 From: Vasily Kleschov Date: Tue, 5 May 2026 13:26:34 +0200 Subject: [PATCH 2/4] Addressed review comments: Moved TuxCare ecosystem implementation into a separate file for Python part; Created corresponding Go implementation of the same thing. Signed-off-by: Vasily Kleschov --- go/osv/ecosystem/provider.go | 4 + go/osv/ecosystem/tuxcare.go | 91 ++++++++++++++++++++++ go/osv/ecosystem/tuxcare_test.go | 120 +++++++++++++++++++++++++++++ osv/ecosystems/_ecosystems.py | 37 +++------ osv/ecosystems/_ecosystems_test.py | 4 +- osv/ecosystems/tuxcare.py | 65 ++++++++++++++++ 6 files changed, 291 insertions(+), 30 deletions(-) create mode 100644 go/osv/ecosystem/tuxcare.go create mode 100644 go/osv/ecosystem/tuxcare_test.go create mode 100644 osv/ecosystems/tuxcare.py 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..614ff3f4876 --- /dev/null +++ b/go/osv/ecosystem/tuxcare.go @@ -0,0 +1,91 @@ +// 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 ( + "errors" + "strings" + + "github.com/ossf/osv-schema/bindings/go/osvconstants" +) + +// tuxcareEcosystem represents "TuxCare:" advisories. It delegates +// version handling to the inner ecosystem. +type tuxcareEcosystem struct { + inner Ecosystem +} + +var _ Ecosystem = tuxcareEcosystem{} + +// Registered in init() to break an initialization cycle: the ecosystems map +// references tuxcareFactory, which calls Provider.Get, which reads the map. +func init() { + ecosystems[osvconstants.EcosystemTuxCare] = tuxcareFactory +} + +// tuxcareFactory builds a tuxcareEcosystem by recursively resolving the inner +// ecosystem via the provider. Returns nil when the suffix is empty or names +// another TuxCare (so Provider.Get reports the ecosystem as unknown). +func tuxcareFactory(p *Provider, suffix string) Ecosystem { + innerName, _, _ := strings.Cut(suffix, ":") + if suffix == "" || innerName == string(osvconstants.EcosystemTuxCare) { + return nil + } + inner, ok := p.Get(suffix) + if !ok { + return nil + } + + return tuxcareEcosystem{inner: unwrap(inner)} +} + +// unwrap strips the version-zero wrapper added by Provider.Get, so callers +// that wrap us again don't produce a doubly-wrapped Version (which would +// fail to compare against a singly-wrapped Version from the same inner +// ecosystem). +func unwrap(e Ecosystem) Ecosystem { + switch w := e.(type) { + case *ecosystemWrapper: + return w.Ecosystem + case *enumerableWrapper: + return w.Enumerable + } + + return e +} + +func (e tuxcareEcosystem) Parse(version string) (Version, error) { + if e.inner == nil { + return nil, errors.New("TuxCare ecosystem has no resolvable inner ecosystem") + } + + return e.inner.Parse(version) +} + +func (e tuxcareEcosystem) Coarse(version string) (string, error) { + if e.inner == nil { + return "", errors.New("TuxCare ecosystem has no resolvable inner ecosystem") + } + + return e.inner.Coarse(version) +} + +func (e tuxcareEcosystem) IsSemver() bool { + if e.inner == nil { + return false + } + + return e.inner.IsSemver() +} diff --git a/go/osv/ecosystem/tuxcare_test.go b/go/osv/ecosystem/tuxcare_test.go new file mode 100644 index 00000000000..a8d0629f0d3 --- /dev/null +++ b/go/osv/ecosystem/tuxcare_test.go @@ -0,0 +1,120 @@ +// 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", + // Unknown inner ecosystem. + "TuxCare:NotARealEcosystem", + } + 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) + } + }) + } +} + +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 fc7c70acb57..6a66e5204a4 100644 --- a/osv/ecosystems/_ecosystems.py +++ b/osv/ecosystems/_ecosystems.py @@ -32,8 +32,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, @@ -71,6 +74,7 @@ 'RubyGems': RubyGems, 'SUSE': RPM, 'SwiftURL': SemverEcosystem, + 'TuxCare': TuxCareEcosystem, 'Ubuntu': Ubuntu, 'VSCode': SemverLike, 'Wolfi': APK, @@ -87,27 +91,6 @@ 'Photon OS': None, } -# 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. -_TUXCARE = 'TuxCare' - - -def _strip_tuxcare(ecosystem: str) -> str | None: - """Strip a leading TuxCare prefix and return the inner ecosystem, or None - if the input is not a TuxCare ecosystem. - - Returns the original string for non-TuxCare inputs. - Returns None for malformed TuxCare inputs (no suffix, or nested TuxCare). - """ - prefix, _, suffix = ecosystem.partition(':') - if prefix != _TUXCARE: - return ecosystem - if not suffix or suffix.partition(':')[0] == _TUXCARE: - return None - return suffix - def is_semver(ecosystem: str) -> bool: """Returns whether an ecosystem uses 'SEMVER' range types""" @@ -117,10 +100,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).""" - inner = _strip_tuxcare(ecosystem) - if inner is None: - return False - name, _, _ = inner.partition(':') + name, _, suffix = ecosystem.partition(':') + if name == _TUXCARE: + return TuxCareEcosystem.is_known_inner(suffix) return name in _ecosystems @@ -155,10 +137,9 @@ def is_known(ecosystem: str) -> bool: def get(name: str) -> OrderedEcosystem | EnumerableEcosystem | None: """Get ecosystem helpers for a given ecosystem.""" - inner = _strip_tuxcare(name) - if inner is None: + if not is_known(name): return None - name, _, suffix = inner.partition(':') + name, _, suffix = name.partition(':') ecosys = _ecosystems.get(name) if ecosys is None: return None diff --git a/osv/ecosystems/_ecosystems_test.py b/osv/ecosystems/_ecosystems_test.py index 6469243c855..ffffaa772cf 100644 --- a/osv/ecosystems/_ecosystems_test.py +++ b/osv/ecosystems/_ecosystems_test.py @@ -127,12 +127,12 @@ def test_tuxcare_ecosystem(self): # Suffixes pass through to the inner ecosystem. tuxcare_alpine = ecosystems.get('TuxCare:Alpine:v3.16') self.assertIsNotNone(tuxcare_alpine) - self.assertEqual(tuxcare_alpine.suffix, 'v3.16') + 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.suffix, 'Pro:18.04:LTS') + self.assertEqual(tuxcare_ubuntu._inner.suffix, 'Pro:18.04:LTS') # Bare TuxCare returns None. self.assertIsNone(ecosystems.get('TuxCare')) 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 From b1b5450fa3ad52a023aa0727c7bc31edabe1678a Mon Sep 17 00:00:00 2001 From: Vasily Kleschov Date: Tue, 5 May 2026 13:57:02 +0200 Subject: [PATCH 3/4] Make TuxCare ecosystem behave similarly to Debian one Signed-off-by: Vasily Kleschov --- go/osv/ecosystem/ecosystem.go | 1 + go/osv/ecosystem/tuxcare.go | 76 +++++++++++++++++--------------- go/osv/ecosystem/tuxcare_test.go | 16 ++++++- 3 files changed, 55 insertions(+), 38 deletions(-) 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/tuxcare.go b/go/osv/ecosystem/tuxcare.go index 614ff3f4876..56971c25bcd 100644 --- a/go/osv/ecosystem/tuxcare.go +++ b/go/osv/ecosystem/tuxcare.go @@ -15,77 +15,81 @@ package ecosystem import ( - "errors" + "fmt" "strings" "github.com/ossf/osv-schema/bindings/go/osvconstants" ) // tuxcareEcosystem represents "TuxCare:" advisories. It delegates -// version handling to the inner ecosystem. +// 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 { - inner Ecosystem + p *Provider + suffix string } var _ Ecosystem = tuxcareEcosystem{} -// Registered in init() to break an initialization cycle: the ecosystems map -// references tuxcareFactory, which calls Provider.Get, which reads the map. -func init() { - ecosystems[osvconstants.EcosystemTuxCare] = tuxcareFactory -} - -// tuxcareFactory builds a tuxcareEcosystem by recursively resolving the inner -// ecosystem via the provider. Returns nil when the suffix is empty or names -// another TuxCare (so Provider.Get reports the ecosystem as unknown). func tuxcareFactory(p *Provider, suffix string) Ecosystem { innerName, _, _ := strings.Cut(suffix, ":") if suffix == "" || innerName == string(osvconstants.EcosystemTuxCare) { - return nil - } - inner, ok := p.Get(suffix) - if !ok { + // Bare "TuxCare" or nested "TuxCare:TuxCare:..." is malformed. return nil } - return tuxcareEcosystem{inner: unwrap(inner)} + return tuxcareEcosystem{p: p, suffix: suffix} } -// unwrap strips the version-zero wrapper added by Provider.Get, so callers -// that wrap us again don't produce a doubly-wrapped Version (which would -// fail to compare against a singly-wrapped Version from the same inner -// ecosystem). -func unwrap(e Ecosystem) Ecosystem { - switch w := e.(type) { - case *ecosystemWrapper: - return w.Ecosystem - case *enumerableWrapper: - return w.Enumerable +// 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 e + return unwrap(inner), nil } func (e tuxcareEcosystem) Parse(version string) (Version, error) { - if e.inner == nil { - return nil, errors.New("TuxCare ecosystem has no resolvable inner ecosystem") + inner, err := e.resolve() + if err != nil { + return nil, err } - return e.inner.Parse(version) + return inner.Parse(version) } func (e tuxcareEcosystem) Coarse(version string) (string, error) { - if e.inner == nil { - return "", errors.New("TuxCare ecosystem has no resolvable inner ecosystem") + inner, err := e.resolve() + if err != nil { + return "", err } - return e.inner.Coarse(version) + return inner.Coarse(version) } func (e tuxcareEcosystem) IsSemver() bool { - if e.inner == nil { + inner, err := e.resolve() + if err != nil { return false } - return e.inner.IsSemver() + return inner.IsSemver() +} + +// 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 index a8d0629f0d3..954617c2ff1 100644 --- a/go/osv/ecosystem/tuxcare_test.go +++ b/go/osv/ecosystem/tuxcare_test.go @@ -50,8 +50,6 @@ func TestTuxCareEcosystem_Malformed(t *testing.T) { // Nested TuxCare. "TuxCare:TuxCare", "TuxCare:TuxCare:Red Hat", - // Unknown inner ecosystem. - "TuxCare:NotARealEcosystem", } for _, ecosystem := range cases { t.Run(ecosystem, func(t *testing.T) { @@ -62,6 +60,20 @@ func TestTuxCareEcosystem_Malformed(t *testing.T) { } } +// 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) From e3010dae647461983eb7fda3e1104748c2fcb51e Mon Sep 17 00:00:00 2001 From: Vasily Kleschov Date: Wed, 6 May 2026 10:38:36 +0200 Subject: [PATCH 4/4] Always return false in IsSemver() for TuxCare ecosystem Signed-off-by: Vasily Kleschov --- go/osv/ecosystem/tuxcare.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/go/osv/ecosystem/tuxcare.go b/go/osv/ecosystem/tuxcare.go index 56971c25bcd..4fca590fba6 100644 --- a/go/osv/ecosystem/tuxcare.go +++ b/go/osv/ecosystem/tuxcare.go @@ -72,13 +72,11 @@ func (e tuxcareEcosystem) Coarse(version string) (string, error) { 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 { - inner, err := e.resolve() - if err != nil { - return false - } - - return inner.IsSemver() + return false } // unwrap strips the wrapper added by Provider.Get, so callers that wrap us