diff --git a/ecosystem.go b/ecosystem.go index 69d0047..c6411bc 100644 --- a/ecosystem.go +++ b/ecosystem.go @@ -168,6 +168,7 @@ func MakePURLString(ecosystem, name, version string) string { if !ok { return "" } + namespace, pkgName, version = normalizeComponents(purlType, namespace, pkgName, version, "") return buildPURLString(purlType, namespace, pkgName, version, "") } diff --git a/ecosystem_test.go b/ecosystem_test.go index ddb0330..e61396e 100644 --- a/ecosystem_test.go +++ b/ecosystem_test.go @@ -390,6 +390,20 @@ func TestMakePURLString(t *testing.T) { version: "1.0", want: "pkg:composer/vendor%2Bname/package%20name@1.0", }, + { + name: "composer normalization", + ecosystem: "composer", + pkg: "Symfony/Console", + version: "6.1.7", + want: "pkg:composer/symfony/console@6.1.7", + }, + { + name: "pypi normalization", + ecosystem: "pypi", + pkg: "Django_REST", + version: "1.0.0", + want: "pkg:pypi/django-rest@1.0.0", + }, { name: "default namespace", ecosystem: "alpine", @@ -439,6 +453,17 @@ func TestMakePURLString(t *testing.T) { } } +func TestMakePURLStringMatchesMakePURLOnNormalizeError(t *testing.T) { + // chrome-extension names must be 32 lowercase letters; a short name makes + // packageurl-go's Normalize error after it has already lowercased the name. + // MakePURL and MakePURLString must still agree in that case. + got := MakePURLString("chrome-extension", "ABC", "") + want := MakePURL("chrome-extension", "ABC", "").String() + if got != want { + t.Errorf("MakePURLString = %q, MakePURL().String() = %q", got, want) + } +} + func TestMakePURLStringRejectsSwiftRegistryIdentity(t *testing.T) { identities := []string{ "apple.swift-argument-parser", diff --git a/makepurl.go b/makepurl.go index 06fb3b4..37255f2 100644 --- a/makepurl.go +++ b/makepurl.go @@ -4,6 +4,7 @@ import ( "strings" "github.com/git-pkgs/vers" + packageurl "github.com/package-url/packageurl-go" ) // CleanVersion extracts a version from a version constraint string. @@ -43,9 +44,32 @@ func BuildPURLString(ecosystem, name, version, registryURL string) string { registryURL = "" } + namespace, pkgName, cleanVersion = normalizeComponents(purlType, namespace, pkgName, cleanVersion, registryURL) return buildPURLString(purlType, namespace, pkgName, cleanVersion, registryURL) } +// normalizeComponents applies packageurl-go's per-type canonicalization +// (lowercasing composer/golang names, PyPI underscore-to-dash, etc) so the +// fast-path string builders agree with Parse. The registryURL is passed as a +// repository_url qualifier because some types (mlflow) vary name casing by +// registry. Normalize's error is ignored so the result matches New, which also +// discards it; whatever fields Normalize wrote before erroring are kept. +func normalizeComponents(purlType, namespace, name, version, registryURL string) (string, string, string) { + var q packageurl.Qualifiers + if registryURL != "" { + q = packageurl.Qualifiers{{Key: "repository_url", Value: registryURL}} + } + p := packageurl.PackageURL{ + Type: purlType, + Namespace: namespace, + Name: name, + Version: version, + Qualifiers: q, + } + _ = p.Normalize() + return p.Namespace, p.Name, p.Version +} + func buildPURLString(purlType, namespace, name, version, registryURL string) string { n := len("pkg:") + len(purlType) + escapedNamespaceLength(namespace) + 1 + escapedComponentLength(name) if version != "" { diff --git a/makepurl_test.go b/makepurl_test.go index 3a28ec8..fbf61c8 100644 --- a/makepurl_test.go +++ b/makepurl_test.go @@ -64,6 +64,11 @@ func TestBuildPURLString(t *testing.T) { {"with registry", "npm", "lodash", "1.0.0", "https://npm.example.com", "pkg:npm/lodash@1.0.0?repository_url=https:%2F%2Fnpm.example.com"}, {"default registry ignored", "npm", "lodash", "1.0.0", "https://registry.npmjs.org", "pkg:npm/lodash@1.0.0"}, {"composer", "packagist", "vendor/pkg", "1.0", "", "pkg:composer/vendor/pkg@1.0"}, + {"composer normalization", "packagist", "Vendor/Package", "1.0", "", "pkg:composer/vendor/package@1.0"}, + {"pypi normalization", "pypi", "Django_REST", "1.0.0", "", "pkg:pypi/django-rest@1.0.0"}, + {"golang normalization", "golang", "GitHub.com/Foo/Bar", "v1.0.0", "", "pkg:golang/github.com/foo/bar@v1.0.0"}, + {"mlflow databricks normalization", "mlflow", "TrafficSigns", "1.0", "https://adb-123.4.azuredatabricks.net/api/2.0/mlflow", "pkg:mlflow/trafficsigns@1.0?repository_url=https:%2F%2Fadb-123.4.azuredatabricks.net%2Fapi%2F2.0%2Fmlflow"}, + {"mlflow azureml preserves case", "mlflow", "TrafficSigns", "1.0", "https://westus2.api.azureml.ms/mlflow/v1.0", "pkg:mlflow/TrafficSigns@1.0?repository_url=https:%2F%2Fwestus2.api.azureml.ms%2Fmlflow%2Fv1.0"}, {"swift source coordinate", "swift", "github.com/apple/swift-argument-parser", "1.8.2", "", "pkg:swift/github.com/apple/swift-argument-parser@1.8.2"}, {"swift registry identity", "swift", "apple.swift-argument-parser", "1.8.2", "", ""}, {"swift registry path", "swift", "apple/swift-argument-parser", "1.8.2", "", ""}, @@ -94,6 +99,8 @@ func TestBuildPURLStringMatchesMakePURL(t *testing.T) { {"maven", "org.apache:commons", "1.0", ""}, {"golang", "github.com/foo/bar", "v1.0.0", ""}, {"packagist", "vendor/pkg", "1.0", ""}, + {"packagist", "Vendor/Package", "1.0", ""}, + {"pypi", "Django_REST", "1.0.0", ""}, {"npm", "lodash", "^1.0.0", ""}, {"npm", "pkg", "1.0.0", "https://custom.registry.com"}, {"swift", "github.com/apple/swift-argument-parser", "1.8.2", ""}, diff --git a/purl.go b/purl.go index f65ccb5..3618cd4 100644 --- a/purl.go +++ b/purl.go @@ -26,6 +26,11 @@ func Parse(s string) (*PURL, error) { } // New creates a new PURL from components. +// +// The result is normalized with the same per-type rules Parse applies +// (packageurl-go's Normalize), so New and Parse produce the same string for +// equivalent inputs. Normalization errors are ignored; any adjustments +// Normalize applied before erroring are kept. func New(purlType, namespace, name, version string, qualifiers map[string]string) *PURL { var q packageurl.Qualifiers if len(qualifiers) > 0 { @@ -40,6 +45,7 @@ func New(purlType, namespace, name, version string, qualifiers map[string]string } } p := packageurl.NewPackageURL(purlType, namespace, name, version, q, "") + _ = p.Normalize() return &PURL{*p} } diff --git a/purl_test.go b/purl_test.go index 043109e..6764d92 100644 --- a/purl_test.go +++ b/purl_test.go @@ -124,6 +124,20 @@ func TestNew(t *testing.T) { qualifiers: map[string]string{"repository_url": "https://npm.example.com"}, want: "pkg:npm/lodash?repository_url=https:%2F%2Fnpm.example.com", }, + { + name: "pypi normalization", + purlType: "pypi", + pkgName: "Django_REST", + want: "pkg:pypi/django-rest", + }, + { + name: "composer normalization", + purlType: "composer", + namespace: "Symfony", + pkgName: "Console", + version: "6.1.7", + want: "pkg:composer/symfony/console@6.1.7", + }, } for _, tt := range tests { @@ -136,6 +150,31 @@ func TestNew(t *testing.T) { } } +func TestNewMatchesParse(t *testing.T) { + cases := []struct { + purlType, namespace, name, version string + }{ + {"pypi", "", "Django", "4.2.0"}, + {"pypi", "", "PyYAML", ""}, + {"composer", "Symfony", "Console", "6.1.7"}, + {"golang", "GitHub.com/Foo", "Bar", "v1.0.0"}, + {"npm", "@Babel", "Core", "7.24.0"}, + } + + for _, tt := range cases { + t.Run(tt.purlType+"/"+tt.name, func(t *testing.T) { + built := New(tt.purlType, tt.namespace, tt.name, tt.version, nil).String() + parsed, err := Parse(built) + if err != nil { + t.Fatalf("Parse(%q) error: %v", built, err) + } + if parsed.String() != built { + t.Errorf("New = %q, Parse round-trip = %q", built, parsed.String()) + } + }) + } +} + func TestRepositoryURL(t *testing.T) { tests := []struct { purl string