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
2 changes: 2 additions & 0 deletions ecosystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (
ecosystemAlpine = "alpine"
ecosystemArch = "arch"
ecosystemComposer = "composer"
ecosystemGit = "git"
ecosystemGitHubActions = "github-actions"
ecosystemGolang = "golang"
ecosystemMaven = "maven"
Expand Down Expand Up @@ -143,6 +144,7 @@ func PURLTypeToDepsdev(purlType string) string {
// - 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"
// - git: codeberg.org/forgejo/forgejo -> namespace="codeberg.org", name="forgejo/forgejo"
// - composer: vendor/package -> namespace="vendor", name="package"
// - alpine: pkg -> namespace="alpine", name="pkg"
// - arch: pkg -> namespace="arch", name="pkg"
Expand Down
114 changes: 114 additions & 0 deletions git_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package purl_test

import (
"testing"

"github.com/git-pkgs/purl"
)

func TestGitPURLs(t *testing.T) {
tests := []struct {
host, repository, ref, want string
}{
{"codeberg.org", "forgejo/forgejo", "a72d2c07cfca03b55371089de6aa230d8c951fa0", "pkg:git/codeberg.org/forgejo/forgejo@a72d2c07cfca03b55371089de6aa230d8c951fa0"},
{"gitlab.gnome.org", "GNOME/adwaita-fonts", "1.0", "pkg:git/gitlab.gnome.org/GNOME/adwaita-fonts@1.0"},
{"Git.Example.org", "Group/Subgroup/Repo.git", "", "pkg:git/Git.Example.org/Group/Subgroup/Repo.git"},
{"cygwin.com", "cgit/newlib-cygwin", "main", "pkg:git/cygwin.com/cgit/newlib-cygwin@main"},
{"git.example.org", "repo", "refs/heads/main", "pkg:git/git.example.org/repo@refs%2Fheads%2Fmain"},
{"git.example.org", "team/repo+tools", "release/1.0+build", "pkg:git/git.example.org/team/repo%2Btools@release%2F1.0%2Bbuild"},
{"git.example.org", "team/repo(!)", "release-1.0", "pkg:git/git.example.org/team/repo%28%21%29@release-1.0"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
coordinate := tt.host + "/" + tt.repository
made := purl.MakePURL("GIT", coordinate, tt.ref)
if made == nil {
t.Fatal("MakePURL returned nil")
}
parsed, err := purl.Parse(tt.want)
if err != nil {
t.Fatal(err)
}
for _, p := range []*purl.PURL{made, parsed, purl.New("git", tt.host, tt.repository, tt.ref, nil)} {
if p.Namespace != tt.host || p.Name != tt.repository || p.Version != tt.ref {
t.Errorf("components = %#v", p.PackageURL)
}
if p.String() != tt.want || p.ToString() != tt.want {
t.Errorf("String() = %q, ToString() = %q, want %q", p.String(), p.ToString(), tt.want)
}
if p.FullName() != coordinate {
t.Errorf("FullName() = %q, want %q", p.FullName(), coordinate)
}
}
if got := purl.MakePURLString("GIT", coordinate, tt.ref); got != tt.want {
t.Errorf("MakePURLString() = %q, want %q", got, tt.want)
}
if got := purl.BuildPURLString("GIT", coordinate, tt.ref, ""); got != tt.want {
t.Errorf("BuildPURLString() = %q, want %q", got, tt.want)
}
})
}
}

func TestGitQualifiersAndSubpath(t *testing.T) {
const base = "pkg:git/codeberg.org/forgejo/forgejo"
const qualifiers = "?repository_url=https:%2F%2Fgit.example.org"
const subpath = "#options/locale%20readme.md"
p, err := purl.Parse(base + "@main" + qualifiers + subpath)
if err != nil {
t.Fatal(err)
}
if p.Subpath != "options/locale readme.md" || p.RepositoryURL() != "https://git.example.org" {
t.Fatalf("components = %#v", p.PackageURL)
}
for _, tt := range []struct {
p *purl.PURL
want string
}{
{p, base + "@main" + qualifiers + subpath},
{p.WithVersion("release/v1"), base + "@release%2Fv1" + qualifiers + subpath},
{p.WithoutVersion(), base + qualifiers + subpath},
{p.WithoutVersion().WithQualifier("repository_url", "https://mirror.example.org"), base + "?repository_url=https:%2F%2Fmirror.example.org" + subpath},
} {
if got := tt.p.String(); got != tt.want {
t.Errorf("String() = %q, want %q", got, tt.want)
}
}
if got := purl.BuildPURLString("git", "codeberg.org/forgejo/forgejo", "main", "https://git.example.org"); got != base+"@main"+qualifiers {
t.Errorf("BuildPURLString() = %q", got)
}
if p.Version != "main" || p.RepositoryURL() != "https://git.example.org" || p.Namespace != "codeberg.org" || p.Name != "forgejo/forgejo" {
t.Errorf("original was mutated: %#v", p.PackageURL)
}
}

func TestGitRequiresHostAndRepository(t *testing.T) {
for _, name := range []string{"", "codeberg.org", "/forgejo", "codeberg.org/", "codeberg.org//forgejo", "codeberg.org/forgejo/"} {
t.Run(name, func(t *testing.T) {
if got := purl.MakePURL("git", name, "main"); got != nil {
t.Errorf("MakePURL() = %q, want nil", got.String())
}
if got := purl.MakePURLString("git", name, "main"); got != "" {
t.Errorf("MakePURLString() = %q, want empty", got)
}
if got := purl.BuildPURLString("git", name, "main", ""); got != "" {
t.Errorf("BuildPURLString() = %q, want empty", got)
}
})
}
for _, input := range []string{"pkg:git/repo", "pkg:git/codeberg.org/", "pkg:git/codeberg.org/repo%ZZ"} {
if _, err := purl.Parse(input); err == nil {
t.Errorf("Parse(%q) succeeded, want error", input)
}
}
}

func TestGitType(t *testing.T) {
if !purl.IsKnownType("git") || !purl.IsValidEcosystem("GIT") {
t.Fatal("git is not recognized")
}
cfg := purl.TypeInfo("git")
if cfg == nil || !cfg.NamespaceRequired() || cfg.DefaultRegistry != nil || purl.DefaultRegistry("git") != "" {
t.Fatalf("unexpected git configuration: %#v", cfg)
}
}
18 changes: 14 additions & 4 deletions makepurl.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,8 @@ func hasConstraintSyntax(version, scheme string) bool {
return false
}

// 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. It returns an empty string when
// the package identifier cannot be represented as a PURL.
// BuildPURLString builds a PURL string from ecosystem-native identifiers. It
// returns an empty string when the identifier cannot be represented as a PURL.
func BuildPURLString(ecosystem, name, version, registryURL string) string {
purlType := EcosystemToPURLType(ecosystem)
namespace, pkgName, ok := splitNamespace(ecosystem, name)
Expand Down Expand Up @@ -108,6 +106,13 @@ func suppressRepositoryURL(purlType string) bool {
}

func buildPURLString(purlType, namespace, name, version, registryURL string) string {
if purlType == ecosystemGit {
p := PURL{packageurl.PackageURL{Type: purlType, Namespace: namespace, Name: name, Version: version}}
if registryURL != "" {
p.Qualifiers = packageurl.Qualifiers{{Key: "repository_url", Value: registryURL}}
}
return p.String()
}
n := len("pkg:") + len(purlType) + escapedNamespaceLength(namespace) + 1 + escapedComponentLength(name)
if version != "" {
n += 1 + escapedComponentLength(version)
Expand Down Expand Up @@ -192,6 +197,11 @@ func splitNamespace(ecosystem, name string) (namespace, pkgName string, ok bool)
}

switch normalized {
case ecosystemGit:
namespace, pkgName, ok = strings.Cut(name, "/")
if !ok || namespace == "" || pkgName == "" || strings.HasSuffix(pkgName, "/") || strings.Contains(name, "//") {
return "", "", false
}
case ecosystemNPM:
if strings.HasPrefix(name, "@") {
if i := strings.IndexByte(name, '/'); i >= 0 {
Expand Down
26 changes: 25 additions & 1 deletion purl.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
package purl

import (
"errors"
"sort"
"strings"

packageurl "github.com/package-url/packageurl-go"
)
Expand All @@ -22,6 +24,15 @@ func Parse(s string) (*PURL, error) {
if err != nil {
return nil, err
}
if p.Type == ecosystemGit {
if p.Namespace == "" {
return nil, errors.New("git purl requires a host and repository path")
}
if host, path, ok := strings.Cut(p.Namespace, "/"); ok {
p.Namespace = host
p.Name = path + "/" + p.Name
}
}
return &PURL{p}, nil
}

Expand Down Expand Up @@ -51,7 +62,20 @@ func New(purlType, namespace, name, version string, qualifiers map[string]string

// String returns the PURL as a string.
func (p *PURL) String() string {
return p.PackageURL.String()
return p.ToString()
}

// ToString returns the PURL as a string.
func (p *PURL) ToString() string {
components := p.PackageURL
if components.Type == ecosystemGit {
// Serialize repository path segments without escaping their separators.
if i := strings.LastIndexByte(components.Name, '/'); i >= 0 {
components.Namespace += "/" + components.Name[:i]
components.Name = components.Name[i+1:]
}
}
return components.String()
}

// RepositoryURL returns the repository_url qualifier value, if present.
Expand Down
12 changes: 11 additions & 1 deletion types.json
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@
"pkg:generic/bitwarderl?vcs_url=git%2Bhttps://git.fsfe.org/dxtr/bitwarderl%40cc55108da32"
]
},
"git": {
"description": "Git-based source packages",
"default_registry": null,
"namespace_requirement": "required",
"examples": [
"pkg:git/codeberg.org/forgejo/forgejo@a72d2c07cfca03b55371089de6aa230d8c951fa0",
"pkg:git/projects.blender.org/blender/blender.git",
"pkg:git/gitlab.gnome.org/GNOME/adwaita-fonts"
]
},
"github": {
"description": "GitHub-based packages",
"default_registry": "https://github.com",
Expand Down Expand Up @@ -580,4 +590,4 @@
}
}
}
}
}
Loading