diff --git a/go/osv/ecosystem/dhi.go b/go/osv/ecosystem/dhi.go new file mode 100644 index 00000000000..7bee4eb76e5 --- /dev/null +++ b/go/osv/ecosystem/dhi.go @@ -0,0 +1,83 @@ +// 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" +) + +// dhiEcosystem represents "Docker Hardened Images::" +// advisories (e.g. "Docker Hardened Images:Alpine:3.23", +// "Docker Hardened Images:Debian:trixie"). DHI OS packages are repackaged +// Alpine (apk) and Debian (dpkg) packages that keep their upstream version +// syntax (e.g. "8.4.0-r0", "7.88.1-10+deb13u2"), so version handling delegates +// to the lineage ecosystem named in the suffix, resolved lazily via the +// Provider to avoid a package-init cycle. +type dhiEcosystem struct { + p *Provider + suffix string +} + +var _ Ecosystem = dhiEcosystem{} + +func dhiFactory(p *Provider, suffix string) Ecosystem { + lineage, _, _ := strings.Cut(suffix, ":") + if suffix == "" || lineage == string(osvconstants.EcosystemDockerHardenedImages) { + // Bare "Docker Hardened Images" or a self-referential suffix is malformed. + return nil + } + + return dhiEcosystem{p: p, suffix: suffix} +} + +// resolve looks up the lineage ecosystem named by the suffix (e.g. "Alpine:3.23" +// or "Debian:trixie") 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 dhiEcosystem) resolve() (Ecosystem, error) { + inner, ok := e.p.Get(e.suffix) + if !ok { + return nil, fmt.Errorf("unknown Docker Hardened Images lineage ecosystem %q", e.suffix) + } + + return unwrap(inner), nil +} + +func (e dhiEcosystem) Parse(version string) (Version, error) { + inner, err := e.resolve() + if err != nil { + return nil, err + } + + return inner.Parse(version) +} + +func (e dhiEcosystem) Coarse(version string) (string, error) { + inner, err := e.resolve() + if err != nil { + return "", err + } + + return inner.Coarse(version) +} + +// IsSemver always returns false: DHI advisories use ECOSYSTEM ranges, and DHI +// versions follow apk/dpkg ordering rather than SemVer. +func (e dhiEcosystem) IsSemver() bool { + return false +} diff --git a/go/osv/ecosystem/dhi_test.go b/go/osv/ecosystem/dhi_test.go new file mode 100644 index 00000000000..ba8bbc45cb5 --- /dev/null +++ b/go/osv/ecosystem/dhi_test.go @@ -0,0 +1,175 @@ +// 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 TestDHIEcosystem_DelegatesToInner(t *testing.T) { + p := NewProvider(nil) + + cases := []struct { + name string + ecosystem string + }{ + {"Alpine", "Docker Hardened Images:Alpine:3.23"}, + {"Debian", "Docker Hardened Images:Debian:trixie"}, + } + 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 TestDHIEcosystem_Malformed(t *testing.T) { + p := NewProvider(nil) + cases := []string{ + // Bare "Docker Hardened Images" with no lineage suffix. + "Docker Hardened Images", + "Docker Hardened Images:", + // Self-referential suffix. + "Docker Hardened Images:Docker Hardened Images", + } + 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) + } + }) + } +} + +// IsSemver is false: DHI uses ECOSYSTEM ranges, not SEMVER. +func TestDHIEcosystem_IsSemverFalse(t *testing.T) { + p := NewProvider(nil) + e, ok := p.Get("Docker Hardened Images:Alpine:3.23") + if !ok { + t.Fatalf("Docker Hardened Images:Alpine:3.23 not found") + } + if e.IsSemver() { + t.Errorf("IsSemver() = true, want false") + } +} + +// The Alpine lineage sorts with apk semantics, not SemVer. In particular the +// "-rN" package release is ordered numerically (r2 < r10); a SemVer comparison +// would invert this by comparing the prerelease identifiers "r10" and "r2" +// lexically. Results also match the plain Alpine parser. +func TestDHIEcosystem_AlpineLineage(t *testing.T) { + p := NewProvider(nil) + + dhi, ok := p.Get("Docker Hardened Images:Alpine:3.23") + if !ok { + t.Fatalf("Docker Hardened Images:Alpine:3.23 not found") + } + alpine, ok := p.Get("Alpine:3.23") + if !ok { + t.Fatalf("Alpine:3.23 not found") + } + + v1, err := dhi.Parse("8.4.0-r0") + if err != nil { + t.Fatalf("dhi.Parse(8.4.0-r0): %v", err) + } + v2, err := dhi.Parse("8.5.0-r0") + if err != nil { + t.Fatalf("dhi.Parse(8.5.0-r0): %v", err) + } + if c, err := v1.Compare(v2); err != nil || c != -1 { + t.Errorf("Compare(8.4.0-r0, 8.5.0-r0) = (%d, %v), want (-1, nil)", c, err) + } + + // apk orders the -rN release numerically, unlike SemVer prerelease ordering. + r2, err := dhi.Parse("1.2.3-r2") + if err != nil { + t.Fatalf("dhi.Parse(1.2.3-r2): %v", err) + } + r10, err := dhi.Parse("1.2.3-r10") + if err != nil { + t.Fatalf("dhi.Parse(1.2.3-r10): %v", err) + } + if c, err := r2.Compare(r10); err != nil || c != -1 { + t.Errorf("Compare(1.2.3-r2, 1.2.3-r10) = (%d, %v), want (-1, nil)", c, err) + } + + // Delegation matches the plain Alpine parser. + dv, err := dhi.Parse("8.4.0-r0") + if err != nil { + t.Fatalf("dhi.Parse: %v", err) + } + av, err := alpine.Parse("8.4.0-r0") + if err != nil { + t.Fatalf("alpine.Parse: %v", err) + } + if c, err := dv.Compare(av); err != nil || c != 0 { + t.Errorf("Compare(dhi, alpine) = (%d, %v), want (0, nil)", c, err) + } +} + +// The Debian lineage sorts with dpkg semantics (epoch/revision aware) and +// matches the plain Debian parser. +func TestDHIEcosystem_DebianLineage(t *testing.T) { + p := NewProvider(nil) + + dhi, ok := p.Get("Docker Hardened Images:Debian:trixie") + if !ok { + t.Fatalf("Docker Hardened Images:Debian:trixie not found") + } + debian, ok := p.Get("Debian:trixie") + if !ok { + t.Fatalf("Debian:trixie not found") + } + + v1, err := dhi.Parse("7.88.1-10+deb13u1") + if err != nil { + t.Fatalf("dhi.Parse(7.88.1-10+deb13u1): %v", err) + } + v2, err := dhi.Parse("7.88.1-10+deb13u2") + if err != nil { + t.Fatalf("dhi.Parse(7.88.1-10+deb13u2): %v", err) + } + if c, err := v1.Compare(v2); err != nil || c != -1 { + t.Errorf("Compare(deb13u1, deb13u2) = (%d, %v), want (-1, nil)", c, err) + } + + dv, err := dhi.Parse("7.88.1-10+deb13u2") + if err != nil { + t.Fatalf("dhi.Parse: %v", err) + } + bv, err := debian.Parse("7.88.1-10+deb13u2") + if err != nil { + t.Fatalf("debian.Parse: %v", err) + } + if c, err := dv.Compare(bv); err != nil || c != 0 { + t.Errorf("Compare(dhi, debian) = (%d, %v), want (0, nil)", c, err) + } +} + +// An unknown lineage is accepted by Get (resolved lazily, mirroring TuxCare); +// the failure surfaces at Parse time. +func TestDHIEcosystem_UnknownLineageFailsAtParse(t *testing.T) { + p := NewProvider(nil) + e, ok := p.Get("Docker Hardened Images:NotARealEcosystem") + if !ok { + t.Fatalf("Provider.Get(Docker Hardened Images:NotARealEcosystem) = ok=false, want true") + } + if _, err := e.Parse("1.0.0"); err == nil { + t.Errorf("Parse on unknown lineage returned nil error, want non-nil") + } +} diff --git a/go/osv/ecosystem/ecosystem.go b/go/osv/ecosystem/ecosystem.go index ab33883eb7a..9ae50baf1a5 100644 --- a/go/osv/ecosystem/ecosystem.go +++ b/go/osv/ecosystem/ecosystem.go @@ -51,7 +51,7 @@ var ecosystems = map[osvconstants.Ecosystem]ecosystemFactory{ osvconstants.EcosystemCRAN: func(p *Provider, _ string) Ecosystem { return cranEcosystem{p: p} }, osvconstants.EcosystemCratesIO: statelessFactory[semverEcosystem], osvconstants.EcosystemDebian: debianFactory, - osvconstants.EcosystemDockerHardenedImages: statelessFactory[semverEcosystem], + osvconstants.EcosystemDockerHardenedImages: dhiFactory, osvconstants.EcosystemEcho: echoFactory, osvconstants.EcosystemGHC: func(p *Provider, _ string) Ecosystem { return ghcEcosystem{p: p} }, osvconstants.EcosystemGo: statelessFactory[semverEcosystem],