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
9 changes: 9 additions & 0 deletions internal/core/errors.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
package core

import (
"errors"

"github.com/git-pkgs/registries/client"
)

// ErrNotFound is returned when a package or version is not found.
var ErrNotFound = client.ErrNotFound

// ErrTooManyPages is returned when a paginated listing runs past MaxPages.
var ErrTooManyPages = errors.New("too many pages")

// MaxPages caps paginated listings so a registry that never reports a last
// page cannot keep the caller fetching.
const MaxPages = 1000

// Type aliases for backward compatibility.
type (
HTTPError = client.HTTPError
Expand Down
3 changes: 3 additions & 0 deletions internal/githubactions/githubactions.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Versi

var versions []core.Version
for page := 1; ; page++ {
if page > core.MaxPages {
return nil, fmt.Errorf("%w: %s tags", core.ErrTooManyPages, name)
}
endpoint := fmt.Sprintf("%s/tags?per_page=%d&page=%d", r.repoEndpoint(a), tagsPerPage, page)
var tags []tagResponse
if err := r.getJSON(ctx, endpoint, &tags); err != nil {
Expand Down
21 changes: 21 additions & 0 deletions internal/githubactions/githubactions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,3 +337,24 @@ func TestEcosystem(t *testing.T) {
t.Errorf("Ecosystem() = %q", got)
}
}

func TestFetchVersionsStopsAtPageCap(t *testing.T) {
var requests int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
tags := make([]map[string]any, tagsPerPage)
for i := range tags {
tags[i] = map[string]any{"name": fmt.Sprintf("v%d.%d", requests, i)}
}
_ = json.NewEncoder(w).Encode(tags)
}))
defer server.Close()

_, err := New(server.URL, core.DefaultClient()).FetchVersions(context.Background(), "actions/checkout")
if !errors.Is(err, core.ErrTooManyPages) {
t.Errorf("err = %v, want ErrTooManyPages", err)
}
if requests != core.MaxPages {
t.Errorf("made %d requests, want %d", requests, core.MaxPages)
}
}
5 changes: 4 additions & 1 deletion internal/golang/golang.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,10 @@ func (r *Registry) fetchVersionsPkgsite(ctx context.Context, name string) ([]cor
var versions []core.Version
token := ""

for {
for pages := 0; ; pages++ {
if pages == core.MaxPages {
return nil, fmt.Errorf("%w: %s versions", core.ErrTooManyPages, name)
}
q := url.Values{"limit": {fmt.Sprint(pkgsitePageLimit)}}
if token != "" {
q.Set("token", token)
Expand Down
39 changes: 39 additions & 0 deletions internal/golang/golang_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package golang
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -429,3 +430,41 @@ func TestDeriveRepoURL(t *testing.T) {
})
}
}

func TestFetchVersionsPkgsiteStopsAtPageCap(t *testing.T) {
var requests int
pkgsite := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
_ = json.NewEncoder(w).Encode(pkgsiteVersions{
Items: []pkgsiteVersion{{Version: fmt.Sprintf("v0.0.%d", requests)}},
NextPageToken: fmt.Sprint(requests),
})
}))
defer pkgsite.Close()

proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/example.com/mod/@v/list":
_, _ = w.Write([]byte("v1.0.0\n"))
case "/example.com/mod/@v/v1.0.0.info":
_ = json.NewEncoder(w).Encode(versionInfo{Version: "v1.0.0"})
default:
w.WriteHeader(404)
}
}))
defer proxy.Close()

reg := New(proxy.URL, core.DefaultClient())
reg.pkgsiteURL = pkgsite.URL

versions, err := reg.FetchVersions(context.Background(), "example.com/mod")
if err != nil {
t.Fatalf("FetchVersions failed: %v", err)
}
if requests != core.MaxPages {
t.Errorf("made %d pkgsite requests, want %d", requests, core.MaxPages)
}
if len(versions) != 1 || versions[0].Number != "v1.0.0" {
t.Errorf("expected proxy fallback v1.0.0, got %+v", versions)
}
}
41 changes: 37 additions & 4 deletions internal/nuget/nuget.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ package nuget
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"

Expand All @@ -12,8 +14,9 @@ import (
)

const (
DefaultURL = "https://api.nuget.org/v3"
ecosystem = "nuget"
DefaultURL = "https://api.nuget.org/v3"
ecosystem = "nuget"
maxCatalogRedirects = 10
)

func init() {
Expand Down Expand Up @@ -213,12 +216,33 @@ func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Versi
// fetchIntegrity fetches the catalog leaf to extract the package hash.
// The registration index does not include packageHash, only the full
// catalog leaf does. Errors are swallowed since integrity is best-effort.
// The leaf URL comes from the registry response, so only follow it on the
// registry's own origin.
func (r *Registry) fetchIntegrity(ctx context.Context, catalogURL string) string {
if catalogURL == "" {
if catalogURL == "" || !sameOrigin(r.baseURL, catalogURL) {
return ""
}
// Scope the redirect restriction to this fetch without changing the shared client.
catalogClient := *r.client
// Integrity is best-effort; avoid backoff for rejected catalog redirects.
catalogClient.MaxRetries = 0
httpClient := *r.client.HTTPClient
checkRedirect := httpClient.CheckRedirect
httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if !sameOrigin(r.baseURL, req.URL.String()) {
return fmt.Errorf("nuget catalog redirect leaves registry origin")
}
if len(via) >= maxCatalogRedirects {
return fmt.Errorf("nuget catalog stopped after %d redirects", maxCatalogRedirects)
}
if checkRedirect != nil {
return checkRedirect(req, via)
}
return nil
}
catalogClient.HTTPClient = &httpClient
var leaf catalogLeaf
if err := r.client.GetJSON(ctx, catalogURL, &leaf); err != nil {
if err := catalogClient.GetJSON(ctx, catalogURL, &leaf); err != nil {
return ""
}
if leaf.PackageHash == "" {
Expand All @@ -231,6 +255,15 @@ func (r *Registry) fetchIntegrity(ctx context.Context, catalogURL string) string
return algo + "-" + leaf.PackageHash
}

func sameOrigin(a, b string) bool {
first, err := url.Parse(a)
if err != nil {
return false
}
second, err := url.Parse(b)
return err == nil && first.Scheme == second.Scheme && strings.EqualFold(first.Host, second.Host)
}

func (r *Registry) FetchDependencies(ctx context.Context, name, version string) ([]core.Dependency, error) {
lowerName := strings.ToLower(name)
url := fmt.Sprintf("%s/registration5-semver1/%s/index.json", r.baseURL, lowerName)
Expand Down
34 changes: 34 additions & 0 deletions internal/nuget/nuget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,37 @@ func TestEcosystem(t *testing.T) {
t.Errorf("expected ecosystem 'nuget', got %q", reg.Ecosystem())
}
}

func TestFetchVersionsSkipsCatalogURLOnOtherOrigin(t *testing.T) {
var otherHits int
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
otherHits++
_ = json.NewEncoder(w).Encode(catalogLeaf{PackageHash: "leaked", PackageHashAlgorithm: "SHA512"})
}))
defer other.Close()

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(registrationResponse{
Items: []registrationPage{{Items: []registrationLeaf{{
CatalogEntry: catalogEntry{
CatalogURL: other.URL + "/latest/meta-data/",
ID: "xunit",
Version: "2.6.0",
Listed: true,
},
}}}},
})
}))
defer server.Close()

versions, err := New(server.URL, core.DefaultClient()).FetchVersions(context.Background(), "xunit")
if err != nil {
t.Fatalf("FetchVersions failed: %v", err)
}
if otherHits != 0 {
t.Errorf("fetched catalog URL on another origin %d times", otherHits)
}
if len(versions) != 1 || versions[0].Integrity != "" {
t.Errorf("versions = %+v, want one version with empty integrity", versions)
}
}
3 changes: 3 additions & 0 deletions internal/swift/swift.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ func (r *Registry) list(ctx context.Context, name string) (*listing, error) {
if seen[page] {
return nil, fmt.Errorf("swift registry pagination loop at %s", page)
}
if len(seen) == core.MaxPages {
return nil, fmt.Errorf("%w: swift registry listing for %s", core.ErrTooManyPages, name)
}
seen[page] = true
var response struct {
Releases map[string]listedRelease `json:"releases"`
Expand Down
91 changes: 42 additions & 49 deletions internal/urlparser/urlparser.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,16 @@ func init() {
}
}

// maxURLLength bounds input from registry metadata; real repository URLs
// are far shorter.
const maxURLLength = 4096

// Clean removes common noise from git URLs: schemes, auth, brackets, anchors, etc.
// Returns just the host/path portion ready for further processing.
// URLs longer than maxURLLength return "".
func Clean(rawURL string) string {
s := strings.TrimSpace(rawURL)
if s == "" {
if s == "" || len(s) > maxURLLength {
return ""
}

Expand Down Expand Up @@ -133,62 +138,49 @@ func trimGitSuffix(s string) string {
return s
}

// removeSchemes strips all scheme prefixes from a URL
func removeSchemes(s string) string {
for {
prev := s

// Remove scm:git:, scm:svn:, scm:hg: prefixes
sLower := strings.ToLower(s)
switch {
case strings.HasPrefix(sLower, "scm:git:"):
s = s[len("scm:git:"):]
case strings.HasPrefix(sLower, "scm:svn:"):
s = s[len("scm:svn:"):]
case strings.HasPrefix(sLower, "scm:hg:"):
s = s[len("scm:hg:"):]
}
var scmPrefixes = []string{"scm:git:", "scm:svn:", "scm:hg:"}

var schemes = []string{
"git+https://", "git+https:",
"git+ssh://", "git+ssh:",
"https://", "https:",
"http://", "http:",
"git://", "git:",
"ssh://", "ssh:",
"svn://", "svn:",
"hg://", "hg:",
"scm://", "scm:",
}

// Remove standard schemes
s = removeScheme(s)
var gitSlashPrefix = []string{"git//"}

// Remove git// pattern
if strings.HasPrefix(strings.ToLower(s), "git//") {
s = s[5:]
// removeSchemes strips all scheme prefixes from a URL. It advances an offset
// rather than re-slicing and lowercasing, so repeated prefixes stay linear.
func removeSchemes(s string) string {
i := 0
for {
start := i
i += prefixLen(s[i:], scmPrefixes)
i += prefixLen(s[i:], schemes)
i += prefixLen(s[i:], gitSlashPrefix)
for i < len(s) && s[i] == '/' {
i++
}

// Remove leading slashes
s = strings.TrimLeft(s, "/")

if s == prev {
break
if i == start {
return s[i:]
}
}
return s
}

// removeScheme removes a single URL scheme prefix
func removeScheme(s string) string {
sLower := strings.ToLower(s)

schemes := []string{
"git+https://", "git+https:",
"git+ssh://", "git+ssh:",
"https://", "https:",
"http://", "http:",
"git://", "git:",
"ssh://", "ssh:",
"svn://", "svn:",
"hg://", "hg:",
"scm://", "scm:",
}

for _, scheme := range schemes {
if strings.HasPrefix(sLower, scheme) {
return s[len(scheme):]
// prefixLen returns the length of the first prefix that s starts with,
// ignoring case, or 0.
func prefixLen(s string, prefixes []string) int {
for _, p := range prefixes {
if len(s) >= len(p) && strings.EqualFold(s[:len(p)], p) {
return len(p)
}
}
return s
return 0
}

// removeAuth removes user:pass@ or user@ from URLs
Expand Down Expand Up @@ -420,9 +412,10 @@ func IsKnownHost(rawURL string) bool {
}

// Normalize cleans a git URL and ensures it has an https scheme.
// URLs longer than maxURLLength return "".
func Normalize(rawURL string) string {
s := strings.TrimSpace(rawURL)
if s == "" {
if s == "" || len(s) > maxURLLength {
return ""
}

Expand Down
Loading
Loading