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
1 change: 1 addition & 0 deletions ecosystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}

Expand Down
25 changes: 25 additions & 0 deletions ecosystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions makepurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 != "" {
Expand Down
7 changes: 7 additions & 0 deletions makepurl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "", ""},
Expand Down Expand Up @@ -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", ""},
Expand Down
6 changes: 6 additions & 0 deletions purl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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}
}

Expand Down
39 changes: 39 additions & 0 deletions purl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down