pax_global_header00006660000000000000000000000064151722104760014517gustar00rootroot0000000000000052 comment=3c7c6fcbfee666681a367cc01a61fa089aaf2f9b golang-github-git-pkgs-purl-0.1.10/000077500000000000000000000000001517221047600170305ustar00rootroot00000000000000golang-github-git-pkgs-purl-0.1.10/.github/000077500000000000000000000000001517221047600203705ustar00rootroot00000000000000golang-github-git-pkgs-purl-0.1.10/.github/dependabot.yml000066400000000000000000000004051517221047600232170ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: gomod directory: / schedule: interval: weekly open-pull-requests-limit: 10 - package-ecosystem: github-actions directory: / schedule: interval: weekly open-pull-requests-limit: 5 golang-github-git-pkgs-purl-0.1.10/.github/workflows/000077500000000000000000000000001517221047600224255ustar00rootroot00000000000000golang-github-git-pkgs-purl-0.1.10/.github/workflows/ci.yml000066400000000000000000000020761517221047600235500ustar00rootroot00000000000000name: CI on: push: branches: [main] pull_request: branches: [main] permissions: {} jobs: test: runs-on: ubuntu-latest strategy: matrix: go-version: ['1.25'] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: ${{ matrix.go-version }} - name: Build run: go build -v ./... - name: Test run: go test -v -race ./... lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: persist-credentials: false - name: Set up Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6 with: go-version: '1.25' - name: golangci-lint uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9 with: version: latest golang-github-git-pkgs-purl-0.1.10/.golangci.yml000066400000000000000000000000601517221047600214100ustar00rootroot00000000000000version: "2" formatters: enable: - gofmt golang-github-git-pkgs-purl-0.1.10/LICENSE000066400000000000000000000020571517221047600200410ustar00rootroot00000000000000MIT License Copyright (c) 2026 Andrew Nesbitt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. golang-github-git-pkgs-purl-0.1.10/README.md000066400000000000000000000032761517221047600203170ustar00rootroot00000000000000# purl Go library for working with Package URLs (PURLs). Wraps [packageurl-go](https://github.com/package-url/packageurl-go) with additional helpers for registry URL generation, type configuration, and version cleaning. ## Installation ``` go get github.com/git-pkgs/purl ``` ## Usage ```go import "github.com/git-pkgs/purl" // Parse a PURL p, _ := purl.Parse("pkg:npm/%40babel/core@7.24.0") fmt.Println(p.FullName()) // @babel/core fmt.Println(p.Type) // npm fmt.Println(p.Version) // 7.24.0 // Create a PURL p := purl.New("npm", "@babel", "core", "7.24.0", nil) fmt.Println(p.String()) // pkg:npm/%40babel/core@7.24.0 // Generate registry URL url, _ := p.RegistryURL() fmt.Println(url) // https://www.npmjs.com/package/@babel/core // Parse registry URL back to PURL p, _ := purl.ParseRegistryURL("https://crates.io/crates/serde") fmt.Println(p.String()) // pkg:cargo/serde // Check if using private registry p, _ := purl.Parse("pkg:npm/lodash?repository_url=https://npm.example.com") fmt.Println(p.IsPrivateRegistry()) // true // Get type configuration cfg := purl.TypeInfo("npm") fmt.Println(*cfg.DefaultRegistry) // https://registry.npmjs.org // Clean version constraints version := purl.CleanVersion("^1.2.3", "npm") fmt.Println(version) // 1.2.3 ``` ## Type Configuration Type information comes from an embedded copy of [purl-types.json](https://github.com/andrew/purl/blob/main/purl-types.json) which includes default registries, namespace requirements, and URI templates for registry URL generation. ```go purl.KnownTypes() // []string of all known PURL types purl.IsKnownType("npm") // true purl.DefaultRegistry("npm") // https://registry.npmjs.org ``` ## License MIT golang-github-git-pkgs-purl-0.1.10/defaults.go000066400000000000000000000025061517221047600211710ustar00rootroot00000000000000package purl import ( "strings" ) // IsDefaultRegistry returns true if the registryURL matches the default registry for the type. func IsDefaultRegistry(purlType, registryURL string) bool { if registryURL == "" { return true } cfg := TypeInfo(purlType) if cfg == nil || cfg.DefaultRegistry == nil { return false } defaultURL := *cfg.DefaultRegistry if defaultURL == "" { return false } // Compare hosts defaultHost := extractHost(defaultURL) givenHost := extractHost(registryURL) if defaultHost == "" || givenHost == "" { return false } return givenHost == defaultHost || strings.HasSuffix(givenHost, "."+defaultHost) } // IsNonDefaultRegistry returns true if the registryURL is not the default registry for the type. func IsNonDefaultRegistry(purlType, registryURL string) bool { if registryURL == "" { return false } return !IsDefaultRegistry(purlType, registryURL) } // extractHost extracts the host from a URL string without using net/url.Parse. func extractHost(rawURL string) string { s := rawURL // Strip scheme if i := strings.Index(s, "://"); i >= 0 { s = s[i+3:] } // Strip userinfo if i := strings.Index(s, "@"); i >= 0 { s = s[i+1:] } // Strip path, query, fragment for _, sep := range []byte{'/', '?', '#'} { if i := strings.IndexByte(s, sep); i >= 0 { s = s[:i] } } return s } golang-github-git-pkgs-purl-0.1.10/defaults_test.go000066400000000000000000000060211517221047600222240ustar00rootroot00000000000000package purl import ( "testing" ) func TestIsDefaultRegistry(t *testing.T) { tests := []struct { purlType string registryURL string want bool }{ // Empty URL is always default {"npm", "", true}, {"pypi", "", true}, // npm default registry (from types.json: https://registry.npmjs.org) {"npm", "https://registry.npmjs.org", true}, {"npm", "https://npm.example.com", false}, // pypi default registry (from types.json: https://pypi.org) {"pypi", "https://pypi.org", true}, {"pypi", "https://pypi.example.com", false}, // cargo default registry (from types.json: https://crates.io) {"cargo", "https://crates.io", true}, {"cargo", "https://cargo.example.com", false}, // gem default registry (from types.json: https://rubygems.org) {"gem", "https://rubygems.org", true}, {"gem", "https://gems.example.com", false}, // maven default registry (from types.json: https://repo.maven.apache.org/maven2) {"maven", "https://repo.maven.apache.org", true}, {"maven", "https://maven.example.com", false}, // golang default registry (from types.json: https://pkg.go.dev) {"golang", "https://pkg.go.dev", true}, {"golang", "https://go.example.com", false}, // docker default registry (from types.json: https://hub.docker.com) {"docker", "https://hub.docker.com", true}, {"docker", "https://gcr.io", false}, // subdomain matching {"npm", "https://cdn.registry.npmjs.org", true}, // Types with no default registry {"apk", "https://example.com", false}, {"deb", "https://example.com", false}, // Unknown type {"unknown-type", "https://example.com", false}, } for _, tt := range tests { name := tt.purlType + ":" + tt.registryURL if tt.registryURL == "" { name = tt.purlType + ":empty" } t.Run(name, func(t *testing.T) { if got := IsDefaultRegistry(tt.purlType, tt.registryURL); got != tt.want { t.Errorf("IsDefaultRegistry(%q, %q) = %v, want %v", tt.purlType, tt.registryURL, got, tt.want) } }) } } func TestIsNonDefaultRegistry(t *testing.T) { tests := []struct { purlType string registryURL string want bool }{ {"npm", "", false}, {"npm", "https://registry.npmjs.org", false}, {"npm", "https://npm.example.com", true}, } for _, tt := range tests { t.Run(tt.purlType+":"+tt.registryURL, func(t *testing.T) { if got := IsNonDefaultRegistry(tt.purlType, tt.registryURL); got != tt.want { t.Errorf("IsNonDefaultRegistry(%q, %q) = %v, want %v", tt.purlType, tt.registryURL, got, tt.want) } }) } } func TestIsPrivateRegistry(t *testing.T) { tests := []struct { purl string want bool }{ {"pkg:npm/lodash", false}, {"pkg:npm/lodash?repository_url=https://registry.npmjs.org", false}, {"pkg:npm/lodash?repository_url=https://npm.example.com", true}, } for _, tt := range tests { t.Run(tt.purl, func(t *testing.T) { p, err := Parse(tt.purl) if err != nil { t.Fatalf("Parse error: %v", err) } if got := p.IsPrivateRegistry(); got != tt.want { t.Errorf("IsPrivateRegistry() = %v, want %v", got, tt.want) } }) } } golang-github-git-pkgs-purl-0.1.10/doc.go000066400000000000000000000031671517221047600201330ustar00rootroot00000000000000// Package purl provides utilities for working with Package URLs (PURLs). // // It wraps github.com/package-url/packageurl-go with additional helpers // for registry URL generation, type configuration, and version cleaning. // // # Parsing and Creating PURLs // // // Parse a PURL string // p, err := purl.Parse("pkg:npm/%40babel/core@7.24.0") // fmt.Println(p.Type) // npm // fmt.Println(p.Namespace) // @babel // fmt.Println(p.Name) // core // fmt.Println(p.Version) // 7.24.0 // fmt.Println(p.FullName()) // @babel/core // // // Create a PURL from components // p := purl.New("npm", "@babel", "core", "7.24.0", nil) // fmt.Println(p.String()) // pkg:npm/%40babel/core@7.24.0 // // # Registry URLs // // // Generate a registry URL from a PURL // p, _ := purl.Parse("pkg:npm/lodash@4.17.21") // url, _ := p.RegistryURLWithVersion() // fmt.Println(url) // https://www.npmjs.com/package/lodash/v/4.17.21 // // // Parse a registry URL back to a PURL // p, _ := purl.ParseRegistryURL("https://crates.io/crates/serde") // fmt.Println(p.String()) // pkg:cargo/serde // // # Type Configuration // // Type information comes from an embedded purl-types.json file. // // purl.KnownTypes() // []string of all known PURL types // purl.IsKnownType("npm") // true // purl.DefaultRegistry("npm") // https://registry.npmjs.org // // cfg := purl.TypeInfo("maven") // fmt.Println(cfg.NamespaceRequired()) // true // // # Private Registries // // p, _ := purl.Parse("pkg:npm/lodash?repository_url=https://npm.example.com") // fmt.Println(p.IsPrivateRegistry()) // true // fmt.Println(p.RepositoryURL()) // https://npm.example.com package purl golang-github-git-pkgs-purl-0.1.10/ecosystem.go000066400000000000000000000135121517221047600213740ustar00rootroot00000000000000package purl import ( "strings" ) const ecosystemMaven = "maven" // 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", } // ecosystemAliases maps alternate names to canonical ecosystem names. var ecosystemAliases = map[string]string{ "go": "golang", "gem": "rubygems", "composer": "packagist", } // osvEcosystemNames maps PURL types to OSV ecosystem names. var osvEcosystemNames = map[string]string{ "gem": "RubyGems", "npm": "npm", "pypi": "PyPI", "cargo": "crates.io", "golang": "Go", ecosystemMaven: "Maven", "nuget": "NuGet", "composer": "Packagist", "hex": "Hex", "pub": "Pub", "cocoapods": "CocoaPods", "githubactions": "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", } // defaultNamespaces defines default namespaces for certain ecosystems. var defaultNamespaces = map[string]string{ "alpine": "alpine", "arch": "arch", } // NormalizeEcosystem returns the canonical ecosystem name. // Handles aliases like "go" -> "golang", "gem" -> "rubygems". func NormalizeEcosystem(ecosystem string) string { lower := strings.ToLower(ecosystem) if canonical, ok := ecosystemAliases[lower]; ok { return canonical } return lower } // EcosystemToPURLType converts an ecosystem name to the corresponding PURL type. // Returns the input unchanged if no mapping exists. func EcosystemToPURLType(ecosystem string) string { normalized := NormalizeEcosystem(ecosystem) if t, ok := purlTypeForEcosystem[normalized]; ok { return t } return normalized } // PURLTypeToEcosystem converts a PURL type back to an ecosystem name. // This is the inverse of EcosystemToPURLType. func PURLTypeToEcosystem(purlType string) string { // Reverse lookup for eco, pt := range purlTypeForEcosystem { if pt == purlType { return eco } } return purlType } // EcosystemToOSV converts an ecosystem name to the OSV ecosystem name. // OSV uses specific capitalization and naming conventions. func EcosystemToOSV(ecosystem string) string { purlType := EcosystemToPURLType(ecosystem) if osv, ok := osvEcosystemNames[purlType]; ok { return osv } return ecosystem } // PURLTypeToDepsdev converts a PURL type to the deps.dev system name. // Returns empty string if the type is not supported by deps.dev. func PURLTypeToDepsdev(purlType string) string { if system, ok := depsdevSystemNames[purlType]; ok { return system } return "" } // MakePURL constructs a PURL from ecosystem-native package identifiers. // // It handles namespace extraction for ecosystems: // - npm: @scope/pkg -> namespace="@scope", name="pkg" // - maven: group:artifact -> namespace="group", name="artifact" // - golang: github.com/foo/bar -> namespace="github.com/foo", name="bar" // - composer: vendor/package -> namespace="vendor", name="package" // - alpine: pkg -> namespace="alpine", name="pkg" // - arch: pkg -> namespace="arch", name="pkg" 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 "npm": 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 "golang": 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 "packagist", "composer": if strings.Contains(name, "/") { parts := strings.SplitN(name, "/", 2) //nolint:mnd namespace = parts[0] pkgName = parts[1] } case "github-actions": // 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] } } return New(purlType, namespace, pkgName, version, nil) } // MakePURLString is like MakePURL but returns the PURL as a string. func MakePURLString(ecosystem, name, version string) string { return MakePURL(ecosystem, name, version).String() } // SupportedEcosystems returns a list of all supported ecosystem names. // This includes both PURL types and common aliases. func SupportedEcosystems() []string { seen := make(map[string]bool) var result []string // Add all known PURL types for _, t := range KnownTypes() { if !seen[t] { seen[t] = true result = append(result, t) } } // Add ecosystem aliases for alias := range ecosystemAliases { if !seen[alias] { seen[alias] = true result = append(result, alias) } } // Add ecosystems that map to different PURL types for eco := range purlTypeForEcosystem { if !seen[eco] { seen[eco] = true result = append(result, eco) } } return result } // IsValidEcosystem returns true if the ecosystem is recognized. func IsValidEcosystem(ecosystem string) bool { normalized := NormalizeEcosystem(ecosystem) purlType := EcosystemToPURLType(normalized) return IsKnownType(purlType) } golang-github-git-pkgs-purl-0.1.10/ecosystem_test.go000066400000000000000000000160471517221047600224410ustar00rootroot00000000000000package purl import ( "testing" ) func TestNormalizeEcosystem(t *testing.T) { tests := []struct { ecosystem string want string }{ {"npm", "npm"}, {"NPM", "npm"}, {"go", "golang"}, {"Go", "golang"}, {"golang", "golang"}, {"gem", "rubygems"}, {"rubygems", "rubygems"}, {"composer", "packagist"}, {"packagist", "packagist"}, {"cargo", "cargo"}, {"unknown", "unknown"}, } for _, tt := range tests { t.Run(tt.ecosystem, func(t *testing.T) { if got := NormalizeEcosystem(tt.ecosystem); got != tt.want { t.Errorf("NormalizeEcosystem(%q) = %q, want %q", tt.ecosystem, got, tt.want) } }) } } func TestEcosystemToPURLType(t *testing.T) { tests := []struct { ecosystem string want string }{ {"npm", "npm"}, {"alpine", "apk"}, {"arch", "alpm"}, {"rubygems", "gem"}, {"packagist", "composer"}, {"cargo", "cargo"}, {"go", "golang"}, // alias normalized first {"gem", "gem"}, // alias to rubygems, then rubygems -> gem {"composer", "composer"}, // alias to packagist, then packagist -> composer {"github-actions", "githubactions"}, {"unknown", "unknown"}, } for _, tt := range tests { t.Run(tt.ecosystem, func(t *testing.T) { if got := EcosystemToPURLType(tt.ecosystem); got != tt.want { t.Errorf("EcosystemToPURLType(%q) = %q, want %q", tt.ecosystem, got, tt.want) } }) } } func TestPURLTypeToEcosystem(t *testing.T) { tests := []struct { purlType string want string }{ {"apk", "alpine"}, {"alpm", "arch"}, {"gem", "rubygems"}, {"composer", "packagist"}, {"githubactions", "github-actions"}, {"npm", "npm"}, // no reverse mapping, returns as-is {"cargo", "cargo"}, // no reverse mapping, returns as-is } for _, tt := range tests { t.Run(tt.purlType, func(t *testing.T) { if got := PURLTypeToEcosystem(tt.purlType); got != tt.want { t.Errorf("PURLTypeToEcosystem(%q) = %q, want %q", tt.purlType, got, tt.want) } }) } } func TestEcosystemToOSV(t *testing.T) { tests := []struct { ecosystem string want string }{ {"npm", "npm"}, {"rubygems", "RubyGems"}, {"gem", "RubyGems"}, {"pypi", "PyPI"}, {"cargo", "crates.io"}, {"golang", "Go"}, {"go", "Go"}, {"maven", "Maven"}, {"nuget", "NuGet"}, {"packagist", "Packagist"}, {"composer", "Packagist"}, {"hex", "Hex"}, {"pub", "Pub"}, {"cocoapods", "CocoaPods"}, {"github-actions", "GitHub Actions"}, {"unknown", "unknown"}, // falls through } for _, tt := range tests { t.Run(tt.ecosystem, func(t *testing.T) { if got := EcosystemToOSV(tt.ecosystem); got != tt.want { t.Errorf("EcosystemToOSV(%q) = %q, want %q", tt.ecosystem, got, tt.want) } }) } } func TestPURLTypeToDepsdev(t *testing.T) { tests := []struct { purlType string want string }{ {"npm", "NPM"}, {"gem", "RUBYGEMS"}, {"pypi", "PYPI"}, {"cargo", "CARGO"}, {"golang", "GO"}, {"maven", "MAVEN"}, {"nuget", "NUGET"}, {"unknown", ""}, {"hex", ""}, } for _, tt := range tests { t.Run(tt.purlType, func(t *testing.T) { if got := PURLTypeToDepsdev(tt.purlType); got != tt.want { t.Errorf("PURLTypeToDepsdev(%q) = %q, want %q", tt.purlType, got, tt.want) } }) } } func TestMakePURL(t *testing.T) { tests := []struct { name string ecosystem string pkg string version string wantStr string }{ // npm with scope { name: "npm scoped", ecosystem: "npm", pkg: "@babel/core", version: "7.24.0", wantStr: "pkg:npm/%40babel/core@7.24.0", }, // npm without scope { name: "npm unscoped", ecosystem: "npm", pkg: "lodash", version: "4.17.21", wantStr: "pkg:npm/lodash@4.17.21", }, // golang { name: "golang", ecosystem: "golang", pkg: "github.com/foo/bar", version: "v1.0.0", wantStr: "pkg:golang/github.com/foo/bar@v1.0.0", }, // maven { name: "maven", ecosystem: "maven", pkg: "org.apache.commons:commons-lang3", version: "3.12.0", wantStr: "pkg:maven/org.apache.commons/commons-lang3@3.12.0", }, // packagist/composer { name: "packagist", ecosystem: "packagist", pkg: "laravel/framework", version: "9.0.0", wantStr: "pkg:composer/laravel/framework@9.0.0", }, // alpine (default namespace) { name: "alpine", ecosystem: "alpine", pkg: "curl", version: "8.0.0", wantStr: "pkg:apk/alpine/curl@8.0.0", }, // arch (default namespace) { name: "arch", ecosystem: "arch", pkg: "base", version: "1.0", wantStr: "pkg:alpm/arch/base@1.0", }, // cargo (no special handling) { name: "cargo", ecosystem: "cargo", pkg: "serde", version: "1.0.0", wantStr: "pkg:cargo/serde@1.0.0", }, // pypi { name: "pypi", ecosystem: "pypi", pkg: "requests", version: "2.28.0", wantStr: "pkg:pypi/requests@2.28.0", }, // using alias { name: "go alias", ecosystem: "go", pkg: "github.com/user/repo", version: "v1.0.0", wantStr: "pkg:golang/github.com/user/repo@v1.0.0", }, // github-actions { name: "github-actions", ecosystem: "github-actions", pkg: "actions/checkout", version: "v4", wantStr: "pkg:githubactions/actions/checkout@v4", }, // github-actions with path { name: "github-actions with path", ecosystem: "github-actions", pkg: "actions/cache/restore", version: "v3", wantStr: "pkg:githubactions/actions/cache@v3", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := MakePURL(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) } }) } } 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) } } func TestSupportedEcosystems(t *testing.T) { ecosystems := SupportedEcosystems() // Should have at least the known types plus aliases if len(ecosystems) < 10 { t.Errorf("SupportedEcosystems() returned only %d items, expected more", len(ecosystems)) } // Check that some known ecosystems are present has := make(map[string]bool) for _, e := range ecosystems { has[e] = true } required := []string{"npm", "cargo", "pypi", "maven", "golang", "go", "gem", "alpine"} for _, r := range required { if !has[r] { t.Errorf("SupportedEcosystems() missing %q", r) } } } func TestIsValidEcosystem(t *testing.T) { tests := []struct { ecosystem string want bool }{ {"npm", true}, {"cargo", true}, {"pypi", true}, {"golang", true}, {"go", true}, {"gem", true}, {"rubygems", true}, {"alpine", true}, {"notarealecosystem", false}, } for _, tt := range tests { t.Run(tt.ecosystem, func(t *testing.T) { if got := IsValidEcosystem(tt.ecosystem); got != tt.want { t.Errorf("IsValidEcosystem(%q) = %v, want %v", tt.ecosystem, got, tt.want) } }) } } golang-github-git-pkgs-purl-0.1.10/fullname.go000066400000000000000000000005771517221047600211730ustar00rootroot00000000000000package purl // FullName returns the package name combining namespace and name. // If there's no namespace, returns just the name. // Maven uses ":" as separator (groupId:artifactId), all others use "/". func (p *PURL) FullName() string { if p.Namespace == "" { return p.Name } if p.Type == "maven" { return p.Namespace + ":" + p.Name } return p.Namespace + "/" + p.Name } golang-github-git-pkgs-purl-0.1.10/fullname_test.go000066400000000000000000000026341517221047600222260ustar00rootroot00000000000000package purl import ( "testing" ) func TestFullName(t *testing.T) { tests := []struct { purl string want string }{ // Simple packages without namespace {"pkg:cargo/serde", "serde"}, {"pkg:npm/lodash", "lodash"}, {"pkg:pypi/requests", "requests"}, {"pkg:gem/rails", "rails"}, // npm scoped packages {"pkg:npm/%40babel/core@7.24.0", "@babel/core"}, {"pkg:npm/%40types/node@18.0.0", "@types/node"}, // Maven with groupId (uses : separator) {"pkg:maven/org.apache.commons/commons-lang3@3.12.0", "org.apache.commons:commons-lang3"}, {"pkg:maven/junit/junit@4.13.2", "junit:junit"}, // Go modules {"pkg:golang/github.com/gorilla/mux@v1.8.0", "github.com/gorilla/mux"}, {"pkg:golang/google.golang.org/grpc@v1.50.0", "google.golang.org/grpc"}, // Terraform modules {"pkg:terraform/hashicorp/consul/aws@0.11.0", "hashicorp/consul/aws"}, // Composer {"pkg:composer/symfony/console@6.1.7", "symfony/console"}, // Hex (no namespace) {"pkg:hex/phoenix@1.7.0", "phoenix"}, // Elm {"pkg:elm/elm/http@2.0.0", "elm/http"}, // Clojars {"pkg:clojars/org.clojure/clojure@1.11.1", "org.clojure/clojure"}, } for _, tt := range tests { t.Run(tt.purl, func(t *testing.T) { p, err := Parse(tt.purl) if err != nil { t.Fatalf("Parse(%q) error = %v", tt.purl, err) } if got := p.FullName(); got != tt.want { t.Errorf("FullName() = %q, want %q", got, tt.want) } }) } } golang-github-git-pkgs-purl-0.1.10/go.mod000066400000000000000000000002031517221047600201310ustar00rootroot00000000000000module github.com/git-pkgs/purl go 1.25.6 require ( github.com/git-pkgs/packageurl-go v0.3.1 github.com/git-pkgs/vers v0.2.4 ) golang-github-git-pkgs-purl-0.1.10/go.sum000066400000000000000000000005401517221047600201620ustar00rootroot00000000000000github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M= github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4= github.com/git-pkgs/vers v0.2.4 h1:Zr3jR/Xf1i/6cvBaJKPxhCwjzqz7uvYHE0Fhid/GPBk= github.com/git-pkgs/vers v0.2.4/go.mod h1:biTbSQK1qdbrsxDEKnqe3Jzclxz8vW6uDcwKjfUGcOo= golang-github-git-pkgs-purl-0.1.10/makepurl.go000066400000000000000000000112661517221047600212050ustar00rootroot00000000000000package purl import ( "strings" "github.com/git-pkgs/vers" ) // CleanVersion extracts a version from a version constraint string. // Uses the vers library to parse the constraint and extract the minimum bound. // If parsing fails, returns the original string. func CleanVersion(version, scheme string) string { if version == "" { return "" } r, err := vers.ParseNative(version, scheme) if err != nil || len(r.Intervals) == 0 { return version } // Return the minimum bound from the first interval if r.Intervals[0].Min != "" { return r.Intervals[0].Min } return version } // 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. func BuildPURLString(ecosystem, name, version, registryURL string) string { purlType := EcosystemToPURLType(ecosystem) 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 cleanVersion != "" { n += 1 + len(cleanVersion) // "@" + version } if needsQualifier { n += len("?repository_url=") + len(registryURL) } var b strings.Builder b.Grow(n) 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 = "" } writeComponentEscaped(&b, seg) } } b.WriteByte('/') writeComponentEscaped(&b, pkgName) if cleanVersion != "" { b.WriteByte('@') writeComponentEscaped(&b, cleanVersion) } if needsQualifier { b.WriteString("?repository_url=") writeQualifierEscaped(&b, registryURL) } return b.String() } // splitNamespace extracts namespace and package name from an ecosystem-native // package identifier. func splitNamespace(ecosystem, name string) (namespace, pkgName string) { pkgName = name normalized := NormalizeEcosystem(ecosystem) if ns, ok := defaultNamespaces[normalized]; ok { namespace = ns } switch normalized { case "npm": if strings.HasPrefix(name, "@") { if i := strings.IndexByte(name, '/'); i >= 0 { namespace = name[:i] pkgName = name[i+1:] } } case "golang": if i := strings.LastIndex(name, "/"); i > 0 { namespace = name[:i] pkgName = name[i+1:] } case "maven": if i := strings.IndexByte(name, ':'); i >= 0 { namespace = name[:i] pkgName = name[i+1:] } case "packagist", "composer": if i := strings.IndexByte(name, '/'); i >= 0 { namespace = name[:i] pkgName = name[i+1:] } case "github-actions": if i := strings.IndexByte(name, '/'); i >= 0 { namespace = name[:i] rest := name[i+1:] if j := strings.IndexByte(rest, '/'); j >= 0 { pkgName = rest[:j] } else { pkgName = rest } } } return } // writeComponentEscaped writes s to b, percent-encoding characters that are not safe // in PURL path components (namespace, name, version). func writeComponentEscaped(b *strings.Builder, s string) { for i := 0; i < len(s); i++ { c := s[i] if isComponentSafe(c) { b.WriteByte(c) } else { b.WriteByte('%') b.WriteByte(hexDigit(c >> 4)) //nolint:mnd b.WriteByte(hexDigit(c & 0x0f)) //nolint:mnd } } } // isComponentSafe returns true for characters that can appear unencoded in // PURL namespace/name/version segments. Matches the fork's isPurlSafe. func isComponentSafe(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~' || c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || c == ')' || c == '*' || c == ',' || c == ';' || c == '=' || c == ':' } // writeQualifierEscaped writes s to b, percent-encoding characters that are not safe // in PURL qualifier values. func writeQualifierEscaped(b *strings.Builder, s string) { for i := 0; i < len(s); i++ { c := s[i] if isQualifierValueSafe(c) { b.WriteByte(c) } else { b.WriteByte('%') b.WriteByte(hexDigit(c >> 4)) //nolint:mnd b.WriteByte(hexDigit(c & 0x0f)) //nolint:mnd } } } func isQualifierValueSafe(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '_' || c == '~' || c == ':' } func hexDigit(b byte) byte { if b < 10 { //nolint:mnd return '0' + b } return 'A' + b - 10 //nolint:mnd } golang-github-git-pkgs-purl-0.1.10/makepurl_test.go000066400000000000000000000066241517221047600222460ustar00rootroot00000000000000package purl import ( "testing" ) func TestCleanVersion(t *testing.T) { tests := []struct { version string scheme string want string }{ // npm constraints {"1.0.0", "npm", "1.0.0"}, {"^1.0.0", "npm", "1.0.0"}, {"~1.0.0", "npm", "1.0.0"}, {">=1.0.0", "npm", "1.0.0"}, {">=1.0.0 <2.0.0", "npm", "1.0.0"}, // gem constraints {"~> 1.0", "gem", "1.0"}, {">= 1.0, < 2.0", "gem", "1.0"}, // pypi constraints {">=1.0.0", "pypi", "1.0.0"}, {"~=1.4.2", "pypi", "1.4.2"}, // cargo constraints {"^1.0.0", "cargo", "1.0.0"}, // Plain versions pass through {"1.0.0", "npm", "1.0.0"}, {"v1.0.0", "go", "v1.0.0"}, // Empty {"", "npm", ""}, } for _, tt := range tests { t.Run(tt.version+"_"+tt.scheme, func(t *testing.T) { if got := CleanVersion(tt.version, tt.scheme); got != tt.want { t.Errorf("CleanVersion(%q, %q) = %q, want %q", tt.version, tt.scheme, got, tt.want) } }) } } func TestBuildPURLString(t *testing.T) { tests := []struct { name string ecosystem string pkgName string version string registryURL string want string }{ {"simple npm", "npm", "lodash", "4.17.21", "", "pkg:npm/lodash@4.17.21"}, {"scoped npm", "npm", "@babel/core", "7.20.0", "", "pkg:npm/%40babel/core@7.20.0"}, // @ encoded in namespace {"gem", "rubygems", "rails", "7.0.0", "", "pkg:gem/rails@7.0.0"}, {"pypi", "pypi", "requests", "2.28.0", "", "pkg:pypi/requests@2.28.0"}, {"maven", "maven", "org.apache:commons", "1.0", "", "pkg:maven/org.apache/commons@1.0"}, {"golang", "golang", "github.com/foo/bar", "v1.0.0", "", "pkg:golang/github.com/foo/bar@v1.0.0"}, {"no version", "npm", "lodash", "", "", "pkg:npm/lodash"}, {"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"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := BuildPURLString(tt.ecosystem, tt.pkgName, tt.version, tt.registryURL) if got != tt.want { t.Errorf("BuildPURLString() = %q, want %q", got, tt.want) } }) } } func TestBuildPURLStringMatchesMakePURL(t *testing.T) { // Ensure BuildPURLString produces the same output as the struct-based path cases := []struct { ecosystem string name string version string registryURL string }{ {"npm", "lodash", "4.17.21", ""}, {"npm", "@babel/core", "7.20.0", ""}, {"rubygems", "rails", "7.0.0", ""}, {"maven", "org.apache:commons", "1.0", ""}, {"golang", "github.com/foo/bar", "v1.0.0", ""}, {"packagist", "vendor/pkg", "1.0", ""}, {"npm", "lodash", "^1.0.0", ""}, {"npm", "pkg", "1.0.0", "https://custom.registry.com"}, } for _, tt := range cases { t.Run(tt.ecosystem+"/"+tt.name, func(t *testing.T) { fast := BuildPURLString(tt.ecosystem, tt.name, tt.version, tt.registryURL) purlType := EcosystemToPURLType(tt.ecosystem) cleanVersion := CleanVersion(tt.version, purlType) p := MakePURL(tt.ecosystem, tt.name, cleanVersion) if tt.registryURL != "" && IsNonDefaultRegistry(purlType, tt.registryURL) { p = p.WithQualifier("repository_url", tt.registryURL) } slow := p.String() if fast != slow { t.Errorf("mismatch:\n fast: %q\n slow: %q", fast, slow) } }) } } golang-github-git-pkgs-purl-0.1.10/purl.go000066400000000000000000000057741517221047600203560ustar00rootroot00000000000000// Package purl provides utilities for working with Package URLs (PURLs). // // It wraps github.com/git-pkgs/packageurl-go with additional helpers // for ecosystem-specific name formatting, registry URL generation, and // PURL construction from ecosystem-native package identifiers. package purl import ( "sort" packageurl "github.com/git-pkgs/packageurl-go" ) // PURL wraps packageurl.PackageURL with additional helpers. type PURL struct { packageurl.PackageURL } // Parse parses a Package URL string into a PURL. func Parse(s string) (*PURL, error) { p, err := packageurl.FromString(s) if err != nil { return nil, err } return &PURL{p}, nil } // New creates a new PURL from components. func New(purlType, namespace, name, version string, qualifiers map[string]string) *PURL { var q packageurl.Qualifiers if len(qualifiers) > 0 { // Sort keys for deterministic output keys := make([]string, 0, len(qualifiers)) for k := range qualifiers { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { q = append(q, packageurl.Qualifier{Key: k, Value: qualifiers[k]}) } } p := packageurl.NewPackageURL(purlType, namespace, name, version, q, "") return &PURL{*p} } // String returns the PURL as a string. func (p *PURL) String() string { return p.PackageURL.String() } // RepositoryURL returns the repository_url qualifier value, if present. func (p *PURL) RepositoryURL() string { return p.Qualifiers.Map()["repository_url"] } // IsPrivateRegistry returns true if the PURL has a non-default repository_url. func (p *PURL) IsPrivateRegistry() bool { repoURL := p.RepositoryURL() if repoURL == "" { return false } return IsNonDefaultRegistry(p.Type, repoURL) } // Qualifier returns the value of a qualifier, or empty string if not present. func (p *PURL) Qualifier(key string) string { return p.Qualifiers.Map()[key] } // WithVersion returns a copy of the PURL with a different version. func (p *PURL) WithVersion(version string) *PURL { return &PURL{ PackageURL: packageurl.PackageURL{ Type: p.Type, Namespace: p.Namespace, Name: p.Name, Version: version, Qualifiers: p.Qualifiers, Subpath: p.Subpath, }, } } // WithoutVersion returns a copy of the PURL without a version. func (p *PURL) WithoutVersion() *PURL { return p.WithVersion("") } // WithQualifier returns a copy of the PURL with the qualifier set. // If the qualifier already exists, it is replaced. func (p *PURL) WithQualifier(key, value string) *PURL { q := make(packageurl.Qualifiers, 0, len(p.Qualifiers)+1) replaced := false for _, qual := range p.Qualifiers { if qual.Key == key { q = append(q, packageurl.Qualifier{Key: key, Value: value}) replaced = true } else { q = append(q, qual) } } if !replaced { q = append(q, packageurl.Qualifier{Key: key, Value: value}) } return &PURL{ PackageURL: packageurl.PackageURL{ Type: p.Type, Namespace: p.Namespace, Name: p.Name, Version: p.Version, Qualifiers: q, Subpath: p.Subpath, }, } } golang-github-git-pkgs-purl-0.1.10/purl_test.go000066400000000000000000000151371517221047600214070ustar00rootroot00000000000000package purl import ( "testing" ) func TestParse(t *testing.T) { tests := []struct { input string wantType string wantNS string wantName string wantVer string wantErr bool }{ // Basic packages without version {"pkg:cargo/serde", "cargo", "", "serde", "", false}, {"pkg:npm/lodash", "npm", "", "lodash", "", false}, {"pkg:pypi/requests", "pypi", "", "requests", "", false}, {"pkg:gem/rails", "gem", "", "rails", "", false}, // Packages with version {"pkg:cargo/serde@1.0.0", "cargo", "", "serde", "1.0.0", false}, {"pkg:npm/lodash@4.17.21", "npm", "", "lodash", "4.17.21", false}, {"pkg:gem/rails@7.0.0", "gem", "", "rails", "7.0.0", false}, // npm scoped packages {"pkg:npm/%40babel/core", "npm", "@babel", "core", "", false}, {"pkg:npm/%40babel/core@7.24.0", "npm", "@babel", "core", "7.24.0", false}, // Maven with groupId {"pkg:maven/org.apache.commons/commons-lang3", "maven", "org.apache.commons", "commons-lang3", "", false}, {"pkg:maven/org.apache.commons/commons-lang3@3.12.0", "maven", "org.apache.commons", "commons-lang3", "3.12.0", false}, // Go modules {"pkg:golang/github.com/gorilla/mux", "golang", "github.com/gorilla", "mux", "", false}, {"pkg:golang/github.com/gorilla/mux@v1.8.0", "golang", "github.com/gorilla", "mux", "v1.8.0", false}, // Terraform modules {"pkg:terraform/hashicorp/consul/aws", "terraform", "hashicorp/consul", "aws", "", false}, {"pkg:terraform/hashicorp/consul/aws@0.11.0", "terraform", "hashicorp/consul", "aws", "0.11.0", false}, // Hex {"pkg:hex/phoenix@1.7.0", "hex", "", "phoenix", "1.7.0", false}, // Composer {"pkg:composer/symfony/console@6.1.7", "composer", "symfony", "console", "6.1.7", false}, // Errors {"cargo/serde", "", "", "", "", true}, // missing pkg: prefix {"invalid", "", "", "", "", true}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { p, err := Parse(tt.input) if (err != nil) != tt.wantErr { t.Errorf("Parse(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) return } if tt.wantErr { return } if p.Type != tt.wantType { t.Errorf("Type = %q, want %q", p.Type, tt.wantType) } if p.Namespace != tt.wantNS { t.Errorf("Namespace = %q, want %q", p.Namespace, tt.wantNS) } if p.Name != tt.wantName { t.Errorf("Name = %q, want %q", p.Name, tt.wantName) } if p.Version != tt.wantVer { t.Errorf("Version = %q, want %q", p.Version, tt.wantVer) } }) } } func TestNew(t *testing.T) { tests := []struct { name string purlType string namespace string pkgName string version string qualifiers map[string]string want string }{ { name: "simple package", purlType: "cargo", pkgName: "serde", want: "pkg:cargo/serde", }, { name: "package with version", purlType: "npm", pkgName: "lodash", version: "4.17.21", want: "pkg:npm/lodash@4.17.21", }, { name: "npm scoped", purlType: "npm", namespace: "@babel", pkgName: "core", version: "7.24.0", want: "pkg:npm/%40babel/core@7.24.0", }, { name: "maven", purlType: "maven", namespace: "org.apache.commons", pkgName: "commons-lang3", version: "3.12.0", want: "pkg:maven/org.apache.commons/commons-lang3@3.12.0", }, { name: "with qualifier", purlType: "npm", pkgName: "lodash", qualifiers: map[string]string{"repository_url": "https://npm.example.com"}, want: "pkg:npm/lodash?repository_url=https:%2F%2Fnpm.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := New(tt.purlType, tt.namespace, tt.pkgName, tt.version, tt.qualifiers) if got := p.String(); got != tt.want { t.Errorf("String() = %q, want %q", got, tt.want) } }) } } func TestRepositoryURL(t *testing.T) { tests := []struct { purl string want string }{ {"pkg:npm/lodash", ""}, {"pkg:npm/lodash?repository_url=https://npm.example.com", "https://npm.example.com"}, } for _, tt := range tests { t.Run(tt.purl, func(t *testing.T) { p, err := Parse(tt.purl) if err != nil { t.Fatalf("Parse error: %v", err) } if got := p.RepositoryURL(); got != tt.want { t.Errorf("RepositoryURL() = %q, want %q", got, tt.want) } }) } } func TestWithVersion(t *testing.T) { p, _ := Parse("pkg:npm/lodash") pv := p.WithVersion("4.17.21") if p.Version != "" { t.Error("Original PURL should not have version") } if pv.Version != "4.17.21" { t.Errorf("WithVersion() version = %q, want %q", pv.Version, "4.17.21") } if pv.String() != "pkg:npm/lodash@4.17.21" { t.Errorf("WithVersion() string = %q, want %q", pv.String(), "pkg:npm/lodash@4.17.21") } } func TestWithoutVersion(t *testing.T) { p, _ := Parse("pkg:npm/lodash@4.17.21") pv := p.WithoutVersion() if p.Version != "4.17.21" { t.Error("Original PURL should have version") } if pv.Version != "" { t.Errorf("WithoutVersion() version = %q, want empty", pv.Version) } } func TestQualifier(t *testing.T) { p, _ := Parse("pkg:npm/lodash?repository_url=https://npm.example.com&checksum=sha256:abc123") tests := []struct { key string want string }{ {"repository_url", "https://npm.example.com"}, {"checksum", "sha256:abc123"}, {"nonexistent", ""}, } for _, tt := range tests { t.Run(tt.key, func(t *testing.T) { if got := p.Qualifier(tt.key); got != tt.want { t.Errorf("Qualifier(%q) = %q, want %q", tt.key, got, tt.want) } }) } } func TestWithQualifier(t *testing.T) { p, _ := Parse("pkg:npm/lodash@4.17.21") // Add a new qualifier p2 := p.WithQualifier("repository_url", "https://npm.example.com") if got := p2.Qualifier("repository_url"); got != "https://npm.example.com" { t.Errorf("WithQualifier() new qualifier = %q, want %q", got, "https://npm.example.com") } // Original should not be modified if got := p.Qualifier("repository_url"); got != "" { t.Errorf("Original should not have qualifier, got %q", got) } // Replace an existing qualifier p3 := p2.WithQualifier("repository_url", "https://other.example.com") if got := p3.Qualifier("repository_url"); got != "https://other.example.com" { t.Errorf("WithQualifier() replaced qualifier = %q, want %q", got, "https://other.example.com") } // Add a second qualifier p4 := p2.WithQualifier("checksum", "sha256:abc") if got := p4.Qualifier("checksum"); got != "sha256:abc" { t.Errorf("WithQualifier() second qualifier = %q, want %q", got, "sha256:abc") } if got := p4.Qualifier("repository_url"); got != "https://npm.example.com" { t.Errorf("WithQualifier() should preserve existing qualifier = %q, want %q", got, "https://npm.example.com") } } golang-github-git-pkgs-purl-0.1.10/registry.go000066400000000000000000000114541517221047600212340ustar00rootroot00000000000000package purl import ( "errors" "regexp" "strings" "sync" ) // ErrNoRegistryConfig is returned when a PURL type has no registry configuration. var ErrNoRegistryConfig = errors.New("no registry configuration for this type") // ErrNoMatch is returned when a URL doesn't match the reverse regex. var ErrNoMatch = errors.New("URL does not match any known registry pattern") // regexCache caches compiled regular expressions by pattern. var regexCache sync.Map // RegistryURL returns the human-readable registry URL for the package. // For example, pkg:npm/lodash returns "https://www.npmjs.com/package/lodash". func (p *PURL) RegistryURL() (string, error) { cfg := TypeInfo(p.Type) if cfg == nil || cfg.RegistryConfig == nil { return "", ErrNoRegistryConfig } return expandTemplate(cfg.RegistryConfig, p.Namespace, p.Name, "") } // RegistryURLWithVersion returns the registry URL including version. // Falls back to RegistryURL if version URLs aren't supported. func (p *PURL) RegistryURLWithVersion() (string, error) { if p.Version == "" { return p.RegistryURL() } cfg := TypeInfo(p.Type) if cfg == nil || cfg.RegistryConfig == nil { return "", ErrNoRegistryConfig } return expandTemplate(cfg.RegistryConfig, p.Namespace, p.Name, p.Version) } // expandTemplate expands a URI template with the given components. func expandTemplate(rc *RegistryConfig, namespace, name, version string) (string, error) { var template string hasNamespace := namespace != "" // Select the appropriate template if version != "" && rc.Components.VersionInURL { switch { case hasNamespace && rc.URITemplateWithVersion != "": template = rc.URITemplateWithVersion case !hasNamespace && rc.URITemplateWithVersionNoNS != "": template = rc.URITemplateWithVersionNoNS case rc.URITemplateWithVersion != "": template = rc.URITemplateWithVersion } } if template == "" { switch { case hasNamespace: template = rc.URITemplate case rc.URITemplateNoNamespace != "": template = rc.URITemplateNoNamespace default: template = rc.URITemplate } } if template == "" { return "", ErrNoRegistryConfig } // Handle namespace prefix (e.g., @ for npm) displayNamespace := namespace if rc.Components.NamespacePrefix != "" && namespace != "" { // Add prefix if not already present if !strings.HasPrefix(namespace, rc.Components.NamespacePrefix) { displayNamespace = rc.Components.NamespacePrefix + namespace } } // Expand template variables result := template result = strings.ReplaceAll(result, "{namespace}", displayNamespace) result = strings.ReplaceAll(result, "{name}", name) result = strings.ReplaceAll(result, "{version}", version) return result, nil } // ParseRegistryURL attempts to parse a registry URL into a PURL. // It tries all known types to find a match. func ParseRegistryURL(url string) (*PURL, error) { for _, t := range KnownTypes() { p, err := ParseRegistryURLWithType(url, t) if err == nil { return p, nil } } return nil, ErrNoMatch } // ParseRegistryURLWithType parses a registry URL using a specific PURL type. func ParseRegistryURLWithType(url, purlType string) (*PURL, error) { cfg := TypeInfo(purlType) if cfg == nil || cfg.RegistryConfig == nil || cfg.RegistryConfig.ReverseRegex == "" { return nil, ErrNoRegistryConfig } re, err := getOrCompileRegex(cfg.RegistryConfig.ReverseRegex) if err != nil { return nil, err } matches := re.FindStringSubmatch(url) if matches == nil { return nil, ErrNoMatch } var namespace, name, version string // Parse matches based on component configuration if cfg.RegistryConfig.Components.Namespace { if cfg.RegistryConfig.Components.NamespaceRequired { // Namespace is required: matches[1]=namespace, matches[2]=name, matches[3]=version (if present) if len(matches) > 1 { namespace = matches[1] } if len(matches) > 2 { //nolint:mnd name = matches[2] } if len(matches) > 3 { //nolint:mnd version = matches[3] } } else { // Namespace is optional: matches[1]=namespace (maybe empty), matches[2]=name if len(matches) > 2 { //nolint:mnd namespace = matches[1] name = matches[2] } if len(matches) > 3 { //nolint:mnd version = matches[3] } } } else { // No namespace: matches[1]=name, matches[2]=version (if present) if len(matches) > 1 { name = matches[1] } if len(matches) > 2 { //nolint:mnd version = matches[2] } } if name == "" { return nil, ErrNoMatch } return New(purlType, namespace, name, version, nil), nil } // getOrCompileRegex returns a cached compiled regex or compiles and caches it. func getOrCompileRegex(pattern string) (*regexp.Regexp, error) { if cached, ok := regexCache.Load(pattern); ok { return cached.(*regexp.Regexp), nil } re, err := regexp.Compile(pattern) if err != nil { return nil, err } regexCache.Store(pattern, re) return re, nil } golang-github-git-pkgs-purl-0.1.10/registry_test.go000066400000000000000000000151011517221047600222640ustar00rootroot00000000000000package purl import ( "testing" ) func TestRegistryURL(t *testing.T) { tests := []struct { purl string want string wantErr bool }{ // npm {"pkg:npm/lodash", "https://www.npmjs.com/package/lodash", false}, {"pkg:npm/%40babel/core", "https://www.npmjs.com/package/@babel/core", false}, // pypi {"pkg:pypi/requests", "https://pypi.org/project/requests/", false}, // cargo {"pkg:cargo/serde", "https://crates.io/crates/serde", false}, // gem {"pkg:gem/rails", "https://rubygems.org/gems/rails", false}, // maven {"pkg:maven/org.apache.commons/commons-lang3", "https://mvnrepository.com/artifact/org.apache.commons/commons-lang3", false}, // composer {"pkg:composer/symfony/console", "https://packagist.org/packages/symfony/console", false}, // hex {"pkg:hex/phoenix", "https://hex.pm/packages/phoenix", false}, // hackage {"pkg:hackage/aeson", "https://hackage.haskell.org/package/aeson", false}, // pub {"pkg:pub/http", "https://pub.dev/packages/http", false}, // nuget {"pkg:nuget/Newtonsoft.Json", "https://www.nuget.org/packages/Newtonsoft.Json", false}, // conda {"pkg:conda/numpy", "https://anaconda.org/conda-forge/numpy", false}, // homebrew {"pkg:homebrew/wget", "https://formulae.brew.sh/formula/wget", false}, // deno {"pkg:deno/oak", "https://deno.land/x/oak", false}, // No registry config {"pkg:deb/debian/curl", "", true}, {"pkg:apk/alpine/curl", "", true}, } for _, tt := range tests { t.Run(tt.purl, func(t *testing.T) { p, err := Parse(tt.purl) if err != nil { t.Fatalf("Parse error: %v", err) } got, err := p.RegistryURL() if (err != nil) != tt.wantErr { t.Errorf("RegistryURL() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("RegistryURL() = %q, want %q", got, tt.want) } }) } } func TestRegistryURLWithVersion(t *testing.T) { tests := []struct { purl string want string wantErr bool }{ // npm with version {"pkg:npm/lodash@4.17.21", "https://www.npmjs.com/package/lodash/v/4.17.21", false}, {"pkg:npm/%40babel/core@7.24.0", "https://www.npmjs.com/package/@babel/core/v/7.24.0", false}, // npm without version falls back {"pkg:npm/lodash", "https://www.npmjs.com/package/lodash", false}, // pypi with version {"pkg:pypi/requests@2.28.1", "https://pypi.org/project/requests/2.28.1/", false}, // gem with version {"pkg:gem/rails@7.0.4", "https://rubygems.org/gems/rails/versions/7.0.4", false}, // nuget with version {"pkg:nuget/Newtonsoft.Json@13.0.1", "https://www.nuget.org/packages/Newtonsoft.Json/13.0.1", false}, // maven with version {"pkg:maven/org.apache.commons/commons-lang3@3.12.0", "https://mvnrepository.com/artifact/org.apache.commons/commons-lang3/3.12.0", false}, // hackage with version {"pkg:hackage/aeson@2.1.1.0", "https://hackage.haskell.org/package/aeson-2.1.1.0", false}, // deno with version {"pkg:deno/oak@12.0.0", "https://deno.land/x/oak@12.0.0", false}, // elm with version {"pkg:elm/elm/http@2.0.0", "https://package.elm-lang.org/packages/elm/http/2.0.0", false}, // cargo without version support in URL {"pkg:cargo/serde@1.0.152", "https://crates.io/crates/serde", false}, // hex without version support in URL {"pkg:hex/phoenix@1.7.0", "https://hex.pm/packages/phoenix", false}, } for _, tt := range tests { t.Run(tt.purl, func(t *testing.T) { p, err := Parse(tt.purl) if err != nil { t.Fatalf("Parse error: %v", err) } got, err := p.RegistryURLWithVersion() if (err != nil) != tt.wantErr { t.Errorf("RegistryURLWithVersion() error = %v, wantErr %v", err, tt.wantErr) return } if got != tt.want { t.Errorf("RegistryURLWithVersion() = %q, want %q", got, tt.want) } }) } } func TestParseRegistryURLWithType(t *testing.T) { tests := []struct { url string purlType string wantPURL string wantErr bool }{ // npm {"https://www.npmjs.com/package/lodash", "npm", "pkg:npm/lodash", false}, {"https://npmjs.com/package/lodash", "npm", "pkg:npm/lodash", false}, {"https://www.npmjs.com/package/@babel/core", "npm", "pkg:npm/%40babel/core", false}, {"https://www.npmjs.com/package/lodash/v/4.17.21", "npm", "pkg:npm/lodash@4.17.21", false}, // pypi {"https://pypi.org/project/requests/", "pypi", "pkg:pypi/requests", false}, {"https://pypi.org/project/requests/2.28.1/", "pypi", "pkg:pypi/requests@2.28.1", false}, // cargo {"https://crates.io/crates/serde", "cargo", "pkg:cargo/serde", false}, // gem {"https://rubygems.org/gems/rails", "gem", "pkg:gem/rails", false}, {"https://rubygems.org/gems/rails/versions/7.0.4", "gem", "pkg:gem/rails@7.0.4", false}, // maven {"https://mvnrepository.com/artifact/org.apache.commons/commons-lang3", "maven", "pkg:maven/org.apache.commons/commons-lang3", false}, {"https://mvnrepository.com/artifact/org.apache.commons/commons-lang3/3.12.0", "maven", "pkg:maven/org.apache.commons/commons-lang3@3.12.0", false}, // nuget {"https://www.nuget.org/packages/Newtonsoft.Json", "nuget", "pkg:nuget/Newtonsoft.Json", false}, {"https://nuget.org/packages/Newtonsoft.Json/13.0.1", "nuget", "pkg:nuget/Newtonsoft.Json@13.0.1", false}, // composer {"https://packagist.org/packages/symfony/console", "composer", "pkg:composer/symfony/console", false}, // No match {"https://example.com/package", "npm", "", true}, // No registry config {"https://example.com/package", "deb", "", true}, } for _, tt := range tests { t.Run(tt.url, func(t *testing.T) { p, err := ParseRegistryURLWithType(tt.url, tt.purlType) if (err != nil) != tt.wantErr { t.Errorf("ParseRegistryURLWithType() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } if got := p.String(); got != tt.wantPURL { t.Errorf("ParseRegistryURLWithType() = %q, want %q", got, tt.wantPURL) } }) } } func TestParseRegistryURL(t *testing.T) { tests := []struct { url string wantType string wantErr bool }{ {"https://www.npmjs.com/package/lodash", "npm", false}, {"https://pypi.org/project/requests/", "pypi", false}, {"https://crates.io/crates/serde", "cargo", false}, {"https://rubygems.org/gems/rails", "gem", false}, {"https://example.com/unknown", "", true}, } for _, tt := range tests { t.Run(tt.url, func(t *testing.T) { p, err := ParseRegistryURL(tt.url) if (err != nil) != tt.wantErr { t.Errorf("ParseRegistryURL() error = %v, wantErr %v", err, tt.wantErr) return } if tt.wantErr { return } if p.Type != tt.wantType { t.Errorf("ParseRegistryURL() type = %q, want %q", p.Type, tt.wantType) } }) } } golang-github-git-pkgs-purl-0.1.10/types.go000066400000000000000000000074271517221047600205350ustar00rootroot00000000000000package purl import ( _ "embed" "encoding/json" "sort" "sync" ) //go:embed types.json var typesJSON []byte // TypeConfig contains configuration for a PURL type. type TypeConfig struct { Description string `json:"description"` DefaultRegistry *string `json:"default_registry"` NamespaceRequirement string `json:"namespace_requirement"` Examples []string `json:"examples"` RegistryConfig *RegistryConfig `json:"registry_config"` } // RegistryConfig contains URL templates and patterns for registry URLs. type RegistryConfig struct { BaseURL string `json:"base_url"` ReverseRegex string `json:"reverse_regex"` URITemplate string `json:"uri_template"` URITemplateNoNamespace string `json:"uri_template_no_namespace"` URITemplateWithVersion string `json:"uri_template_with_version"` URITemplateWithVersionNoNS string `json:"uri_template_with_version_no_namespace"` Components RegistryComponents `json:"components"` } // RegistryComponents describes which PURL components are used in registry URLs. type RegistryComponents struct { Namespace bool `json:"namespace"` NamespaceRequired bool `json:"namespace_required"` NamespacePrefix string `json:"namespace_prefix"` VersionInURL bool `json:"version_in_url"` VersionPath string `json:"version_path"` VersionPrefix string `json:"version_prefix"` VersionSeparator string `json:"version_separator"` DefaultVersion string `json:"default_version"` TrailingSlash bool `json:"trailing_slash"` SpecialHandling string `json:"special_handling"` } // NamespaceRequired returns true if the type requires a namespace. func (t *TypeConfig) NamespaceRequired() bool { return t.NamespaceRequirement == "required" } // NamespaceProhibited returns true if the type prohibits namespaces. func (t *TypeConfig) NamespaceProhibited() bool { return t.NamespaceRequirement == "prohibited" } type typesData struct { Version string `json:"version"` Description string `json:"description"` Source string `json:"source"` LastUpdated string `json:"last_updated"` Types map[string]TypeConfig `json:"types"` } var ( loadOnce sync.Once loadedData *typesData loadErr error ) func loadTypes() (*typesData, error) { loadOnce.Do(func() { loadedData = &typesData{} loadErr = json.Unmarshal(typesJSON, loadedData) }) return loadedData, loadErr } // TypeInfo returns configuration for a PURL type, or nil if unknown. func TypeInfo(purlType string) *TypeConfig { data, err := loadTypes() if err != nil { return nil } cfg, ok := data.Types[purlType] if !ok { return nil } return &cfg } // KnownTypes returns a sorted list of all known PURL types. func KnownTypes() []string { data, err := loadTypes() if err != nil { return nil } types := make([]string, 0, len(data.Types)) for t := range data.Types { types = append(types, t) } sort.Strings(types) return types } // IsKnownType returns true if the PURL type is defined in types.json. func IsKnownType(purlType string) bool { data, err := loadTypes() if err != nil { return false } _, ok := data.Types[purlType] return ok } // DefaultRegistry returns the default registry URL for a PURL type. // Returns empty string if the type has no default registry. func DefaultRegistry(purlType string) string { cfg := TypeInfo(purlType) if cfg == nil || cfg.DefaultRegistry == nil { return "" } return *cfg.DefaultRegistry } // TypesVersion returns the version of the types.json data. func TypesVersion() string { data, err := loadTypes() if err != nil { return "" } return data.Version } golang-github-git-pkgs-purl-0.1.10/types.json000066400000000000000000000532161517221047600210760ustar00rootroot00000000000000{ "version": "1.2.0", "description": "Official PURL types with registry configurations", "source": "https://github.com/package-url/purl-spec", "last_updated": "2025-07-27", "types": { "alpm": { "description": "Arch Linux packages and other users of the libalpm/pacman package manager.", "default_registry": null, "examples": [ "pkg:alpm/arch/pacman@6.0.1-1?arch=x86_64", "pkg:alpm/arch/python-pip@21.0-1?arch=any", "pkg:alpm/arch/containers-common@1:0.47.4-4?arch=x86_64" ] }, "apk": { "description": "Alpine Linux APK-based packages", "default_registry": null, "examples": [ "pkg:apk/alpine/curl@7.83.0-r0?arch=x86", "pkg:apk/alpine/apk@2.12.9-r3?arch=x86" ] }, "bitbucket": { "description": "Bitbucket-based packages", "default_registry": "https://bitbucket.org", "examples": [ "pkg:bitbucket/atlassian/python-bitbucket@0.1.0", "pkg:bitbucket/birkenfeld/pygments-main@2.13.0", "pkg:bitbucket/pygame/pygame@2.1.2" ] }, "bitnami": { "description": "Bitnami-based packages", "default_registry": null, "examples": [ "pkg:bitnami/wordpress?distro=debian-12", "pkg:bitnami/wordpress@6.2.0?distro=debian-12", "pkg:bitnami/wordpress@6.2.0?arch=arm64&distro=debian-12", "pkg:bitnami/wordpress@6.2.0?arch=arm64&distro=photon-4" ] }, "cargo": { "description": "Cargo packages for Rust", "default_registry": "https://crates.io", "examples": [ "pkg:cargo/rand@0.7.2", "pkg:cargo/clap@4.0.32", "pkg:cargo/serde@1.0.152" ], "registry_config": { "base_url": "https://crates.io/crates", "reverse_regex": "^https://crates\\.io/crates/([^/?#]+)", "uri_template": "https://crates.io/crates/{name}", "components": { "namespace": false, "version_in_url": false } } }, "cocoapods": { "description": "CocoaPods pods", "default_registry": "https://cdn.cocoapods.org/", "examples": [ "pkg:cocoapods/Alamofire@5.6.4", "pkg:cocoapods/SwiftyJSON@5.0.1", "pkg:cocoapods/AFNetworking@4.0.1" ], "registry_config": { "base_url": "https://cocoapods.org/pods", "reverse_regex": "^https://cocoapods\\.org/pods/([^/?#]+)", "uri_template": "https://cocoapods.org/pods/{name}", "components": { "namespace": false, "version_in_url": false } } }, "composer": { "description": "Composer PHP packages", "default_registry": "https://packagist.org", "namespace_requirement": "required", "examples": [ "pkg:composer/symfony/console@6.1.7", "pkg:composer/laravel/framework@9.42.2", "pkg:composer/phpunit/phpunit@9.5.27" ], "registry_config": { "base_url": "https://packagist.org/packages", "reverse_regex": "^https://packagist\\.org/packages/([^/?#]+)/([^/?#]+)", "uri_template": "https://packagist.org/packages/{namespace}/{name}", "components": { "namespace": true, "namespace_required": true, "version_in_url": false } } }, "conan": { "description": "Conan C/C++ packages. The purl is designed to closely resemble the Conan-native /@/ syntax for package references as specified in https://docs.conan.io/en/1.46/cheatsheet.html#package-terminology", "default_registry": "https://conan.io/center", "examples": [ "pkg:conan/openssl@3.0.3", "pkg:conan/openssl.org/openssl@3.0.3?user=bincrafters&channel=stable", "pkg:conan/openssl.org/openssl@3.0.3?arch=x86_64&build_type=Debug&compiler=Visual%20Studio&compiler.runtime=MDd&compiler.version=16&os=Windows&shared=True&rrev=93a82349c31917d2d674d22065c7a9ef9f380c8e&prev=b429db8a0e324114c25ec387bfd8281f330d7c5c" ], "registry_config": { "base_url": "https://conan.io/center/recipes", "reverse_regex": "^https://conan\\.io/center/recipes/([^/?#]+)", "uri_template": "https://conan.io/center/recipes/{name}", "components": { "namespace": false, "version_in_url": false } } }, "conda": { "description": "conda is for Conda packages", "default_registry": "https://repo.anaconda.com", "examples": [ "pkg:conda/numpy@1.24.1", "pkg:conda/pandas@1.5.2", "pkg:conda/matplotlib@3.6.2" ], "registry_config": { "base_url": "https://anaconda.org/conda-forge", "reverse_regex": "^https://anaconda\\.org/conda-forge/([^/?#]+)", "uri_template": "https://anaconda.org/conda-forge/{name}", "components": { "namespace": false, "version_in_url": false } } }, "cpan": { "description": "CPAN Perl packages", "default_registry": "https://www.cpan.org/", "examples": [ "pkg:cpan/Moose@2.2014", "pkg:cpan/DBI@1.643", "pkg:cpan/Catalyst-Runtime@5.90128" ], "registry_config": { "base_url": "https://metacpan.org/dist", "reverse_regex": "^https://metacpan\\.org/dist/([^/?#]+)", "uri_template": "https://metacpan.org/dist/{name}", "components": { "namespace": false, "version_in_url": false } } }, "cran": { "description": "CRAN R packages", "default_registry": "https://cran.r-project.org", "namespace_requirement": "prohibited", "examples": [ "pkg:cran/ggplot2@3.4.0", "pkg:cran/dplyr@1.0.10", "pkg:cran/devtools@2.4.5" ] }, "deb": { "description": "Debian packages, Debian derivatives, and Ubuntu packages", "default_registry": null, "examples": [ "pkg:deb/debian/curl@7.50.3-1?arch=i386&distro=jessie", "pkg:deb/debian/dpkg@1.19.0.4?arch=amd64&distro=stretch", "pkg:deb/ubuntu/dpkg@1.19.0.4?arch=amd64", "pkg:deb/debian/attr@1:2.4.47-2?arch=source", "pkg:deb/debian/attr@1:2.4.47-2%2Bb1?arch=amd64" ] }, "docker": { "description": "for Docker images", "default_registry": "https://hub.docker.com", "examples": [ "pkg:docker/nginx@1.21.6", "pkg:docker/ubuntu@20.04", "pkg:docker/node@18.12.1" ] }, "gem": { "description": "RubyGems", "default_registry": "https://rubygems.org", "namespace_requirement": "prohibited", "examples": [ "pkg:gem/ruby-advisory-db-check@0.12.4", "pkg:gem/rails@7.0.4", "pkg:gem/bundler@2.3.26" ], "registry_config": { "base_url": "https://rubygems.org/gems", "reverse_regex": "^https://rubygems\\.org/gems/([^/?#]+)(?:/versions/([^/?#]+))?", "uri_template": "https://rubygems.org/gems/{name}", "uri_template_with_version": "https://rubygems.org/gems/{name}/versions/{version}", "components": { "namespace": false, "version_in_url": true, "version_path": "/versions/" } } }, "generic": { "description": "The generic type is for plain, generic packages that do not fit anywhere else such as for \"upstream-from-distro\" packages. In particular this is handy for a plain version control repository such as a bare git repo in combination with a vcs_url.", "default_registry": null, "examples": [ "pkg:generic/openssl@1.1.10g", "pkg:generic/openssl@1.1.10g?download_url=https://openssl.org/source/openssl-1.1.0g.tar.gz&checksum=sha256:de4d501267da", "pkg:generic/bitwarderl?vcs_url=git%2Bhttps://git.fsfe.org/dxtr/bitwarderl%40cc55108da32" ] }, "github": { "description": "GitHub-based packages", "default_registry": "https://github.com", "examples": [ "pkg:github/torvalds/linux@6.1", "pkg:github/microsoft/vscode@1.74.2", "pkg:github/npm/cli@9.2.0" ] }, "golang": { "description": "Go packages", "default_registry": "https://pkg.go.dev", "ecosystems_registry": "proxy.golang.org", "examples": [ "pkg:golang/github.com/gorilla/context@234fd47e07d1004f0aed9c", "pkg:golang/google.golang.org/genproto#googleapis/api/annotations", "pkg:golang/github.com/gorilla/context@234fd47e07d1004f0aed9c#api" ], "registry_config": { "base_url": "https://pkg.go.dev", "reverse_regex": "^https://pkg\\.go\\.dev/(.+)", "uri_template": "https://pkg.go.dev/{namespace}/{name}", "components": { "namespace": true, "namespace_required": true, "version_in_url": false, "special_handling": "golang_import_path" } } }, "hackage": { "description": "Haskell packages", "default_registry": "https://hackage.haskell.org", "examples": [ "pkg:hackage/aeson@2.1.1.0", "pkg:hackage/lens@5.2", "pkg:hackage/mtl@2.2.2" ], "registry_config": { "base_url": "https://hackage.haskell.org/package", "reverse_regex": "^https://hackage\\.haskell\\.org/package/([^/?#-]+)(?:-([^/?#]+))?", "uri_template": "https://hackage.haskell.org/package/{name}", "uri_template_with_version": "https://hackage.haskell.org/package/{name}-{version}", "components": { "namespace": false, "version_in_url": true, "version_separator": "-" } } }, "hex": { "description": "Hex packages", "default_registry": "https://repo.hex.pm", "examples": [ "pkg:hex/phoenix@1.6.15", "pkg:hex/ecto@3.9.4", "pkg:hex/plug@1.14.0" ], "registry_config": { "base_url": "https://hex.pm/packages", "reverse_regex": "^https://hex\\.pm/packages/([^/?#]+)", "uri_template": "https://hex.pm/packages/{name}", "components": { "namespace": false, "version_in_url": false } } }, "huggingface": { "description": "Hugging Face ML models", "default_registry": "https://huggingface.co", "examples": [ "pkg:huggingface/huggingface/distilbert-base-uncased@043235d6088ecd3dd5fb5ca3592b6913fd516027", "pkg:huggingface/microsoft/deberta-v3-base@559062ad13d311b87b2c455e67dcd5f1c8f65111?repository_url=https://hub-ci.huggingface.co" ], "registry_config": { "base_url": "https://huggingface.co", "reverse_regex": "^https://huggingface\\.co/(?:([^/?#]+)/)?([^/?#]+)", "uri_template": "https://huggingface.co/{namespace}/{name}", "uri_template_no_namespace": "https://huggingface.co/{name}", "components": { "namespace": true, "namespace_required": false, "version_in_url": false } } }, "luarocks": { "description": "Lua packages installed with LuaRocks", "default_registry": "https://luarocks.org", "examples": [ "pkg:luarocks/luasocket@3.1.0-1", "pkg:luarocks/hisham/luafilesystem@1.8.0-1", "pkg:luarocks/username/packagename@0.1.0-1?repository_url=https://example.com/private_rocks_server/" ], "registry_config": { "base_url": "https://luarocks.org/modules", "reverse_regex": "^https://luarocks\\.org/modules/(?:([^/?#]+)/)?([^/?#]+)", "uri_template": "https://luarocks.org/modules/{namespace}/{name}", "uri_template_no_namespace": "https://luarocks.org/modules/{name}", "components": { "namespace": true, "namespace_required": false, "version_in_url": false } } }, "maven": { "description": "PURL type for Maven JARs and related artifacts.", "default_registry": "https://repo.maven.apache.org/maven2", "ecosystems_registry": "repo1.maven.org", "namespace_requirement": "required", "examples": [ "pkg:maven/org.apache.commons/commons-lang3@3.12.0", "pkg:maven/junit/junit@4.13.2", "pkg:maven/org.springframework/spring-core@5.3.23" ], "registry_config": { "base_url": "https://mvnrepository.com/artifact", "reverse_regex": "^https://mvnrepository\\.com/artifact/([^/?#]+)/([^/?#]+)(?:/([^/?#]+))?", "uri_template": "https://mvnrepository.com/artifact/{namespace}/{name}", "uri_template_with_version": "https://mvnrepository.com/artifact/{namespace}/{name}/{version}", "components": { "namespace": true, "namespace_required": true, "version_in_url": true, "version_path": "/" } } }, "mlflow": { "description": "MLflow ML models (Azure ML, Databricks, etc.)", "default_registry": null, "examples": [ "pkg:mlflow/creditfraud@3?repository_url=https://westus2.api.azureml.ms/mlflow/v1.0/subscriptions/a50f2011-fab8-4164-af23-c62881ef8c95/resourceGroups/TestResourceGroup/providers/Microsoft.MachineLearningServices/workspaces/TestWorkspace", "pkg:mlflow/trafficsigns@10?model_uuid=36233173b22f4c89b451f1228d700d49&run_id=410a3121-2709-4f88-98dd-dba0ef056b0a&repository_url=https://adb-5245952564735461.0.azuredatabricks.net/api/2.0/mlflow" ] }, "npm": { "description": "PURL type for npm packages.", "default_registry": "https://registry.npmjs.org", "ecosystems_registry": "npmjs.org", "namespace_requirement": "optional", "examples": [ "pkg:npm/@babel/core@7.20.0", "pkg:npm/lodash@4.17.21", "pkg:npm/react@18.2.0" ], "registry_config": { "base_url": "https://www.npmjs.com/package", "reverse_regex": "^https://(?:www\\.)?npmjs\\.com/package/(?:(@[^/]+)/)?([^/?#]+)(?:/v/([^/?#]+))?", "uri_template": "https://www.npmjs.com/package/{namespace}/{name}", "uri_template_no_namespace": "https://www.npmjs.com/package/{name}", "uri_template_with_version": "https://www.npmjs.com/package/{namespace}/{name}/v/{version}", "uri_template_with_version_no_namespace": "https://www.npmjs.com/package/{name}/v/{version}", "components": { "namespace": true, "namespace_required": false, "namespace_prefix": "@", "version_in_url": true, "version_path": "/v/" } } }, "nuget": { "description": "NuGet .NET packages", "default_registry": "https://www.nuget.org", "examples": [ "pkg:nuget/Newtonsoft.Json@13.0.1", "pkg:nuget/EntityFramework@6.4.4", "pkg:nuget/Microsoft.AspNetCore@6.0.13" ], "registry_config": { "base_url": "https://www.nuget.org/packages", "reverse_regex": "^https://(?:www\\.)?nuget\\.org/packages/([^/?#]+)(?:/([^/?#]+))?", "uri_template": "https://www.nuget.org/packages/{name}", "uri_template_with_version": "https://www.nuget.org/packages/{name}/{version}", "components": { "namespace": false, "version_in_url": true, "version_path": "/" } } }, "oci": { "description": "For artifacts stored in registries that conform to the OCI Distribution Specification https://github.com/opencontainers/distribution-spec including container images built by Docker and others", "default_registry": null, "examples": [ "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=docker.io/library/debian&arch=amd64&tag=latest", "pkg:oci/debian@sha256%3A244fd47e07d10?repository_url=ghcr.io/debian&tag=bullseye", "pkg:oci/static@sha256%3A244fd47e07d10?repository_url=gcr.io/distroless/static&tag=latest", "pkg:oci/hello-wasm@sha256%3A244fd47e07d10?tag=v1" ] }, "pub": { "description": "Dart and Flutter pub packages", "default_registry": "https://pub.dartlang.org", "examples": [ "pkg:pub/http@0.13.5", "pkg:pub/flutter@3.3.10", "pkg:pub/provider@6.0.5" ], "registry_config": { "base_url": "https://pub.dev/packages", "reverse_regex": "^https://pub\\.dev/packages/([^/?#]+)", "uri_template": "https://pub.dev/packages/{name}", "components": { "namespace": false, "version_in_url": false } } }, "pypi": { "description": "Python packages", "default_registry": "https://pypi.org", "examples": [ "pkg:pypi/django@4.1.4", "pkg:pypi/requests@2.28.1", "pkg:pypi/numpy@1.24.1" ], "registry_config": { "base_url": "https://pypi.org/project", "reverse_regex": "^https://pypi\\.org/project/([^/?#]+)/?(?:([^/?#]+)/?)?", "uri_template": "https://pypi.org/project/{name}/", "uri_template_with_version": "https://pypi.org/project/{name}/{version}/", "components": { "namespace": false, "version_in_url": true, "version_path": "/", "trailing_slash": true } } }, "qpkg": { "description": "QNX packages", "default_registry": null, "examples": [ "pkg:qpkg/blackberry/com.qnx.sdp@7.0.0.SGA201702151847", "pkg:qpkg/blackberry/com.qnx.qnx710.foo.bar.qux@0.0.4.01449T202205040833L" ] }, "rpm": { "description": "RPM packages", "default_registry": null, "examples": [ "pkg:rpm/fedora/curl@7.50.3-1.fc25?arch=i386&distro=fedora-25", "pkg:rpm/fedora/centerim@4.22.10-1.el6?arch=i686&epoch=1&distro=fedora-25" ] }, "swid": { "description": "PURL type for ISO-IEC 19770-2 Software Identification (SWID) tags.", "default_registry": null, "examples": [ "pkg:swid/Acme/example.com/Enterprise+Server@1.0.0?tag_id=75b8c285-fa7b-485b-b199-4745e3004d0d", "pkg:swid/Fedora@29?tag_id=org.fedoraproject.Fedora-29", "pkg:swid/Adobe+Systems+Incorporated/Adobe+InDesign@CC?tag_id=CreativeCloud-CS6-Win-GM-MUL" ] }, "swift": { "description": "Swift packages", "default_registry": "https://swiftpackageindex.com", "namespace_requirement": "required", "examples": [ "pkg:swift/github.com/Alamofire/Alamofire@5.6.4", "pkg:swift/github.com/apple/swift-package-manager@1.7.0" ], "registry_config": { "base_url": "https://swiftpackageindex.com", "reverse_regex": "^https://swiftpackageindex\\.com/([^/?#]+)/([^/?#]+)", "uri_template": "https://swiftpackageindex.com/{namespace}/{name}", "components": { "namespace": true, "namespace_required": true, "version_in_url": false } } }, "clojars": { "description": "Clojars packages", "default_registry": "https://clojars.org", "examples": [ "pkg:clojars/org.clojure/clojure@1.11.1", "pkg:clojars/ring/ring-core@1.9.5" ], "registry_config": { "base_url": "https://clojars.org", "reverse_regex": "^https://clojars\\.org/(?:([^/?#]+)/)?([^/?#]+)", "uri_template": "https://clojars.org/{namespace}/{name}", "uri_template_no_namespace": "https://clojars.org/{name}", "components": { "namespace": true, "namespace_required": false, "version_in_url": false } } }, "elm": { "description": "Elm packages", "default_registry": "https://package.elm-lang.org", "examples": [ "pkg:elm/elm/http@2.0.0", "pkg:elm/elm-community/json-extra@4.3.0" ], "registry_config": { "base_url": "https://package.elm-lang.org/packages", "reverse_regex": "^https://package\\.elm-lang\\.org/packages/([^/?#]+)/([^/?#]+)(?:/([^/?#]+))?", "uri_template": "https://package.elm-lang.org/packages/{namespace}/{name}/latest", "uri_template_with_version": "https://package.elm-lang.org/packages/{namespace}/{name}/{version}", "components": { "namespace": true, "namespace_required": true, "version_in_url": true, "default_version": "latest" } } }, "deno": { "description": "Deno packages", "default_registry": "https://deno.land", "examples": [ "pkg:deno/oak@12.0.0", "pkg:deno/std@0.177.0#http/server" ], "registry_config": { "base_url": "https://deno.land/x", "reverse_regex": "^https://deno\\.land/x/([^/?#@]+)(?:@([^/?#]+))?", "uri_template": "https://deno.land/x/{name}", "uri_template_with_version": "https://deno.land/x/{name}@{version}", "components": { "namespace": false, "version_in_url": true, "version_prefix": "@" } } }, "homebrew": { "description": "Homebrew packages", "default_registry": "https://formulae.brew.sh", "examples": [ "pkg:homebrew/wget@1.21.3", "pkg:homebrew/node@19.6.0" ], "registry_config": { "base_url": "https://formulae.brew.sh/formula", "reverse_regex": "^https://formulae\\.brew\\.sh/formula/([^/?#]+)", "uri_template": "https://formulae.brew.sh/formula/{name}", "components": { "namespace": false, "version_in_url": false } } }, "bioconductor": { "description": "Bioconductor packages", "default_registry": "https://bioconductor.org", "examples": [ "pkg:bioconductor/IRanges@2.28.0", "pkg:bioconductor/GenomicRanges@1.46.1" ], "registry_config": { "base_url": "https://bioconductor.org/packages", "reverse_regex": "^https://bioconductor\\.org/packages/([^/?#]+)", "uri_template": "https://bioconductor.org/packages/{name}", "components": { "namespace": false, "version_in_url": false } } } } }golang-github-git-pkgs-purl-0.1.10/types_test.go000066400000000000000000000071421517221047600215660ustar00rootroot00000000000000package purl import ( "testing" ) func TestTypeInfo(t *testing.T) { tests := []struct { purlType string wantDescription string wantRegistry string wantNil bool }{ {"npm", "PURL type for npm packages.", "https://registry.npmjs.org", false}, {"pypi", "Python packages", "https://pypi.org", false}, {"cargo", "Cargo packages for Rust", "https://crates.io", false}, {"unknown-type", "", "", true}, } for _, tt := range tests { t.Run(tt.purlType, func(t *testing.T) { cfg := TypeInfo(tt.purlType) switch { case tt.wantNil: if cfg != nil { t.Errorf("TypeInfo(%q) = %v, want nil", tt.purlType, cfg) } case cfg == nil: t.Errorf("TypeInfo(%q) = nil, want non-nil", tt.purlType) default: if cfg.Description != tt.wantDescription { t.Errorf("Description = %q, want %q", cfg.Description, tt.wantDescription) } if cfg.DefaultRegistry == nil { if tt.wantRegistry != "" { t.Errorf("DefaultRegistry = nil, want %q", tt.wantRegistry) } } else if *cfg.DefaultRegistry != tt.wantRegistry { t.Errorf("DefaultRegistry = %q, want %q", *cfg.DefaultRegistry, tt.wantRegistry) } } }) } } func TestKnownTypes(t *testing.T) { types := KnownTypes() if len(types) == 0 { t.Fatal("KnownTypes() returned empty slice") } // Check that some expected types are present expected := []string{"npm", "pypi", "cargo", "gem", "maven", "golang"} for _, e := range expected { found := false for _, typ := range types { if typ == e { found = true break } } if !found { t.Errorf("KnownTypes() missing %q", e) } } // Verify sorted for i := 1; i < len(types); i++ { if types[i-1] > types[i] { t.Errorf("KnownTypes() not sorted: %q > %q", types[i-1], types[i]) break } } } func TestIsKnownType(t *testing.T) { tests := []struct { purlType string want bool }{ {"npm", true}, {"pypi", true}, {"cargo", true}, {"unknown-type", false}, {"", false}, } for _, tt := range tests { t.Run(tt.purlType, func(t *testing.T) { if got := IsKnownType(tt.purlType); got != tt.want { t.Errorf("IsKnownType(%q) = %v, want %v", tt.purlType, got, tt.want) } }) } } func TestDefaultRegistry(t *testing.T) { tests := []struct { purlType string want string }{ {"npm", "https://registry.npmjs.org"}, {"pypi", "https://pypi.org"}, {"cargo", "https://crates.io"}, {"apk", ""}, // no default registry {"deb", ""}, // no default registry {"unknown", ""}, } for _, tt := range tests { t.Run(tt.purlType, func(t *testing.T) { if got := DefaultRegistry(tt.purlType); got != tt.want { t.Errorf("DefaultRegistry(%q) = %q, want %q", tt.purlType, got, tt.want) } }) } } func TestNamespaceRequirement(t *testing.T) { tests := []struct { purlType string wantRequired bool wantProhibited bool }{ {"maven", true, false}, {"composer", true, false}, {"gem", false, true}, {"cran", false, true}, {"npm", false, false}, // optional {"cargo", false, false}, // no requirement } for _, tt := range tests { t.Run(tt.purlType, func(t *testing.T) { cfg := TypeInfo(tt.purlType) if cfg == nil { t.Fatalf("TypeInfo(%q) = nil", tt.purlType) } if got := cfg.NamespaceRequired(); got != tt.wantRequired { t.Errorf("NamespaceRequired() = %v, want %v", got, tt.wantRequired) } if got := cfg.NamespaceProhibited(); got != tt.wantProhibited { t.Errorf("NamespaceProhibited() = %v, want %v", got, tt.wantProhibited) } }) } } func TestTypesVersion(t *testing.T) { v := TypesVersion() if v == "" { t.Error("TypesVersion() returned empty string") } }