From 72b348d4963460aa5fe4f6d0f7bb6b5cd21c8f69 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 20 Oct 2026 09:14:30 +0100 Subject: [PATCH] Normalize vscode-extension PURLs Lowercase the publisher and extension name, which the spec makes case-insensitive, and split VS Code's publisher.name identifiers. --- ecosystem.go | 27 +++++++++++++------------ makepurl.go | 12 +++++++++++ purl.go | 11 ++++++++++ vscode_test.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 92 insertions(+), 13 deletions(-) create mode 100644 vscode_test.go diff --git a/ecosystem.go b/ecosystem.go index bc1a1e4..600805b 100644 --- a/ecosystem.go +++ b/ecosystem.go @@ -5,19 +5,20 @@ import ( ) const ( - ecosystemAlpine = "alpine" - ecosystemArch = "arch" - ecosystemComposer = "composer" - ecosystemGit = "git" - ecosystemGitHubActions = "github-actions" - ecosystemGolang = "golang" - ecosystemMaven = "maven" - ecosystemNPM = "npm" - ecosystemPackagist = "packagist" - ecosystemRubyGems = "rubygems" - ecosystemSwift = "swift" - purlTypeGem = "gem" - purlTypeGitHubActions = "githubactions" + ecosystemAlpine = "alpine" + ecosystemArch = "arch" + ecosystemComposer = "composer" + ecosystemGit = "git" + ecosystemGitHubActions = "github-actions" + ecosystemGolang = "golang" + ecosystemMaven = "maven" + ecosystemNPM = "npm" + ecosystemPackagist = "packagist" + ecosystemRubyGems = "rubygems" + ecosystemSwift = "swift" + purlTypeGem = "gem" + purlTypeGitHubActions = "githubactions" + purlTypeVSCodeExtension = "vscode-extension" ) // purlTypeForEcosystem maps ecosystem names to PURL types. diff --git a/makepurl.go b/makepurl.go index 1035dec..8712d03 100644 --- a/makepurl.go +++ b/makepurl.go @@ -93,6 +93,7 @@ func normalizeComponents(purlType, namespace, name, version, registryURL string) Qualifiers: q, } _ = p.Normalize() + lowercaseCaseInsensitive(&p) return p.Namespace, p.Name, p.Version } @@ -234,6 +235,8 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string, ok bool) pkgName = rest } } + case purlTypeVSCodeExtension: + return splitVSCodeExtension(name) case ecosystemSwift: i := strings.LastIndexByte(name, '/') if i <= 0 || i == len(name)-1 { @@ -249,6 +252,15 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string, ok bool) return } +// splitVSCodeExtension splits VS Code's publisher.name extension identifier. +func splitVSCodeExtension(name string) (publisher, extension string, ok bool) { + publisher, extension, ok = strings.Cut(name, ".") + if !ok || publisher == "" || extension == "" { + return "", "", false + } + return publisher, extension, true +} + // 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) { diff --git a/purl.go b/purl.go index c3d0b3e..3402bb0 100644 --- a/purl.go +++ b/purl.go @@ -33,9 +33,19 @@ func Parse(s string) (*PURL, error) { p.Name = path + "/" + p.Name } } + lowercaseCaseInsensitive(&p) return &PURL{p}, nil } +// lowercaseCaseInsensitive lowercases components the spec declares +// case-insensitive where packageurl-go's Normalize leaves them as given. +func lowercaseCaseInsensitive(p *packageurl.PackageURL) { + if p.Type == purlTypeVSCodeExtension { + p.Namespace = strings.ToLower(p.Namespace) + p.Name = strings.ToLower(p.Name) + } +} + // New creates a new PURL from components. // // The result is normalized with the same per-type rules Parse applies @@ -57,6 +67,7 @@ func New(purlType, namespace, name, version string, qualifiers map[string]string } p := packageurl.NewPackageURL(purlType, namespace, name, version, q, "") _ = p.Normalize() + lowercaseCaseInsensitive(p) return &PURL{*p} } diff --git a/vscode_test.go b/vscode_test.go new file mode 100644 index 0000000..de4bfc7 --- /dev/null +++ b/vscode_test.go @@ -0,0 +1,55 @@ +package purl_test + +import ( + "testing" + + "github.com/git-pkgs/purl" +) + +func TestVSCodeExtensionCase(t *testing.T) { + const want = "pkg:vscode-extension/ms-python/python@2023.25.10292213" + parsed, err := purl.Parse("pkg:vscode-extension/MS-Python/Python@2023.25.10292213") + if err != nil { + t.Fatal(err) + } + for _, got := range []string{ + parsed.String(), + purl.New("vscode-extension", "MS-Python", "Python", "2023.25.10292213", nil).String(), + purl.MakePURL("vscode-extension", "MS-Python.Python", "2023.25.10292213").String(), + purl.MakePURLString("vscode-extension", "MS-Python.Python", "2023.25.10292213"), + purl.BuildPURLString("vscode-extension", "MS-Python.Python", "2023.25.10292213", ""), + } { + if got != want { + t.Errorf("got %q, want %q", got, want) + } + } +} + +func TestVSCodeExtensionOpenVSX(t *testing.T) { + const want = "pkg:vscode-extension/muhammad-sammy/csharp@2.15.30-RC1?platform=linux-x64&repository_url=https:%2F%2Fopen-vsx.org" + parsed, err := purl.Parse("pkg:vscode-extension/Muhammad-Sammy/CSharp@2.15.30-RC1?platform=linux-x64&repository_url=https:%2F%2Fopen-vsx.org") + if err != nil { + t.Fatal(err) + } + if got := parsed.String(); got != want { + t.Errorf("Parse() = %q, want %q", got, want) + } + if !parsed.IsPrivateRegistry() { + t.Error("open-vsx.org should not be the default registry") + } + built := purl.BuildPURLString("vscode-extension", "Muhammad-Sammy.CSharp", "2.15.30", "https://open-vsx.org") + if built != "pkg:vscode-extension/muhammad-sammy/csharp@2.15.30?repository_url=https:%2F%2Fopen-vsx.org" { + t.Errorf("BuildPURLString() = %q", built) + } +} + +func TestVSCodeExtensionRequiresPublisher(t *testing.T) { + for _, name := range []string{"python", ".python", "ms-python.", "ms-python/python"} { + if got := purl.MakePURLString("vscode-extension", name, "1.0.0"); got != "" { + t.Errorf("MakePURLString(%q) = %q, want empty", name, got) + } + if got := purl.MakePURL("vscode-extension", name, "1.0.0"); got != nil { + t.Errorf("MakePURL(%q) = %q, want nil", name, got) + } + } +}