From af530e5ce66cbf5be6d78105bf609f937d1e4c89 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 16 Aug 2026 22:15:42 +0100 Subject: [PATCH] Handle Swift registry identities --- ecosystem.go | 58 ++++++++++++----------------------------------- ecosystem_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++++- makepurl.go | 27 ++++++++++++++++++---- makepurl_test.go | 8 +++++++ 4 files changed, 99 insertions(+), 50 deletions(-) diff --git a/ecosystem.go b/ecosystem.go index 3c1d451..69d0047 100644 --- a/ecosystem.go +++ b/ecosystem.go @@ -14,6 +14,7 @@ const ( ecosystemNPM = "npm" ecosystemPackagist = "packagist" ecosystemRubyGems = "rubygems" + ecosystemSwift = "swift" purlTypeGem = "gem" purlTypeGitHubActions = "githubactions" ) @@ -145,59 +146,28 @@ func PURLTypeToDepsdev(purlType string) string { // - composer: vendor/package -> namespace="vendor", name="package" // - alpine: pkg -> namespace="alpine", name="pkg" // - arch: pkg -> namespace="arch", name="pkg" +// - swift: host/owner/package -> namespace="host/owner", name="package" +// +// Swift registry identities do not contain source repository coordinates and +// return nil because the Swift PURL type cannot represent them. func MakePURL(ecosystem, name, version string) *PURL { purlType := EcosystemToPURLType(ecosystem) - namespace := "" - pkgName := name - - // Handle default namespaces - if ns, ok := defaultNamespaces[NormalizeEcosystem(ecosystem)]; ok { - namespace = ns - } - - // Extract namespace from name based on ecosystem conventions - switch NormalizeEcosystem(ecosystem) { - 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 ecosystemGolang: - if idx := strings.LastIndex(name, "/"); idx > 0 { - namespace = name[:idx] - pkgName = name[idx+1:] - } - case ecosystemMaven: - if strings.Contains(name, ":") { - parts := strings.SplitN(name, ":", 2) //nolint:mnd - namespace = parts[0] - pkgName = parts[1] - } - case ecosystemPackagist, ecosystemComposer: - if strings.Contains(name, "/") { - parts := strings.SplitN(name, "/", 2) //nolint:mnd - namespace = parts[0] - pkgName = parts[1] - } - 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 - namespace = parts[0] - pkgName = parts[1] - } + namespace, pkgName, ok := splitNamespace(ecosystem, name) + if !ok { + return nil } return New(purlType, namespace, pkgName, version, nil) } -// MakePURLString is like MakePURL but returns the PURL as a string. +// MakePURLString is like MakePURL but returns the PURL as a string. It returns +// an empty string when the package identifier cannot be represented as a PURL. func MakePURLString(ecosystem, name, version string) string { purlType := EcosystemToPURLType(ecosystem) - namespace, pkgName := splitNamespace(ecosystem, name) + namespace, pkgName, ok := splitNamespace(ecosystem, name) + if !ok { + return "" + } return buildPURLString(purlType, namespace, pkgName, version, "") } diff --git a/ecosystem_test.go b/ecosystem_test.go index 8381526..ddb0330 100644 --- a/ecosystem_test.go +++ b/ecosystem_test.go @@ -293,11 +293,22 @@ func TestMakePURL(t *testing.T) { version: "v3", wantStr: "pkg:githubactions/actions/cache@v3", }, + // Swift source coordinate + { + name: "swift source coordinate", + ecosystem: "swift", + pkg: "github.com/apple/swift-argument-parser", + version: "1.8.2", + wantStr: "pkg:swift/github.com/apple/swift-argument-parser@1.8.2", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := MakePURL(tt.ecosystem, tt.pkg, tt.version) + if p == nil { + t.Fatalf("MakePURL(%q, %q, %q) returned nil", tt.ecosystem, tt.pkg, tt.version) + } if got := p.String(); got != tt.wantStr { t.Errorf("MakePURL(%q, %q, %q).String() = %q, want %q", tt.ecosystem, tt.pkg, tt.version, got, tt.wantStr) @@ -306,6 +317,22 @@ func TestMakePURL(t *testing.T) { } } +func TestMakePURLRejectsSwiftRegistryIdentity(t *testing.T) { + identities := []string{ + "apple.swift-argument-parser", + "apple/swift-argument-parser", + "/apple/swift-argument-parser", + } + + for _, identity := range identities { + t.Run(identity, func(t *testing.T) { + if got := MakePURL("swift", identity, "1.8.2"); got != nil { + t.Errorf("MakePURL(%q) = %q, want nil", identity, got.String()) + } + }) + } +} + func TestMakePURLString(t *testing.T) { tests := []struct { name string @@ -384,6 +411,13 @@ func TestMakePURLString(t *testing.T) { version: "1.0.0", want: "pkg:cargo/serde%2Bderive@1.0.0", }, + { + name: "swift source coordinate", + ecosystem: "swift", + pkg: "github.com/apple/swift-argument-parser", + version: "1.8.2", + want: "pkg:swift/github.com/apple/swift-argument-parser@1.8.2", + }, } for _, tt := range tests { @@ -394,13 +428,33 @@ func TestMakePURLString(t *testing.T) { tt.ecosystem, tt.pkg, tt.version, got, tt.want) } - if canonical := MakePURL(tt.ecosystem, tt.pkg, tt.version).String(); got != canonical { + p := MakePURL(tt.ecosystem, tt.pkg, tt.version) + if p == nil { + t.Fatalf("MakePURL(%q, %q, %q) returned nil", tt.ecosystem, tt.pkg, tt.version) + } + if canonical := p.String(); got != canonical { t.Errorf("MakePURLString() = %q, MakePURL().String() = %q", got, canonical) } }) } } +func TestMakePURLStringRejectsSwiftRegistryIdentity(t *testing.T) { + identities := []string{ + "apple.swift-argument-parser", + "apple/swift-argument-parser", + "/apple/swift-argument-parser", + } + + for _, identity := range identities { + t.Run(identity, func(t *testing.T) { + if got := MakePURLString("swift", identity, "1.8.2"); got != "" { + t.Errorf("MakePURLString(%q) = %q, want empty string", identity, got) + } + }) + } +} + func TestSupportedEcosystems(t *testing.T) { ecosystems := SupportedEcosystems() diff --git a/makepurl.go b/makepurl.go index 0d1c8ff..06fb3b4 100644 --- a/makepurl.go +++ b/makepurl.go @@ -29,11 +29,15 @@ func CleanVersion(version, scheme string) string { // BuildPURLString builds a PURL string directly from ecosystem-native identifiers // without creating intermediate PURL structs. This is the fast path for manifest -// parsing where we just need the string output. +// parsing where we just need the string output. It returns an empty string when +// the package identifier cannot be represented as a PURL. func BuildPURLString(ecosystem, name, version, registryURL string) string { purlType := EcosystemToPURLType(ecosystem) + namespace, pkgName, ok := splitNamespace(ecosystem, name) + if !ok { + return "" + } cleanVersion := CleanVersion(version, purlType) - namespace, pkgName := splitNamespace(ecosystem, name) if registryURL != "" && !IsNonDefaultRegistry(purlType, registryURL) { registryURL = "" @@ -115,12 +119,14 @@ func escapedQualifierLength(s string) int { } // splitNamespace extracts namespace and package name from an ecosystem-native -// package identifier. -func splitNamespace(ecosystem, name string) (namespace, pkgName string) { +// package identifier. It reports false when the identifier cannot be represented +// by its ecosystem's PURL type. +func splitNamespace(ecosystem, name string) (namespace, pkgName string, ok bool) { pkgName = name + ok = true normalized := NormalizeEcosystem(ecosystem) - if ns, ok := defaultNamespaces[normalized]; ok { + if ns, found := defaultNamespaces[normalized]; found { namespace = ns } @@ -157,6 +163,17 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string) { pkgName = rest } } + case ecosystemSwift: + i := strings.LastIndexByte(name, '/') + if i <= 0 || i == len(name)-1 { + return "", "", false + } + namespace = name[:i] + ownerSeparator := strings.IndexByte(namespace, '/') + if ownerSeparator <= 0 || ownerSeparator == len(namespace)-1 || strings.Contains(namespace, "//") { + return "", "", false + } + pkgName = name[i+1:] } return } diff --git a/makepurl_test.go b/makepurl_test.go index f9d977e..3a28ec8 100644 --- a/makepurl_test.go +++ b/makepurl_test.go @@ -64,6 +64,10 @@ 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"}, + {"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", "", ""}, + {"swift registry URL path", "swift", "/apple/swift-argument-parser", "1.8.2", "", ""}, } for _, tt := range tests { @@ -92,6 +96,7 @@ func TestBuildPURLStringMatchesMakePURL(t *testing.T) { {"packagist", "vendor/pkg", "1.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", ""}, } for _, tt := range cases { @@ -101,6 +106,9 @@ func TestBuildPURLStringMatchesMakePURL(t *testing.T) { purlType := EcosystemToPURLType(tt.ecosystem) cleanVersion := CleanVersion(tt.version, purlType) p := MakePURL(tt.ecosystem, tt.name, cleanVersion) + if p == nil { + t.Fatalf("MakePURL(%q, %q, %q) returned nil", tt.ecosystem, tt.name, cleanVersion) + } if tt.registryURL != "" && IsNonDefaultRegistry(purlType, tt.registryURL) { p = p.WithQualifier("repository_url", tt.registryURL) }