From 8c5fa11562d5eb7ba50f702cdaa2740f7cd95df5 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 18 Oct 2026 19:19:00 +0100 Subject: [PATCH 1/2] Add a Swift package registry client Reads SE-0292 registries with the registry Accept header, which needed a client method that sets request headers and returns response headers. Claude-Session: https://claude.ai/code/session_01NxsA75HBjdWQKcdx7FDTDc --- README.md | 4 + all/all.go | 1 + client/client.go | 51 +++-- client/client_test.go | 31 +++ fetch/swift_resolver_test.go | 28 +++ internal/swift/swift.go | 433 +++++++++++++++++++++++++++++++++++ internal/swift/swift_test.go | 322 ++++++++++++++++++++++++++ registries_test.go | 33 ++- 8 files changed, 886 insertions(+), 17 deletions(-) create mode 100644 fetch/swift_resolver_test.go create mode 100644 internal/swift/swift.go create mode 100644 internal/swift/swift_test.go diff --git a/README.md b/README.md index 047185d..45eeda8 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ packages = registries.BulkFetchPackagesWithConcurrency(ctx, purls, nil, 5) | Maven | `pkg:maven/org.apache.commons/commons-lang3@3.12.0` | | RubyGems | `pkg:gem/rails@7.1.0` | | Terraform | `pkg:terraform/hashicorp/consul/aws@0.11.0` | +| Swift | `pkg:swift/apple/swift-nio@2.65.0?repository_url=https://packages.example.com` | ## Direct Registry Usage @@ -192,6 +193,9 @@ import _ "github.com/git-pkgs/registries/all" | Homebrew | `brew` | https://formulae.brew.sh | | Deno | `deno` | https://apiland.deno.dev | | Terraform | `terraform` | https://registry.terraform.io | +| Swift | `swift` | URL required | + +Swift package names are a registry scope and name, written `scope/name` (the PURL namespace and name) or `scope.name`. A host-style PURL namespace such as `pkg:swift/github.com/apple/swift-argument-parser` has no defined mapping to a registry scope, so the Swift client rejects it. ## Types diff --git a/all/all.go b/all/all.go index 8e9577a..6e89230 100644 --- a/all/all.go +++ b/all/all.go @@ -38,5 +38,6 @@ import ( _ "github.com/git-pkgs/registries/internal/pub" _ "github.com/git-pkgs/registries/internal/pypi" _ "github.com/git-pkgs/registries/internal/rubygems" + _ "github.com/git-pkgs/registries/internal/swift" _ "github.com/git-pkgs/registries/internal/terraform" ) diff --git a/client/client.go b/client/client.go index 49d3b58..230ff3b 100644 --- a/client/client.go +++ b/client/client.go @@ -61,8 +61,24 @@ func (c *Client) GetJSON(ctx context.Context, url string, v any) error { return json.Unmarshal(body, v) } +// GetJSONWithHeaders fetches a URL with extra request headers, which replace +// the defaults of the same name, decodes the JSON response into v and returns +// the response headers. +func (c *Client) GetJSONWithHeaders(ctx context.Context, url string, header http.Header, v any) (http.Header, error) { + body, respHeader, err := c.getBody(ctx, url, header) + if err != nil { + return nil, err + } + return respHeader, json.Unmarshal(body, v) +} + // GetBody fetches a URL and returns the response body. func (c *Client) GetBody(ctx context.Context, url string) ([]byte, error) { + body, _, err := c.getBody(ctx, url, nil) + return body, err +} + +func (c *Client) getBody(ctx context.Context, url string, header http.Header) ([]byte, http.Header, error) { var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { @@ -73,64 +89,67 @@ func (c *Client) GetBody(ctx context.Context, url string) ([]byte, error) { select { case <-ctx.Done(): - return nil, ctx.Err() + return nil, nil, ctx.Err() case <-time.After(delay): } } if c.RateLimiter != nil { if err := c.RateLimiter.Wait(ctx); err != nil { - return nil, err + return nil, nil, err } } - body, err := c.doRequest(ctx, url) + body, respHeader, err := c.doRequest(ctx, url, header) if err == nil { - return body, nil + return body, respHeader, nil } lastErr = err if errors.Is(err, ErrUpstreamHeld) { - return nil, err + return nil, nil, err } var rateErr *RateLimitError if errors.As(err, &rateErr) { - return nil, err + return nil, nil, err } var httpErr *HTTPError if ok := isHTTPError(err, &httpErr); ok { if httpErr.StatusCode == http.StatusNotFound { - return nil, err + return nil, nil, err } if httpErr.StatusCode == http.StatusTooManyRequests || httpErr.StatusCode >= http.StatusInternalServerError { continue } - return nil, err + return nil, nil, err } } - return nil, lastErr + return nil, nil, lastErr } -func (c *Client) doRequest(ctx context.Context, url string) ([]byte, error) { +func (c *Client) doRequest(ctx context.Context, url string, header http.Header) ([]byte, http.Header, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { - return nil, err + return nil, nil, err } req.Header.Set("User-Agent", c.UserAgent) req.Header.Set("Accept", "application/json") + for name, values := range header { + req.Header[http.CanonicalHeaderKey(name)] = values + } resp, err := c.HTTPClient.Do(req) if err != nil { - return nil, err + return nil, nil, err } defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize)) if err != nil { - return nil, err + return nil, nil, err } if resp.StatusCode >= http.StatusBadRequest { @@ -145,13 +164,13 @@ func (c *Client) doRequest(ctx context.Context, url string) ([]byte, error) { if delay%time.Second != 0 { seconds++ } - return nil, &RateLimitError{RetryAfter: int(seconds)} + return nil, nil, &RateLimitError{RetryAfter: int(seconds)} } } - return nil, httpErr + return nil, nil, httpErr } - return body, nil + return body, resp.Header, nil } func isHTTPError(err error, target **HTTPError) bool { diff --git a/client/client_test.go b/client/client_test.go index 4e203ec..a131be3 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -115,3 +115,34 @@ func TestResponseWithinLimit(t *testing.T) { t.Fatalf("got %q, want %q", string(body), payload) } } + +func TestGetJSONWithHeaders(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Values("Accept"); len(got) != 1 || got[0] != "application/vnd.example+json" { + t.Errorf("Accept = %v, want only the supplied value", got) + } + if r.Header.Get("User-Agent") == "" { + t.Error("default User-Agent was dropped") + } + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`{"name":"test"}`)) + })) + defer srv.Close() + + c := DefaultClient() + c.MaxRetries = 0 + + var result struct { + Name string `json:"name"` + } + header, err := c.GetJSONWithHeaders(context.Background(), srv.URL, http.Header{"accept": {"application/vnd.example+json"}}, &result) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Name != "test" { + t.Errorf("got %+v", result) + } + if header.Get("Link") == "" { + t.Error("response headers not returned") + } +} diff --git a/fetch/swift_resolver_test.go b/fetch/swift_resolver_test.go new file mode 100644 index 0000000..2da1453 --- /dev/null +++ b/fetch/swift_resolver_test.go @@ -0,0 +1,28 @@ +package fetch + +import ( + "context" + "testing" + + "github.com/git-pkgs/registries" + _ "github.com/git-pkgs/registries/internal/swift" +) + +func TestResolveSwift(t *testing.T) { + reg, err := registries.New("swift", "https://packages.example.com", nil) + if err != nil { + t.Fatal(err) + } + r := NewResolver() + r.RegisterRegistry(reg) + + for _, name := range []string{"apple/swift-nio", "apple.swift-nio"} { + info, err := r.Resolve(context.Background(), "swift", name, "2.65.0") + if err != nil { + t.Fatalf("Resolve(%q) failed: %v", name, err) + } + if want := "https://packages.example.com/apple/swift-nio/2.65.0.zip"; info.URL != want { + t.Errorf("URL = %q, want %q", info.URL, want) + } + } +} diff --git a/internal/swift/swift.go b/internal/swift/swift.go new file mode 100644 index 0000000..63888ff --- /dev/null +++ b/internal/swift/swift.go @@ -0,0 +1,433 @@ +// Package swift provides a registry client for Swift package registries +// (SE-0292). +package swift + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/git-pkgs/registries/internal/core" + "github.com/git-pkgs/registries/internal/urlparser" + "github.com/git-pkgs/vers" +) + +const ( + DefaultURL = "" + ecosystem = "swift" + + acceptHeader = "application/vnd.swift.registry.v1+json" + sourceArchive = "source-archive" + maxPages = 50 + maxScopeLen = 39 + maxNameLen = 100 +) + +// ErrInvalidName is returned for names that are not a registry scope and +// package name. +var ErrInvalidName = errors.New("invalid swift package name") + +func init() { + core.Register(ecosystem, DefaultURL, func(baseURL string, client *core.Client) core.Registry { + return New(baseURL, client) + }) +} + +type Registry struct { + baseURL string + client *core.Client + urls *URLs +} + +func New(baseURL string, client *core.Client) *Registry { + if client == nil { + client = core.DefaultClient() + } + r := &Registry{ + baseURL: strings.TrimSuffix(baseURL, "/"), + client: client, + } + r.urls = &URLs{baseURL: r.baseURL} + return r +} + +func (r *Registry) Ecosystem() string { + return ecosystem +} + +func (r *Registry) URLs() core.URLBuilder { //nolint:ireturn + return r.urls +} + +// SplitName returns the registry scope and package name from "scope/name", +// the PURL namespace and name, or "scope.name", the registry identifier. +// A PURL namespace that is a source host such as "github.com/apple" has no +// defined mapping to a scope and is rejected. +func SplitName(name string) (scope, pkg string, err error) { + var ok bool + if strings.Contains(name, "/") { + scope, pkg, ok = strings.Cut(name, "/") + if strings.Contains(pkg, "/") { + return "", "", fmt.Errorf("%w %q: the namespace must be a registry scope, not a source host path", ErrInvalidName, name) + } + } else { + scope, pkg, ok = strings.Cut(name, ".") + } + if !ok || !validIdentifier(scope, maxScopeLen, "-") || !validIdentifier(pkg, maxNameLen, "-_") { + return "", "", fmt.Errorf("%w %q: expected scope/name or scope.name", ErrInvalidName, name) + } + return scope, pkg, nil +} + +// validIdentifier applies the registry rules for scopes and names: +// alphanumerics, with separators only between alphanumerics. +func validIdentifier(s string, maxLen int, separators string) bool { + if s == "" || len(s) > maxLen { + return false + } + prevSeparator := true + for _, c := range s { + switch { + case c < 0x80 && (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9'): + prevSeparator = false + case strings.ContainsRune(separators, c) && !prevSeparator: + prevSeparator = true + default: + return false + } + } + return !prevSeparator +} + +type releasesResponse struct { + Releases map[string]releaseEntry `json:"releases"` +} + +type releaseEntry struct { + URL string `json:"url"` + Problem *problem `json:"problem"` +} + +type problem struct { + Status int `json:"status"` + Title string `json:"title"` + Detail string `json:"detail"` +} + +type releaseResponse struct { + ID string `json:"id"` + Version string `json:"version"` + Resources []resource `json:"resources"` + Metadata releaseMetadata `json:"metadata"` + PublishedAt string `json:"publishedAt"` +} + +type resource struct { + Name string `json:"name"` + Type string `json:"type"` + Checksum string `json:"checksum"` +} + +type releaseMetadata struct { + Author *author `json:"author"` + Description string `json:"description"` + LicenseURL string `json:"licenseURL"` + ReadmeURL string `json:"readmeURL"` + RepositoryURLs []string `json:"repositoryURLs"` + OriginalPublicationTime string `json:"originalPublicationTime"` +} + +type author struct { + Name string `json:"name"` + Email string `json:"email"` + URL string `json:"url"` + Organization *organization `json:"organization"` +} + +type organization struct { + Name string `json:"name"` +} + +func (r *Registry) get(ctx context.Context, rawURL string, v any) (http.Header, error) { + return r.client.GetJSONWithHeaders(ctx, rawURL, http.Header{"Accept": {acceptHeader}}, v) +} + +func isNotFound(err error) bool { + var httpErr *core.HTTPError + return errors.As(err, &httpErr) && (httpErr.StatusCode == http.StatusNotFound || httpErr.StatusCode == http.StatusGone) +} + +// fetchReleases lists every release, following Link rel="next" pages on the +// registry's own host. +func (r *Registry) fetchReleases(ctx context.Context, name string) (map[string]releaseEntry, error) { + scope, pkg, err := SplitName(name) + if err != nil { + return nil, err + } + + releases := make(map[string]releaseEntry) + next := fmt.Sprintf("%s/%s/%s", r.baseURL, scope, pkg) + for page := 0; next != "" && page < maxPages; page++ { + var resp releasesResponse + header, err := r.get(ctx, next, &resp) + if err != nil { + if isNotFound(err) { + return nil, &core.NotFoundError{Ecosystem: ecosystem, Name: name} + } + return nil, err + } + for v, entry := range resp.Releases { + releases[v] = entry + } + next = r.nextPage(next, header) + } + return releases, nil +} + +func (r *Registry) nextPage(current string, header http.Header) string { + base, err := url.Parse(current) + if err != nil { + return "" + } + for _, link := range parseLinks(header.Values("Link")) { + if link.rel != "next" { + continue + } + u, err := base.Parse(link.url) + if err != nil || u.Scheme != base.Scheme || u.Host != base.Host { + return "" + } + return u.String() + } + return "" +} + +type link struct { + url string + rel string +} + +// parseLinks reads RFC 8288 Link header values, keeping the target and rel. +func parseLinks(values []string) []link { + var links []link + for _, value := range values { + for _, part := range strings.Split(value, ",") { + target, params, _ := strings.Cut(strings.TrimSpace(part), ";") + target = strings.TrimSpace(target) + if !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") { + continue + } + l := link{url: target[1 : len(target)-1]} + for _, param := range strings.Split(params, ";") { + key, val, _ := strings.Cut(strings.TrimSpace(param), "=") + if strings.EqualFold(key, "rel") { + l.rel = strings.Trim(val, `"`) + } + } + links = append(links, l) + } + } + return links +} + +func (r *Registry) fetchRelease(ctx context.Context, name, version string) (*releaseResponse, error) { + scope, pkg, err := SplitName(name) + if err != nil { + return nil, err + } + var resp releaseResponse + if _, err := r.get(ctx, fmt.Sprintf("%s/%s/%s/%s", r.baseURL, scope, pkg, url.PathEscape(version)), &resp); err != nil { + if isNotFound(err) { + return nil, &core.NotFoundError{Ecosystem: ecosystem, Name: name, Version: version} + } + return nil, err + } + return &resp, nil +} + +// availableVersions returns releases without a problem, highest first. +func availableVersions(releases map[string]releaseEntry) []string { + versions := make([]string, 0, len(releases)) + for v, entry := range releases { + if entry.Problem == nil { + versions = append(versions, v) + } + } + sortVersions(versions) + return versions +} + +func sortVersions(versions []string) { + sort.Slice(versions, func(i, j int) bool { return vers.Compare(versions[i], versions[j]) > 0 }) +} + +func (r *Registry) latestRelease(ctx context.Context, name string) (*releaseResponse, error) { + releases, err := r.fetchReleases(ctx, name) + if err != nil { + return nil, err + } + versions := availableVersions(releases) + if len(versions) == 0 { + return nil, &core.NotFoundError{Ecosystem: ecosystem, Name: name} + } + return r.fetchRelease(ctx, name, versions[0]) +} + +func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package, error) { + release, err := r.latestRelease(ctx, name) + if err != nil { + return nil, err + } + scope, _, _ := SplitName(name) + + var repository string + for _, u := range release.Metadata.RepositoryURLs { + if repository = urlparser.Parse(u); repository != "" { + break + } + } + + return &core.Package{ + Name: release.ID, + Description: release.Metadata.Description, + Repository: repository, + Namespace: scope, + LatestVersion: release.Version, + Metadata: map[string]any{ + "license_url": release.Metadata.LicenseURL, + "readme_url": release.Metadata.ReadmeURL, + "repository_urls": release.Metadata.RepositoryURLs, + }, + }, nil +} + +// FetchVersions lists releases without fetching each one, so versions carry +// no publication time or checksum; FetchVersion returns those. Releases the +// registry reports a problem for are marked yanked. +func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Version, error) { + releases, err := r.fetchReleases(ctx, name) + if err != nil { + return nil, err + } + + numbers := make([]string, 0, len(releases)) + for v := range releases { + numbers = append(numbers, v) + } + sortVersions(numbers) + + versions := make([]core.Version, 0, len(numbers)) + for _, number := range numbers { + v := core.Version{Number: number} + if p := releases[number].Problem; p != nil { + v.Status = core.StatusYanked + v.Metadata = map[string]any{"problem_status": p.Status, "problem_detail": p.Detail} + } + versions = append(versions, v) + } + return versions, nil +} + +// FetchVersion returns one release with its source archive checksum. +func (r *Registry) FetchVersion(ctx context.Context, name, version string) (*core.Version, error) { + release, err := r.fetchRelease(ctx, name, version) + if err != nil { + return nil, err + } + + published := release.Metadata.OriginalPublicationTime + if published == "" { + published = release.PublishedAt + } + publishedAt, _ := time.Parse(time.RFC3339, published) + + v := &core.Version{ + Number: release.Version, + PublishedAt: publishedAt, + Metadata: map[string]any{ + "license_url": release.Metadata.LicenseURL, + }, + } + for _, res := range release.Resources { + if res.Name != sourceArchive { + continue + } + if res.Checksum != "" { + v.Integrity = "sha256-" + res.Checksum + } + downloadURL := r.urls.Download(name, version) + v.Artifacts = append(v.Artifacts, core.Artifact{ + URL: downloadURL, + Filename: version + ".zip", + Integrity: v.Integrity, + MediaType: res.Type, + }) + } + return v, nil +} + +// FetchDependencies returns nil: dependencies are declared in Package.swift, +// which the registry serves as Swift source rather than structured data. +func (r *Registry) FetchDependencies(ctx context.Context, name, version string) ([]core.Dependency, error) { + return nil, nil +} + +// FetchMaintainers returns the author of the latest release. +func (r *Registry) FetchMaintainers(ctx context.Context, name string) ([]core.Maintainer, error) { + release, err := r.latestRelease(ctx, name) + if err != nil { + return nil, err + } + a := release.Metadata.Author + if a == nil || a.Name == "" { + return nil, nil + } + m := core.Maintainer{Name: a.Name, Email: a.Email, URL: a.URL} + if a.Organization != nil { + m.Role = a.Organization.Name + } + return []core.Maintainer{m}, nil +} + +type URLs struct { + baseURL string +} + +func (u *URLs) Registry(name, version string) string { + scope, pkg, err := SplitName(name) + if err != nil { + return "" + } + if version != "" { + return fmt.Sprintf("%s/%s/%s/%s", u.baseURL, scope, pkg, url.PathEscape(version)) + } + return fmt.Sprintf("%s/%s/%s", u.baseURL, scope, pkg) +} + +func (u *URLs) Download(name, version string) string { + scope, pkg, err := SplitName(name) + if err != nil || version == "" { + return "" + } + return fmt.Sprintf("%s/%s/%s/%s.zip", u.baseURL, scope, pkg, url.PathEscape(version)) +} + +func (u *URLs) Documentation(name, version string) string { + return "" +} + +func (u *URLs) PURL(name, version string) string { + scope, pkg, err := SplitName(name) + if err != nil { + return "" + } + if version != "" { + return fmt.Sprintf("pkg:swift/%s/%s@%s", scope, pkg, version) + } + return fmt.Sprintf("pkg:swift/%s/%s", scope, pkg) +} diff --git a/internal/swift/swift_test.go b/internal/swift/swift_test.go new file mode 100644 index 0000000..1e86d4d --- /dev/null +++ b/internal/swift/swift_test.go @@ -0,0 +1,322 @@ +package swift + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/git-pkgs/registries/internal/core" +) + +const releaseJSON = `{ + "id": "apple.swift-nio", + "version": "2.65.0", + "resources": [ + {"name": "source-archive", "type": "application/zip", + "checksum": "a2ac54cf25fbc1ad0028f03f0aa4b96833b83bb05a14e510892bb27dea4dc812"} + ], + "metadata": { + "author": {"name": "Mona", "email": "mona@example.com", "organization": {"name": "Apple"}}, + "description": "Event-driven network application framework", + "licenseURL": "https://github.com/apple/swift-nio/blob/main/LICENSE.txt", + "repositoryURLs": ["git@github.com:apple/swift-nio.git", "https://github.com/apple/swift-nio.git"] + }, + "publishedAt": "2024-04-02T10:00:00Z" +}` + +func newServer(t *testing.T, handler http.HandlerFunc) *Registry { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != acceptHeader { + t.Errorf("Accept = %q, want %q", got, acceptHeader) + } + handler(w, r) + })) + t.Cleanup(server.Close) + return New(server.URL, core.DefaultClient()) +} + +func TestSplitName(t *testing.T) { + valid := map[string][2]string{ + "apple/swift-nio": {"apple", "swift-nio"}, + "apple.swift-nio": {"apple", "swift-nio"}, + "mona/Linked_List": {"mona", "Linked_List"}, + "a-b/c": {"a-b", "c"}, + "Mona.LinkedList2": {"Mona", "LinkedList2"}, + strings.Repeat("a", 39) + "/x": {strings.Repeat("a", 39), "x"}, + } + for name, want := range valid { + scope, pkg, err := SplitName(name) + if err != nil || scope != want[0] || pkg != want[1] { + t.Errorf("SplitName(%q) = %q, %q, %v; want %q, %q", name, scope, pkg, err, want[0], want[1]) + } + } + + invalid := []string{ + "github.com/apple/swift-argument-parser", + "apple", + "", + "-apple/nio", + "apple-/nio", + "ap--ple/nio", + "apple/_nio", + "apple/ni__o", + "apple/nio-", + "app_le/nio", + "apple/ni.o", + strings.Repeat("a", 40) + "/x", + "apple/" + strings.Repeat("n", 101), + "äpple/nio", + } + for _, name := range invalid { + if _, _, err := SplitName(name); !errors.Is(err, ErrInvalidName) { + t.Errorf("SplitName(%q) err = %v, want ErrInvalidName", name, err) + } + } +} + +func TestFetchVersions(t *testing.T) { + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apple/swift-nio" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"releases": { + "2.9.0": {"url": "https://example.com/apple/swift-nio/2.9.0"}, + "2.65.0": {}, + "2.10.0": {"problem": {"status": 410, "title": "Gone", "detail": "removed"}} + }}`)) + }) + + versions, err := reg.FetchVersions(context.Background(), "apple/swift-nio") + if err != nil { + t.Fatalf("FetchVersions failed: %v", err) + } + var got []string + for _, v := range versions { + got = append(got, v.Number) + } + if strings.Join(got, " ") != "2.65.0 2.10.0 2.9.0" { + t.Errorf("versions = %v, want highest first", got) + } + if versions[1].Status != core.StatusYanked || versions[1].Metadata["problem_status"] != 410 { + t.Errorf("2.10.0 = %+v, want yanked with problem status", versions[1]) + } + if versions[0].Status != core.StatusNone { + t.Errorf("2.65.0 status = %q, want none", versions[0].Status) + } +} + +func TestFetchVersionsFollowsPagination(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.RawQuery { + case "": + w.Header().Add("Link", `<`+server.URL+`/apple/swift-nio>; rel="latest-version"`) + w.Header().Add("Link", `; rel="next", ; rel="last"`) + _, _ = w.Write([]byte(`{"releases": {"2.65.0": {}}}`)) + case "page=2": + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`{"releases": {"2.64.0": {}}}`)) + default: + t.Errorf("unexpected request %s", r.URL) + } + })) + defer server.Close() + + versions, err := New(server.URL, core.DefaultClient()).FetchVersions(context.Background(), "apple.swift-nio") + if err != nil { + t.Fatalf("FetchVersions failed: %v", err) + } + if len(versions) != 2 { + t.Errorf("got %d versions, want 2 from two same-host pages", len(versions)) + } +} + +func TestFetchVersion(t *testing.T) { + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apple/swift-nio/2.65.0" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(releaseJSON)) + }) + + v, err := reg.FetchVersion(context.Background(), "apple/swift-nio", "2.65.0") + if err != nil { + t.Fatalf("FetchVersion failed: %v", err) + } + wantIntegrity := "sha256-a2ac54cf25fbc1ad0028f03f0aa4b96833b83bb05a14e510892bb27dea4dc812" + if v.Integrity != wantIntegrity { + t.Errorf("Integrity = %q, want %q", v.Integrity, wantIntegrity) + } + if !v.PublishedAt.Equal(time.Date(2024, 4, 2, 10, 0, 0, 0, time.UTC)) { + t.Errorf("PublishedAt = %v", v.PublishedAt) + } + if len(v.Artifacts) != 1 { + t.Fatalf("got %d artifacts, want 1", len(v.Artifacts)) + } + a := v.Artifacts[0] + if a.URL != reg.baseURL+"/apple/swift-nio/2.65.0.zip" || a.Filename != "2.65.0.zip" || a.Integrity != wantIntegrity || a.MediaType != "application/zip" { + t.Errorf("artifact = %+v", a) + } +} + +func TestFetchVersionPrefersOriginalPublicationTime(t *testing.T) { + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"id": "mona.LinkedList", "version": "1.0.0", "resources": [], + "metadata": {"originalPublicationTime": "2020-01-01T00:00:00Z"}, "publishedAt": "2024-01-01T00:00:00Z"}`)) + }) + + v, err := reg.FetchVersion(context.Background(), "mona/LinkedList", "1.0.0") + if err != nil { + t.Fatalf("FetchVersion failed: %v", err) + } + if v.PublishedAt.Year() != 2020 { + t.Errorf("PublishedAt = %v, want originalPublicationTime", v.PublishedAt) + } + if v.Integrity != "" || len(v.Artifacts) != 0 { + t.Errorf("release without a source archive = %+v", v) + } +} + +func TestFetchPackageAndMaintainers(t *testing.T) { + var requested []string + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + requested = append(requested, r.URL.Path) + switch r.URL.Path { + case "/apple/swift-nio": + _, _ = w.Write([]byte(`{"releases": {"2.64.0": {}, "2.65.0": {}, "3.0.0": {"problem": {"status": 410}}}}`)) + case "/apple/swift-nio/2.65.0": + _, _ = w.Write([]byte(releaseJSON)) + default: + http.NotFound(w, r) + } + }) + + pkg, err := reg.FetchPackage(context.Background(), "apple/swift-nio") + if err != nil { + t.Fatalf("FetchPackage failed: %v", err) + } + if pkg.Name != "apple.swift-nio" || pkg.Namespace != "apple" || pkg.LatestVersion != "2.65.0" { + t.Errorf("package = %+v", pkg) + } + if pkg.Repository != "https://github.com/apple/swift-nio" { + t.Errorf("Repository = %q", pkg.Repository) + } + if pkg.Description != "Event-driven network application framework" { + t.Errorf("Description = %q", pkg.Description) + } + + maintainers, err := reg.FetchMaintainers(context.Background(), "apple/swift-nio") + if err != nil { + t.Fatalf("FetchMaintainers failed: %v", err) + } + if len(maintainers) != 1 || maintainers[0].Name != "Mona" || maintainers[0].Email != "mona@example.com" || maintainers[0].Role != "Apple" { + t.Errorf("maintainers = %+v", maintainers) + } + for _, p := range requested { + if strings.HasSuffix(p, "/3.0.0") { + t.Errorf("fetched the unavailable release %s", p) + } + } +} + +func TestNotFound(t *testing.T) { + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/apple/swift-nio/9.9.9": + http.NotFound(w, r) + case "/apple/swift-nio/1.0.0": + w.WriteHeader(http.StatusGone) + _, _ = w.Write([]byte(`{"status": 410, "detail": "removed"}`)) + default: + http.NotFound(w, r) + } + }) + ctx := context.Background() + + var notFound *core.NotFoundError + if _, err := reg.FetchVersions(ctx, "apple/missing"); !errors.As(err, ¬Found) { + t.Errorf("FetchVersions missing package err = %v, want NotFoundError", err) + } + if _, err := reg.FetchPackage(ctx, "apple/missing"); !errors.As(err, ¬Found) { + t.Errorf("FetchPackage missing package err = %v, want NotFoundError", err) + } + for _, version := range []string{"9.9.9", "1.0.0"} { + _, err := reg.FetchVersion(ctx, "apple/swift-nio", version) + if !errors.As(err, ¬Found) || notFound.Version != version { + t.Errorf("FetchVersion %s err = %v, want NotFoundError", version, err) + } + } +} + +func TestInvalidNameMakesNoRequest(t *testing.T) { + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s", r.URL) + }) + name := "github.com/apple/swift-argument-parser" + if _, err := reg.FetchVersions(context.Background(), name); !errors.Is(err, ErrInvalidName) { + t.Errorf("FetchVersions err = %v, want ErrInvalidName", err) + } + if _, err := reg.FetchVersion(context.Background(), name, "1.3.0"); !errors.Is(err, ErrInvalidName) { + t.Errorf("FetchVersion err = %v, want ErrInvalidName", err) + } + if got := reg.URLs().Download(name, "1.3.0"); got != "" { + t.Errorf("Download = %q, want empty", got) + } +} + +type tokenTransport struct{ base http.RoundTripper } + +func (t tokenTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer secret") + return t.base.RoundTrip(req) +} + +func TestUsesSuppliedHTTPClient(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer secret" { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write([]byte(`{"releases": {"1.0.0": {}}}`)) + })) + defer server.Close() + + client := core.DefaultClient() + client.HTTPClient = &http.Client{Transport: tokenTransport{base: http.DefaultTransport}} + versions, err := New(server.URL, client).FetchVersions(context.Background(), "mona/LinkedList") + if err != nil || len(versions) != 1 { + t.Errorf("FetchVersions = %v, %v; want one version through the authenticated client", versions, err) + } +} + +func TestURLs(t *testing.T) { + u := New("https://packages.example.com/", nil).URLs() + tests := [][2]string{ + {u.Registry("apple/swift-nio", ""), "https://packages.example.com/apple/swift-nio"}, + {u.Registry("apple.swift-nio", "2.65.0"), "https://packages.example.com/apple/swift-nio/2.65.0"}, + {u.Download("apple/swift-nio", "2.65.0"), "https://packages.example.com/apple/swift-nio/2.65.0.zip"}, + {u.Download("apple/swift-nio", ""), ""}, + {u.PURL("apple.swift-nio", "2.65.0"), "pkg:swift/apple/swift-nio@2.65.0"}, + {u.Documentation("apple/swift-nio", ""), ""}, + } + for _, tt := range tests { + if tt[0] != tt[1] { + t.Errorf("got %q, want %q", tt[0], tt[1]) + } + } +} + +func TestParseLinks(t *testing.T) { + links := parseLinks([]string{`; rel="next", ;rel=latest-version`, `garbage`}) + if len(links) != 2 || links[0].url != "https://a.example/x?page=2" || links[0].rel != "next" || links[1].rel != "latest-version" { + t.Errorf("links = %+v", links) + } +} diff --git a/registries_test.go b/registries_test.go index 3254fb2..baf651b 100644 --- a/registries_test.go +++ b/registries_test.go @@ -17,7 +17,7 @@ import ( func TestSupportedEcosystems(t *testing.T) { ecosystems := registries.SupportedEcosystems() - expected := []string{"brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "helm", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "terraform"} + expected := []string{"brew", "cargo", "clojars", "cocoapods", "composer", "conda", "cpan", "cran", "deno", "dub", "elm", "gem", "golang", "hackage", "haxelib", "helm", "hex", "julia", "luarocks", "maven", "nimble", "npm", "nuget", "pub", "pypi", "swift", "terraform"} sort.Strings(ecosystems) if len(ecosystems) != len(expected) { @@ -62,6 +62,7 @@ func TestNew(t *testing.T) { {"deno", false}, {"terraform", false}, {"helm", true}, + {"swift", true}, {"unknown", true}, } @@ -106,6 +107,7 @@ func TestDefaultURL(t *testing.T) { {"deno", "https://apiland.deno.dev"}, {"terraform", "https://registry.terraform.io"}, {"helm", ""}, + {"swift", ""}, } for _, tt := range tests { @@ -278,3 +280,32 @@ func TestRootClientOptions(t *testing.T) { t.Errorf("WithSafeHTTP error %v should mention loopback", err) } } + +func TestSwiftFromPURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/apple/swift-nio/2.65.0" { + http.NotFound(w, r) + return + } + _, _ = w.Write([]byte(`{"id": "apple.swift-nio", "version": "2.65.0", + "resources": [{"name": "source-archive", "type": "application/zip", "checksum": "abc123"}], "metadata": {}}`)) + })) + defer srv.Close() + + v, err := registries.FetchVersionFromPURL(context.Background(), "pkg:swift/apple/swift-nio@2.65.0?repository_url="+srv.URL, nil) + if err != nil { + t.Fatalf("FetchVersionFromPURL failed: %v", err) + } + if v.Integrity != "sha256-abc123" { + t.Errorf("Integrity = %q, want sha256-abc123", v.Integrity) + } + + _, err = registries.FetchVersionFromPURL(context.Background(), "pkg:swift/github.com/apple/swift-argument-parser@1.3.0?repository_url="+srv.URL, nil) + if err == nil || !strings.Contains(err.Error(), "registry scope") { + t.Errorf("host-style namespace err = %v, want a scope error", err) + } + + if _, err := registries.FetchVersionFromPURL(context.Background(), "pkg:swift/apple/swift-nio@2.65.0", nil); err == nil || !strings.Contains(err.Error(), "no registry URL configured") { + t.Errorf("missing URL err = %v", err) + } +} From 71e8e3a18dcad401432f1407c29fb5f8ce2eb5cf Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 19 Oct 2026 08:23:09 +0100 Subject: [PATCH 2/2] Reject incomplete Swift release pagination --- internal/swift/swift.go | 3 +++ internal/swift/swift_test.go | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/internal/swift/swift.go b/internal/swift/swift.go index 63888ff..fea1dba 100644 --- a/internal/swift/swift.go +++ b/internal/swift/swift.go @@ -186,6 +186,9 @@ func (r *Registry) fetchReleases(ctx context.Context, name string) (map[string]r } next = r.nextPage(next, header) } + if next != "" { + return nil, fmt.Errorf("swift release listing for %q exceeds %d pages", name, maxPages) + } return releases, nil } diff --git a/internal/swift/swift_test.go b/internal/swift/swift_test.go index 1e86d4d..b3a44b3 100644 --- a/internal/swift/swift_test.go +++ b/internal/swift/swift_test.go @@ -3,8 +3,10 @@ package swift import ( "context" "errors" + "fmt" "net/http" "net/http/httptest" + "strconv" "strings" "testing" "time" @@ -137,6 +139,43 @@ func TestFetchVersionsFollowsPagination(t *testing.T) { } } +func TestFetchVersionsPaginationLimit(t *testing.T) { + for _, pages := range []int{maxPages, maxPages + 1} { + t.Run(strconv.Itoa(pages), func(t *testing.T) { + requests := 0 + reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { + requests++ + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page == 0 { + page = 1 + } + if page < pages { + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, page+1)) + } + _, _ = fmt.Fprintf(w, `{"releases":{"1.0.%d":{}}}`, page) + }) + + versions, err := reg.FetchVersions(context.Background(), "mona/demo") + if pages == maxPages { + if err != nil || len(versions) != pages { + t.Fatalf("FetchVersions returned %d versions, %v", len(versions), err) + } + } else if err == nil || versions != nil { + t.Fatalf("FetchVersions returned %d versions, %v; want no partial results and an error", len(versions), err) + } + if requests != maxPages { + t.Errorf("requests = %d, want %d", requests, maxPages) + } + if pages > maxPages { + pkg, err := reg.FetchPackage(context.Background(), "mona/demo") + if err == nil || pkg != nil { + t.Errorf("FetchPackage = %+v, %v; want no package and an error", pkg, err) + } + } + }) + } +} + func TestFetchVersion(t *testing.T) { reg := newServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/apple/swift-nio/2.65.0" {