diff --git a/ecosystem.go b/ecosystem.go index c6411bc..bc1a1e4 100644 --- a/ecosystem.go +++ b/ecosystem.go @@ -8,6 +8,7 @@ const ( ecosystemAlpine = "alpine" ecosystemArch = "arch" ecosystemComposer = "composer" + ecosystemGit = "git" ecosystemGitHubActions = "github-actions" ecosystemGolang = "golang" ecosystemMaven = "maven" @@ -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" diff --git a/git_test.go b/git_test.go new file mode 100644 index 0000000..ec7377b --- /dev/null +++ b/git_test.go @@ -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) + } +} diff --git a/makepurl.go b/makepurl.go index 2844502..1035dec 100644 --- a/makepurl.go +++ b/makepurl.go @@ -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) @@ -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) @@ -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 { diff --git a/purl.go b/purl.go index 3618cd4..c3d0b3e 100644 --- a/purl.go +++ b/purl.go @@ -6,7 +6,9 @@ package purl import ( + "errors" "sort" + "strings" packageurl "github.com/package-url/packageurl-go" ) @@ -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 } @@ -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. diff --git a/types.json b/types.json index bfe4af8..85f9560 100644 --- a/types.json +++ b/types.json @@ -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", @@ -580,4 +590,4 @@ } } } -} \ No newline at end of file +}