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
58 changes: 14 additions & 44 deletions ecosystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const (
ecosystemNPM = "npm"
ecosystemPackagist = "packagist"
ecosystemRubyGems = "rubygems"
ecosystemSwift = "swift"
purlTypeGem = "gem"
purlTypeGitHubActions = "githubactions"
)
Expand Down Expand Up @@ -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, "")
}

Expand Down
56 changes: 55 additions & 1 deletion ecosystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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()

Expand Down
27 changes: 22 additions & 5 deletions makepurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Comment thread
andrew marked this conversation as resolved.
pkgName = name[i+1:]
}
return
}
Expand Down
8 changes: 8 additions & 0 deletions makepurl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down