Skip to content
Merged
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
94 changes: 54 additions & 40 deletions ecosystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,61 +4,73 @@ import (
"strings"
)

const ecosystemMaven = "maven"
const (
ecosystemAlpine = "alpine"
ecosystemArch = "arch"
ecosystemComposer = "composer"
ecosystemGitHubActions = "github-actions"
ecosystemGolang = "golang"
ecosystemMaven = "maven"
ecosystemNPM = "npm"
ecosystemPackagist = "packagist"
ecosystemRubyGems = "rubygems"
purlTypeGem = "gem"
purlTypeGitHubActions = "githubactions"
)

// purlTypeForEcosystem maps ecosystem names to PURL types.
// Most ecosystems use their name as the PURL type, but some differ.
var purlTypeForEcosystem = map[string]string{
"alpine": "apk",
"arch": "alpm",
"rubygems": "gem",
"packagist": "composer",
"github-actions": "githubactions",
ecosystemAlpine: "apk",
ecosystemArch: "alpm",
ecosystemRubyGems: purlTypeGem,
ecosystemPackagist: ecosystemComposer,
ecosystemGitHubActions: purlTypeGitHubActions,
}

// ecosystemAliases maps alternate names to canonical ecosystem names.
var ecosystemAliases = map[string]string{
"go": "golang",
"gem": "rubygems",
"composer": "packagist",
"go": ecosystemGolang,
purlTypeGem: ecosystemRubyGems,
ecosystemComposer: ecosystemPackagist,
}

// osvEcosystemNames maps PURL types to OSV ecosystem names.
var osvEcosystemNames = map[string]string{
"gem": "RubyGems",
"npm": "npm",
"pypi": "PyPI",
"cargo": "crates.io",
"conan": "ConanCenter",
"cran": "CRAN",
"golang": "Go",
"hackage": "Hackage",
ecosystemMaven: "Maven",
"julia": "Julia",
"nuget": "NuGet",
"opam": "opam",
"composer": "Packagist",
"hex": "Hex",
"pub": "Pub",
"swift": "SwiftURL",
"githubactions": "GitHub Actions",
purlTypeGem: "RubyGems",
ecosystemNPM: ecosystemNPM,
"pypi": "PyPI",
"cargo": "crates.io",
"conan": "ConanCenter",
"cran": "CRAN",
ecosystemGolang: "Go",
"hackage": "Hackage",
ecosystemMaven: "Maven",
"julia": "Julia",
"nuget": "NuGet",
"opam": "opam",
ecosystemComposer: "Packagist",
"hex": "Hex",
"pub": "Pub",
"swift": "SwiftURL",
purlTypeGitHubActions: "GitHub Actions",
}

// depsdevSystemNames maps PURL types to deps.dev system names.
var depsdevSystemNames = map[string]string{
"npm": "NPM",
"gem": "RUBYGEMS",
"pypi": "PYPI",
"cargo": "CARGO",
"golang": "GO",
ecosystemMaven: "MAVEN",
"nuget": "NUGET",
ecosystemNPM: "NPM",
purlTypeGem: "RUBYGEMS",
"pypi": "PYPI",
"cargo": "CARGO",
ecosystemGolang: "GO",
ecosystemMaven: "MAVEN",
"nuget": "NUGET",
}

// defaultNamespaces defines default namespaces for certain ecosystems.
var defaultNamespaces = map[string]string{
"alpine": "alpine",
"arch": "arch",
ecosystemAlpine: ecosystemAlpine,
ecosystemArch: ecosystemArch,
}

// NormalizeEcosystem returns the canonical ecosystem name.
Expand Down Expand Up @@ -145,15 +157,15 @@ func MakePURL(ecosystem, name, version string) *PURL {

// Extract namespace from name based on ecosystem conventions
switch NormalizeEcosystem(ecosystem) {
case "npm":
case ecosystemNPM:
if strings.HasPrefix(name, "@") {
parts := strings.SplitN(name, "/", 2) //nolint:mnd
if len(parts) == 2 { //nolint:mnd
namespace = parts[0] // Keep the @ for packageurl-go
pkgName = parts[1]
}
}
case "golang":
case ecosystemGolang:
if idx := strings.LastIndex(name, "/"); idx > 0 {
namespace = name[:idx]
pkgName = name[idx+1:]
Expand All @@ -164,13 +176,13 @@ func MakePURL(ecosystem, name, version string) *PURL {
namespace = parts[0]
pkgName = parts[1]
}
case "packagist", "composer":
case ecosystemPackagist, ecosystemComposer:
if strings.Contains(name, "/") {
parts := strings.SplitN(name, "/", 2) //nolint:mnd
namespace = parts[0]
pkgName = parts[1]
}
case "github-actions":
case ecosystemGitHubActions:
// GitHub Actions: owner/repo or owner/repo/path -> namespace=owner, name=repo (path ignored)
if strings.Contains(name, "/") {
parts := strings.SplitN(name, "/", 3) //nolint:mnd
Expand All @@ -184,7 +196,9 @@ func MakePURL(ecosystem, name, version string) *PURL {

// MakePURLString is like MakePURL but returns the PURL as a string.
func MakePURLString(ecosystem, name, version string) string {
return MakePURL(ecosystem, name, version).String()
purlType := EcosystemToPURLType(ecosystem)
namespace, pkgName := splitNamespace(ecosystem, name)
return buildPURLString(purlType, namespace, pkgName, version, "")
}

// SupportedEcosystems returns a list of all supported ecosystem names.
Expand Down
39 changes: 39 additions & 0 deletions ecosystem_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package purl

import "testing"

var (
benchmarkPackagePURLString string
benchmarkVersionPURLString string
)

func BenchmarkMakePURLString(b *testing.B) {
benchmarks := []struct {
name string
ecosystem string
pkg string
version string
}{
{name: "npm", ecosystem: "npm", pkg: "lodash", version: "4.17.21"},
{name: "npm_scoped", ecosystem: "npm", pkg: "@babel/core", version: "7.24.0"},
{name: "golang", ecosystem: "golang", pkg: "github.com/foo/bar", version: "v1.0.0"},
}

for _, benchmark := range benchmarks {
b.Run(benchmark.name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
benchmarkPackagePURLString = MakePURLString(
benchmark.ecosystem,
benchmark.pkg,
"",
)
benchmarkVersionPURLString = MakePURLString(
benchmark.ecosystem,
benchmark.pkg,
benchmark.version,
)
}
})
}
}
95 changes: 91 additions & 4 deletions ecosystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -307,10 +307,97 @@ func TestMakePURL(t *testing.T) {
}

func TestMakePURLString(t *testing.T) {
got := MakePURLString("npm", "lodash", "4.17.21")
want := "pkg:npm/lodash@4.17.21"
if got != want {
t.Errorf("MakePURLString() = %q, want %q", got, want)
tests := []struct {
name string
ecosystem string
pkg string
version string
want string
}{
{
name: "npm",
ecosystem: "npm",
pkg: "lodash",
version: "4.17.21",
want: "pkg:npm/lodash@4.17.21",
},
{
name: "scoped npm escaping",
ecosystem: "npm",
pkg: "@scope/pkg+name",
version: "1.0.0+build/meta?",
want: "pkg:npm/%40scope/pkg%2Bname@1.0.0%2Bbuild%2Fmeta%3F",
},
{
name: "uppercase ecosystem",
ecosystem: "NPM",
pkg: "@scope/pkg",
version: "",
want: "pkg:npm/%40scope/pkg",
},
{
name: "golang namespace",
ecosystem: "go",
pkg: "github.com/foo/bar",
version: "v1.0.0",
want: "pkg:golang/github.com/foo/bar@v1.0.0",
},
{
name: "golang empty namespace segment",
ecosystem: "golang",
pkg: "github.com//foo/bar",
version: "v1.0.0",
want: "pkg:golang/github.com/foo/bar@v1.0.0",
},
{
name: "maven escaping",
ecosystem: "maven",
pkg: "org example:artifact@name",
version: "3.12.0",
want: "pkg:maven/org%20example/artifact%40name@3.12.0",
},
{
name: "composer escaping",
ecosystem: "composer",
pkg: "vendor+name/package name",
version: "1.0",
want: "pkg:composer/vendor%2Bname/package%20name@1.0",
},
{
name: "default namespace",
ecosystem: "alpine",
pkg: "curl/ssl",
version: "8.0.0-r0",
want: "pkg:apk/alpine/curl%2Fssl@8.0.0-r0",
},
{
name: "github actions path",
ecosystem: "github-actions",
pkg: "actions/cache/restore",
version: "v4",
want: "pkg:githubactions/actions/cache@v4",
},
{
name: "generic ecosystem",
ecosystem: "cargo",
pkg: "serde+derive",
version: "1.0.0",
want: "pkg:cargo/serde%2Bderive@1.0.0",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MakePURLString(tt.ecosystem, tt.pkg, tt.version)
if got != tt.want {
t.Errorf("MakePURLString(%q, %q, %q) = %q, want %q",
tt.ecosystem, tt.pkg, tt.version, got, tt.want)
}

if canonical := MakePURL(tt.ecosystem, tt.pkg, tt.version).String(); got != canonical {
t.Errorf("MakePURLString() = %q, MakePURL().String() = %q", got, canonical)
}
})
}
}

Expand Down
Loading