From 8f858cf19c5b892d77ba312f6a88793a57d09106 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 12 Aug 2026 19:57:31 +0100 Subject: [PATCH 1/2] Reduce allocations in MakePURLString --- ecosystem.go | 94 +++++++++++++++++++++++----------------- ecosystem_bench_test.go | 39 +++++++++++++++++ ecosystem_test.go | 95 +++++++++++++++++++++++++++++++++++++++-- makepurl.go | 90 +++++++++++++++++++++++++------------- 4 files changed, 245 insertions(+), 73 deletions(-) create mode 100644 ecosystem_bench_test.go diff --git a/ecosystem.go b/ecosystem.go index f9f6f49..3c1d451 100644 --- a/ecosystem.go +++ b/ecosystem.go @@ -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. @@ -145,7 +157,7 @@ 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 @@ -153,7 +165,7 @@ func MakePURL(ecosystem, name, version string) *PURL { pkgName = parts[1] } } - case "golang": + case ecosystemGolang: if idx := strings.LastIndex(name, "/"); idx > 0 { namespace = name[:idx] pkgName = name[idx+1:] @@ -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 @@ -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. diff --git a/ecosystem_bench_test.go b/ecosystem_bench_test.go new file mode 100644 index 0000000..c92b2a7 --- /dev/null +++ b/ecosystem_bench_test.go @@ -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, + ) + } + }) + } +} diff --git a/ecosystem_test.go b/ecosystem_test.go index 4121f03..8381526 100644 --- a/ecosystem_test.go +++ b/ecosystem_test.go @@ -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) + } + }) } } diff --git a/makepurl.go b/makepurl.go index bf1e7a4..d3b53f0 100644 --- a/makepurl.go +++ b/makepurl.go @@ -35,18 +35,20 @@ func BuildPURLString(ecosystem, name, version, registryURL string) string { cleanVersion := CleanVersion(version, purlType) namespace, pkgName := splitNamespace(ecosystem, name) - needsQualifier := registryURL != "" && IsNonDefaultRegistry(purlType, registryURL) - - // Estimate capacity - n := 4 + len(purlType) + 1 + len(pkgName) // "pkg:" + type + "/" + name - if namespace != "" { - n += 1 + len(namespace) // "/" + namespace + if registryURL != "" && !IsNonDefaultRegistry(purlType, registryURL) { + registryURL = "" } - if cleanVersion != "" { - n += 1 + len(cleanVersion) // "@" + version + + return buildPURLString(purlType, namespace, pkgName, cleanVersion, registryURL) +} + +func buildPURLString(purlType, namespace, name, version, registryURL string) string { + n := len("pkg:") + len(purlType) + escapedNamespaceLength(namespace) + 1 + escapedComponentLength(name) + if version != "" { + n += 1 + escapedComponentLength(version) } - if needsQualifier { - n += len("?repository_url=") + len(registryURL) + if registryURL != "" { + n += len("?repository_url=") + escapedQualifierLength(registryURL) } var b strings.Builder @@ -54,27 +56,23 @@ func BuildPURLString(ecosystem, name, version, registryURL string) string { b.WriteString("pkg:") b.WriteString(purlType) - if namespace != "" { - // Write namespace segments, escaping each one - for namespace != "" { - b.WriteByte('/') - seg := namespace - if i := strings.IndexByte(namespace, '/'); i >= 0 { - seg = namespace[:i] - namespace = namespace[i+1:] - } else { - namespace = "" + start := 0 + for i := 0; i <= len(namespace); i++ { + if i == len(namespace) || namespace[i] == '/' { + if i > start { + b.WriteByte('/') + writeComponentEscaped(&b, namespace[start:i]) } - writeComponentEscaped(&b, seg) + start = i + 1 } } b.WriteByte('/') - writeComponentEscaped(&b, pkgName) - if cleanVersion != "" { + writeComponentEscaped(&b, name) + if version != "" { b.WriteByte('@') - writeComponentEscaped(&b, cleanVersion) + writeComponentEscaped(&b, version) } - if needsQualifier { + if registryURL != "" { b.WriteString("?repository_url=") writeQualifierEscaped(&b, registryURL) } @@ -82,6 +80,40 @@ func BuildPURLString(ecosystem, name, version, registryURL string) string { return b.String() } +func escapedNamespaceLength(namespace string) int { + n := 0 + start := 0 + for i := 0; i <= len(namespace); i++ { + if i == len(namespace) || namespace[i] == '/' { + if i > start { + n += 1 + escapedComponentLength(namespace[start:i]) + } + start = i + 1 + } + } + return n +} + +func escapedComponentLength(s string) int { + n := len(s) + for i := 0; i < len(s); i++ { + if !isComponentSafe(s[i]) { + n += 2 //nolint:mnd + } + } + return n +} + +func escapedQualifierLength(s string) int { + n := len(s) + for i := 0; i < len(s); i++ { + if !isQualifierValueSafe(s[i]) { + n += 2 //nolint:mnd + } + } + return n +} + // splitNamespace extracts namespace and package name from an ecosystem-native // package identifier. func splitNamespace(ecosystem, name string) (namespace, pkgName string) { @@ -93,14 +125,14 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string) { } switch normalized { - case "npm": + case ecosystemNPM: if strings.HasPrefix(name, "@") { if i := strings.IndexByte(name, '/'); i >= 0 { namespace = name[:i] pkgName = name[i+1:] } } - case "golang": + case ecosystemGolang: if i := strings.LastIndex(name, "/"); i > 0 { namespace = name[:i] pkgName = name[i+1:] @@ -110,12 +142,12 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string) { namespace = name[:i] pkgName = name[i+1:] } - case "packagist", "composer": + case ecosystemPackagist, ecosystemComposer: if i := strings.IndexByte(name, '/'); i >= 0 { namespace = name[:i] pkgName = name[i+1:] } - case "github-actions": + case ecosystemGitHubActions: if i := strings.IndexByte(name, '/'); i >= 0 { namespace = name[:i] rest := name[i+1:] From 3fe6a56109cd84b91a21ac7234df7c507bb30ef2 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 12 Aug 2026 20:09:14 +0100 Subject: [PATCH 2/2] Use Maven ecosystem constant --- makepurl.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/makepurl.go b/makepurl.go index d3b53f0..0d1c8ff 100644 --- a/makepurl.go +++ b/makepurl.go @@ -137,7 +137,7 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string) { namespace = name[:i] pkgName = name[i+1:] } - case "maven": + case ecosystemMaven: if i := strings.IndexByte(name, ':'); i >= 0 { namespace = name[:i] pkgName = name[i+1:]