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) + } +}