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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,25 @@ fmt.Println(*cfg.DefaultRegistry) // https://registry.npmjs.org
// Clean version constraints
version := purl.CleanVersion("^1.2.3", "npm")
fmt.Println(version) // 1.2.3

// Compare two spellings of the same package
a, _ := purl.Parse("pkg:pypi/zope.interface@6.0")
b, _ := purl.Parse("pkg:pypi/Zope_Interface@6.0")
fmt.Println(a.ComparisonForm().String() == b.ComparisonForm().String()) // true
```

`ComparisonForm` applies ecosystem rules that `Parse` does not, so `String()`
output stays stable for callers that store it:

| Type | Rule |
|------|------|
| pypi | name lowercased, runs of `-`, `_`, `.` become `-` (PEP 503) |
| nuget | name and version lowercased |
| composer | leading `v`/`V` before a digit dropped from the version |
| conda | channel given as namespace or `channel/name` moved to the `channel` qualifier |
| oci | namespace folded into the name (`library/alpine`) |
| swift | registry identities (scope without `.` or `/`) lowercased |

## Type Configuration

Type information is embedded in `types.json`. Type names and namespace requirements are checked against the pinned [purl-spec](https://github.com/package-url/purl-spec) submodule. Default registries and URL templates are maintained locally.
Expand Down
93 changes: 93 additions & 0 deletions comparison.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package purl

import (
"regexp"
"strings"

packageurl "github.com/package-url/packageurl-go"
)

const (
purlTypeConda = "conda"
purlTypeNuGet = "nuget"
purlTypeOCI = "oci"
purlTypePyPI = "pypi"
)

var pypiSeparators = regexp.MustCompile(`[-_.]+`)

// ComparisonForm returns a copy of the PURL in a form where two spellings of
// the same package compare equal, following each ecosystem's own rules:
//
// - pypi: name lowercased with runs of "-", "_" and "." folded to "-" (PEP 503)
// - nuget: name and version lowercased
// - composer: a leading "v" or "V" before a digit dropped from the version
// - conda: a channel given as namespace or as a "channel/name" name moved
// into the channel qualifier
// - oci: a namespace folded into the name, so library/alpine is one name
// - swift: registry identities (a scope without "." or "/") lowercased
//
// Parse and String are left as they are, since callers derive storage keys
// from them; use this form only to decide whether two PURLs name the same
// package or version.
func (p *PURL) ComparisonForm() *PURL {
c := packageurl.PackageURL{
Type: p.Type,
Namespace: p.Namespace,
Name: p.Name,
Version: p.Version,
Qualifiers: append(packageurl.Qualifiers(nil), p.Qualifiers...),
Subpath: p.Subpath,
}
switch c.Type {
case purlTypePyPI:
c.Name = pypiSeparators.ReplaceAllString(strings.ToLower(c.Name), "-")
case purlTypeNuGet:
c.Name, c.Version = strings.ToLower(c.Name), strings.ToLower(c.Version)
case ecosystemComposer:
// Packagist keeps a tag's "v"; Composer treats v6.1.7 and 6.1.7 as one version.
if len(c.Version) > 1 && (c.Version[0] == 'v' || c.Version[0] == 'V') && isDigit(c.Version[1]) {
c.Version = c.Version[1:]
}
case purlTypeConda:
comparisonConda(&c)
case purlTypeOCI:
// The spec gives oci no namespace; a path-style repository is one name.
if c.Namespace != "" {
c.Namespace, c.Name = "", c.Namespace+"/"+c.Name
}
case ecosystemSwift:
// Registry identities are case-insensitive (SE-0292). Source
// coordinates such as github.com/apple are left alone.
if c.Namespace != "" && !strings.ContainsAny(c.Namespace, "./") {
c.Namespace, c.Name = strings.ToLower(c.Namespace), strings.ToLower(c.Name)
}
}
return &PURL{c}
}

// comparisonConda moves the channel into the channel qualifier. A name
// written "channel/name" wins over a namespace, which wins over the qualifier.
func comparisonConda(c *packageurl.PackageURL) {
channel := ""
if c.Namespace != "" {
channel, c.Namespace = c.Namespace, ""
}
if i := strings.LastIndex(c.Name, "/"); i >= 0 {
channel, c.Name = c.Name[:i], c.Name[i+1:]
}
if channel == "" {
return
}
for i, q := range c.Qualifiers {
if q.Key == "channel" {
c.Qualifiers[i].Value = channel
return
}
}
c.Qualifiers = append(c.Qualifiers, packageurl.Qualifier{Key: "channel", Value: channel})
}

func isDigit(b byte) bool {
return b >= '0' && b <= '9'
}
125 changes: 125 additions & 0 deletions comparison_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package purl

import "testing"

func TestComparisonForm(t *testing.T) {
tests := []struct {
input string
want string
}{
// PEP 503: case and runs of -, _ and . are equivalent.
{"pkg:pypi/zope.interface@6.0", "pkg:pypi/zope-interface@6.0"},
{"pkg:pypi/zope_interface@6.0", "pkg:pypi/zope-interface@6.0"},
{"pkg:pypi/Zope-Interface@6.0", "pkg:pypi/zope-interface@6.0"},
{"pkg:pypi/zope-interface@6.0", "pkg:pypi/zope-interface@6.0"},
{"pkg:pypi/Foo._-Bar", "pkg:pypi/foo-bar"},
{"pkg:pypi/Django@4.2RC1", "pkg:pypi/django@4.2RC1"},

// NuGet ids and versions are case-insensitive.
{"pkg:nuget/Newtonsoft.Json@13.0.1", "pkg:nuget/newtonsoft.json@13.0.1"},
{"pkg:nuget/newtonsoft.json@13.0.1", "pkg:nuget/newtonsoft.json@13.0.1"},
{"pkg:nuget/NEWTONSOFT.JSON@13.0.1", "pkg:nuget/newtonsoft.json@13.0.1"},
{"pkg:nuget/Foo.Bar@1.0.0-Beta.1", "pkg:nuget/foo.bar@1.0.0-beta.1"},

// Composer drops a tag's leading v.
{"pkg:composer/symfony/console@v6.1.7", "pkg:composer/symfony/console@6.1.7"},
{"pkg:composer/symfony/console@V6.1.7", "pkg:composer/symfony/console@6.1.7"},
{"pkg:composer/symfony/console@6.1.7", "pkg:composer/symfony/console@6.1.7"},
{"pkg:composer/foo/bar@v", "pkg:composer/foo/bar@v"},
{"pkg:composer/foo/bar@vendor-branch", "pkg:composer/foo/bar@vendor-branch"},
{"pkg:composer/foo/bar@dev-main", "pkg:composer/foo/bar@dev-main"},

// conda: the channel ends up in the qualifier however it was given.
{"pkg:conda/numpy@1.26.0", "pkg:conda/numpy@1.26.0"},
{"pkg:conda/numpy@1.26.0?channel=conda-forge", "pkg:conda/numpy@1.26.0?channel=conda-forge"},
{"pkg:conda/conda-forge/numpy@1.26.0", "pkg:conda/numpy@1.26.0?channel=conda-forge"},
{"pkg:conda/conda-forge%2Fnumpy@1.26.0", "pkg:conda/numpy@1.26.0?channel=conda-forge"},
{"pkg:conda/conda-forge/numpy@1.26.0?channel=main", "pkg:conda/numpy@1.26.0?channel=conda-forge"},
{"pkg:conda/numpy@1.26.0?build=py312&channel=main", "pkg:conda/numpy@1.26.0?build=py312&channel=main"},

// oci: a path-style repository is one name.
{"pkg:oci/library/alpine@3.19", "pkg:oci/library%2Falpine@3.19"},
{"pkg:oci/library%2Falpine@3.19", "pkg:oci/library%2Falpine@3.19"},
{"pkg:oci/alpine@3.19", "pkg:oci/alpine@3.19"},

// swift: registry identities are case-insensitive, source coordinates are not.
{"pkg:swift/Apple/Swift-NIO@2.0.0", "pkg:swift/apple/swift-nio@2.0.0"},
{"pkg:swift/github.com/Apple/Swift-NIO@2.0.0", "pkg:swift/github.com/Apple/Swift-NIO@2.0.0"},

// Types without extra rules are unchanged.
{"pkg:npm/%40babel/core@7.24.0", "pkg:npm/%40babel/core@7.24.0"},
{"pkg:gem/Rails@7.0.0", "pkg:gem/Rails@7.0.0"},
{"pkg:maven/org.Apache/Commons@1.0", "pkg:maven/org.Apache/Commons@1.0"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
p, err := Parse(tt.input)
if err != nil {
t.Fatalf("Parse(%q): %v", tt.input, err)
}
if got := p.ComparisonForm().String(); got != tt.want {
t.Errorf("ComparisonForm(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

func TestComparisonFormEquivalentSpellings(t *testing.T) {
groups := [][]string{
{"pkg:pypi/zope.interface@6.0", "pkg:pypi/zope_interface@6.0", "pkg:pypi/Zope-Interface@6.0", "pkg:pypi/ZOPE__INTERFACE@6.0"},
{"pkg:nuget/Newtonsoft.Json@13.0.1-Beta", "pkg:nuget/newtonsoft.json@13.0.1-beta"},
{"pkg:composer/symfony/console@v6.1.7", "pkg:composer/symfony/console@6.1.7"},
{"pkg:conda/conda-forge/numpy@1.26.0", "pkg:conda/numpy@1.26.0?channel=conda-forge", "pkg:conda/conda-forge%2Fnumpy@1.26.0"},
{"pkg:oci/library/alpine@3.19", "pkg:oci/library%2Falpine@3.19"},
{"pkg:swift/Apple/Swift-NIO@2.0.0", "pkg:swift/apple/swift-nio@2.0.0"},
}
for _, group := range groups {
var want string
for i, s := range group {
p, err := Parse(s)
if err != nil {
t.Fatalf("Parse(%q): %v", s, err)
}
got := p.ComparisonForm().String()
if i == 0 {
want = got
} else if got != want {
t.Errorf("ComparisonForm(%q) = %q, want %q (as %q)", s, got, want, group[0])
}
}
}
}

// Parse and String feed storage keys, so ComparisonForm must not change them.
func TestComparisonFormLeavesParseAlone(t *testing.T) {
inputs := map[string]string{
"pkg:pypi/zope.interface@6.0": "pkg:pypi/zope.interface@6.0",
"pkg:pypi/Zope_Interface@6.0": "pkg:pypi/zope-interface@6.0",
"pkg:nuget/Newtonsoft.Json@13.0.1": "pkg:nuget/Newtonsoft.Json@13.0.1",
"pkg:composer/symfony/console@v6.1.7": "pkg:composer/symfony/console@v6.1.7",
"pkg:conda/conda-forge/numpy@1.26.0": "pkg:conda/conda-forge/numpy@1.26.0",
"pkg:oci/library/alpine@3.19": "pkg:oci/library/alpine@3.19",
"pkg:swift/Apple/Swift-NIO@2.0.0": "pkg:swift/Apple/Swift-NIO@2.0.0",
}
for input, want := range inputs {
p, err := Parse(input)
if err != nil {
t.Fatalf("Parse(%q): %v", input, err)
}
_ = p.ComparisonForm()
if got := p.String(); got != want {
t.Errorf("Parse(%q).String() = %q after ComparisonForm, want %q", input, got, want)
}
}
}

func TestComparisonFormDoesNotShareQualifiers(t *testing.T) {
p, err := Parse("pkg:conda/conda-forge/numpy@1.26.0?channel=main")
if err != nil {
t.Fatal(err)
}
_ = p.ComparisonForm()
if got := p.Qualifier("channel"); got != "main" {
t.Errorf("original channel = %q, want main", got)
}
}
Loading