From 0a7d599c1a04075fc2ed5fae2dd5be2b55b2861b Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Fri, 23 Oct 2026 19:26:33 +0200 Subject: [PATCH] Add ComparisonForm for per-ecosystem package identity Parse canonicalises only what packageurl-go does, so spellings such as pkg:pypi/zope.interface and pkg:pypi/zope_interface, or mixed-case NuGet ids, still compare unequal. ComparisonForm applies the remaining ecosystem rules (PEP 503, NuGet case, Composer's leading v, conda channel, OCI repository paths, Swift registry identity case) without changing what Parse or String return. --- README.md | 17 ++++++ comparison.go | 93 +++++++++++++++++++++++++++++++++ comparison_test.go | 125 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 comparison.go create mode 100644 comparison_test.go diff --git a/README.md b/README.md index 4110954..5e3a742 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/comparison.go b/comparison.go new file mode 100644 index 0000000..0605de7 --- /dev/null +++ b/comparison.go @@ -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' +} diff --git a/comparison_test.go b/comparison_test.go new file mode 100644 index 0000000..bf75185 --- /dev/null +++ b/comparison_test.go @@ -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) + } +}