diff --git a/README.md b/README.md index e5f47bf..60c86f2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # registries -Go library for fetching package metadata from registry APIs. Supports 26 ecosystems with a unified interface. Also provides sub-packages for HTTP client usage (`client/`) and streaming artifact downloads (`fetch/`). +Go library for fetching package metadata from registry APIs. Supports 27 ecosystems with a unified interface. Also provides sub-packages for HTTP client usage (`client/`) and streaming artifact downloads (`fetch/`). ## Installation @@ -192,6 +192,45 @@ 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 registries + +Swift lookups require an explicit registry URL and a registry identity: + +```go +reg, err := registries.New("swift", "https://registry.example.com", nil) +// Handle err before using reg. +pkg, err := reg.FetchPackage(ctx, "scope.package-name") +versions, err := reg.FetchVersions(ctx, "scope.package-name") +``` + +Import `registries/all` to register the adapter. Identities are case-insensitive. +Source coordinates such as `github.com/owner/repo` are rejected; the adapter does +not infer registry identities from repository names. Swift source-based PURLs +cannot drive these lookups, and `URLs().PURL` returns an empty string. + +`FetchPackage` reads metadata from the release named by the registry's +`latest-version` link, falling back to the highest available stable release and +then the highest prerelease. Pagination and release metadata URLs must stay on +the registry's origin. +`FetchVersions` follows pagination and fetches each available release +for its publication time, source archive URL and SHA256 checksum. A release +reported with a protocol `problem` is returned as `retracted`, with the problem +retained in its metadata. Other fetch or decoding failures return an error rather +than a partial version list. + +Release metadata retains fields such as `licenseURL`, `author` and +`repositoryURLs`. A license URL is not converted into an SPDX expression, and an +author is not treated as a maintainer. Original publication time takes precedence +over registry publication time. Archive signatures are retained in artifact +metadata but are not verified. + +`FetchDependencies` and `FetchMaintainers` return errors wrapping +`errors.ErrUnsupported`. The registry protocol supplies executable Swift +manifests rather than structured dependency data, and has no maintainer endpoint. +The adapter does not execute Swift or download archives. Authentication can be +provided through the shared client's HTTP transport. ## Types diff --git a/all/all.go b/all/all.go index 8e9577a..01b9126 100644 --- a/all/all.go +++ b/all/all.go @@ -9,7 +9,7 @@ // // // Now all ecosystems are available // ecosystems := registries.SupportedEcosystems() -// // ["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"] +// // ["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"] package all import ( @@ -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..5569016 100644 --- a/client/client.go +++ b/client/client.go @@ -54,16 +54,29 @@ func DefaultClient() *Client { // GetJSON fetches a URL and decodes the JSON response into v. func (c *Client) GetJSON(ctx context.Context, url string, v any) error { - body, err := c.GetBody(ctx, url) + _, err := c.GetJSONWithHeaders(ctx, url, nil, v) + return err +} + +// GetJSONWithHeaders sends request headers and returns response headers. +// It uses the same retry and rate-limit policy as GetJSON. +func (c *Client) GetJSONWithHeaders(ctx context.Context, url string, headers http.Header, v any) (http.Header, error) { + body, responseHeaders, err := c.getBody(ctx, url, headers) if err != nil { - return err + return responseHeaders, err } - return json.Unmarshal(body, v) + return responseHeaders, 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, headers http.Header) ([]byte, http.Header, error) { var lastErr error + var lastHeaders http.Header for attempt := 0; attempt <= c.MaxRetries; attempt++ { if attempt > 0 { @@ -73,64 +86,71 @@ 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, responseHeaders, err := c.doRequest(ctx, url, headers) if err == nil { - return body, nil + return body, responseHeaders, nil } lastErr = err + lastHeaders = responseHeaders if errors.Is(err, ErrUpstreamHeld) { - return nil, err + return nil, responseHeaders, err } var rateErr *RateLimitError if errors.As(err, &rateErr) { - return nil, err + return nil, responseHeaders, err } var httpErr *HTTPError if ok := isHTTPError(err, &httpErr); ok { if httpErr.StatusCode == http.StatusNotFound { - return nil, err + return nil, responseHeaders, err } if httpErr.StatusCode == http.StatusTooManyRequests || httpErr.StatusCode >= http.StatusInternalServerError { continue } - return nil, err + return nil, responseHeaders, err } } - return nil, lastErr + return nil, lastHeaders, lastErr } -func (c *Client) doRequest(ctx context.Context, url string) ([]byte, error) { +func (c *Client) doRequest(ctx context.Context, url string, headers 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 key, values := range headers { + req.Header.Del(key) + for _, value := range values { + req.Header.Add(key, value) + } + } 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, resp.Header, err } if resp.StatusCode >= http.StatusBadRequest { @@ -145,13 +165,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, resp.Header, &RateLimitError{RetryAfter: int(seconds)} } } - return nil, httpErr + return nil, resp.Header, httpErr } - return body, nil + return body, resp.Header, nil } func isHTTPError(err error, target **HTTPError) bool { diff --git a/client/headers_test.go b/client/headers_test.go new file mode 100644 index 0000000..98dbd8d --- /dev/null +++ b/client/headers_test.go @@ -0,0 +1,74 @@ +package client + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetJSONWithHeaders(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if r.Header.Get("Accept") != "application/vnd.swift.registry.v1+json" || r.Header.Get("User-Agent") != "registries" { + t.Errorf("headers = %#v", r.Header) + } + if attempts == 1 { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + w.Header().Add("Link", `; rel="next"`) + _, _ = fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + c := DefaultClient() + c.MaxRetries = 1 + c.BaseDelay = 0 + requestHeaders := http.Header{"Accept": {"application/vnd.swift.registry.v1+json"}} + var result struct{ OK bool } + headers, err := c.GetJSONWithHeaders(context.Background(), server.URL, requestHeaders, &result) + if err != nil || !result.OK || attempts != 2 || headers.Get("Link") == "" { + t.Fatalf("result = %#v, headers = %#v, attempts = %d, err = %v", result, headers, attempts, err) + } + if requestHeaders.Get("User-Agent") != "" { + t.Fatal("caller headers mutated") + } +} + +func TestGetJSONDefaultHeadersUnchanged(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Accept = %q", r.Header.Get("Accept")) + } + _, _ = fmt.Fprint(w, `{}`) + })) + defer server.Close() + var response map[string]any + if err := DefaultClient().GetJSON(context.Background(), server.URL, &response); err != nil { + t.Fatal(err) + } +} + +func TestGetJSONWithHeadersFailure(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusServiceUnavailable, http.StatusTooManyRequests} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Version", "1") + if status == http.StatusTooManyRequests { + w.Header().Set("Retry-After", "10") + } + w.WriteHeader(status) + })) + defer server.Close() + c := DefaultClient() + c.MaxRetries = 0 + var result any + headers, err := c.GetJSONWithHeaders(context.Background(), server.URL, nil, &result) + if err == nil || headers.Get("Content-Version") != "1" { + t.Errorf("headers = %#v, err = %v", headers, err) + } + }) + } +} diff --git a/internal/swift/links.go b/internal/swift/links.go new file mode 100644 index 0000000..f9d5442 --- /dev/null +++ b/internal/swift/links.go @@ -0,0 +1,72 @@ +package swift + +import ( + "fmt" + "mime" + "net/http" + "net/url" + "regexp" + "strings" +) + +var linkPattern = regexp.MustCompile(`<([^>]*)>((?:[^",]|"(?:\\.|[^"\\])*")*)`) + +func responseLinks(headers http.Header) map[string][]string { + links := make(map[string][]string) + for _, value := range headers.Values("Link") { + for _, match := range linkPattern.FindAllStringSubmatch(value, -1) { + _, params, err := mime.ParseMediaType("link" + strings.TrimSpace(match[2])) + if err != nil { + continue + } + for rel := range strings.FieldsSeq(strings.ToLower(params["rel"])) { + links[rel] = append(links[rel], match[1]) + } + } + } + return links +} + +func resolveLink(baseURL, reference string) (string, error) { + base, err := url.Parse(baseURL) + if err != nil { + return "", err + } + ref, err := url.Parse(reference) + if err != nil { + return "", err + } + resolved := base.ResolveReference(ref) + if (resolved.Scheme != "http" && resolved.Scheme != "https") || resolved.Host == "" || + resolved.User != nil || resolved.Fragment != "" { + return "", fmt.Errorf("invalid Swift registry link %q", reference) + } + return resolved.String(), nil +} + +func nextPage(page string, links map[string][]string) (string, error) { + next := links["next"] + if len(next) == 0 { + return "", nil + } + if len(next) != 1 { + return "", fmt.Errorf("multiple next-page links in Swift registry response") + } + resolved, err := resolveLink(page, next[0]) + if err != nil { + return "", err + } + if !sameOrigin(page, resolved) { + return "", fmt.Errorf("swift registry pagination changed origin") + } + return resolved, nil +} + +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 && first.Host == second.Host +} diff --git a/internal/swift/release.go b/internal/swift/release.go new file mode 100644 index 0000000..9960760 --- /dev/null +++ b/internal/swift/release.go @@ -0,0 +1,90 @@ +package swift + +import ( + "context" + "encoding/hex" + "fmt" + "strings" + "time" + + "github.com/git-pkgs/registries/internal/core" + "github.com/git-pkgs/registries/internal/urlparser" +) + +type resource struct { + Name string `json:"name"` + Type string `json:"type"` + Checksum string `json:"checksum"` + Signing map[string]any `json:"signing"` +} + +type releaseInfo struct { + ID string `json:"id"` + Version string `json:"version"` + Resources []resource `json:"resources"` + Metadata map[string]any `json:"metadata"` + PublishedAt string `json:"publishedAt"` +} + +func (r *Registry) release(ctx context.Context, name, version, endpoint string) (*releaseInfo, error) { + var info releaseInfo + if _, err := r.getJSON(ctx, endpoint, name, version, &info); err != nil { + return nil, err + } + if !strings.EqualFold(info.ID, name) || info.Version != version { + return nil, fmt.Errorf("swift registry returned release %q@%q for %q@%q", info.ID, info.Version, name, version) + } + return &info, nil +} + +func (info *releaseInfo) populatePackage(pkg *core.Package) { + pkg.Description, _ = info.Metadata["description"].(string) + repositories, _ := info.Metadata["repositoryURLs"].([]any) + if pkg.Repository == "" { + for _, repository := range repositories { + value, _ := repository.(string) + if normalized := urlparser.CanonicalURL(value); normalized != "" { + pkg.Repository = normalized + break + } + } + } +} + +func (info *releaseInfo) version(urls *URLs, name string) (*core.Version, error) { + version := &core.Version{Number: info.Version, Metadata: info.Metadata} + published := info.PublishedAt + if original, _ := info.Metadata["originalPublicationTime"].(string); original != "" { + published = original + } + if published != "" { + var err error + version.PublishedAt, err = time.Parse(time.RFC3339Nano, published) + if err != nil { + return nil, fmt.Errorf("invalid Swift release publication time: %w", err) + } + } + for _, resource := range info.Resources { + if resource.Name != "source-archive" || resource.Type != "application/zip" { + continue + } + const sha256Bytes = 32 + checksum, err := hex.DecodeString(resource.Checksum) + if err != nil || len(checksum) != sha256Bytes { + return nil, fmt.Errorf("invalid Swift source archive SHA256 checksum") + } + if len(version.Artifacts) != 0 { + return nil, fmt.Errorf("duplicate Swift source archive resource") + } + version.Integrity = "sha256-" + strings.ToLower(resource.Checksum) + artifact := core.Artifact{ + URL: urls.Download(name, info.Version), Filename: info.Version + ".zip", + Integrity: version.Integrity, MediaType: resource.Type, + } + if resource.Signing != nil { + artifact.Metadata = map[string]any{"signing": resource.Signing} + } + version.Artifacts = append(version.Artifacts, artifact) + } + return version, nil +} diff --git a/internal/swift/swift.go b/internal/swift/swift.go new file mode 100644 index 0000000..5029e42 --- /dev/null +++ b/internal/swift/swift.go @@ -0,0 +1,221 @@ +package swift + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "sort" + "strings" + + "github.com/git-pkgs/registries/internal/core" + "github.com/git-pkgs/registries/internal/urlparser" + "github.com/git-pkgs/vers" +) + +const ( + ecosystem = "swift" + mediaType = "application/vnd.swift.registry.v1+json" +) + +func init() { + core.Register(ecosystem, "", func(baseURL string, client *core.Client) core.Registry { + return &Registry{client: client, urls: &URLs{baseURL: baseURL}} + }) +} + +type Registry struct { + client *core.Client + urls *URLs +} + +func (r *Registry) Ecosystem() string { return ecosystem } + +func (r *Registry) URLs() core.URLBuilder { //nolint:ireturn + return r.urls +} + +type listedRelease struct { + URL string `json:"url"` + Problem map[string]any `json:"problem"` +} + +type listing struct { + releases map[string]listedRelease + links map[string][]string +} + +func (r *Registry) getJSON(ctx context.Context, endpoint, name, version string, dest any) (http.Header, error) { + headers, err := r.client.GetJSONWithHeaders(ctx, endpoint, http.Header{"Accept": {mediaType}}, dest) + var httpErr *core.HTTPError + if errors.As(err, &httpErr) && httpErr.IsNotFound() { + return headers, &core.NotFoundError{Ecosystem: ecosystem, Name: name, Version: version} + } + if err == nil && headers.Get("Content-Version") != "" && headers.Get("Content-Version") != "1" { + return headers, fmt.Errorf("unsupported Swift registry Content-Version %q", headers.Get("Content-Version")) + } + return headers, err +} + +func (r *Registry) list(ctx context.Context, name string) (*listing, error) { + page, err := r.urls.endpoint(name, "") + if err != nil { + return nil, err + } + result := &listing{releases: make(map[string]listedRelease), links: make(map[string][]string)} + seen := make(map[string]bool) + for page != "" { + if seen[page] { + return nil, fmt.Errorf("swift registry pagination loop at %s", page) + } + seen[page] = true + var response struct { + Releases map[string]listedRelease `json:"releases"` + } + headers, err := r.getJSON(ctx, page, name, "", &response) + if err != nil { + return nil, err + } + if response.Releases == nil { + return nil, fmt.Errorf("swift registry response has no releases object") + } + for number, release := range response.Releases { + if number == "" { + return nil, fmt.Errorf("swift registry returned an empty release version") + } + release.URL, err = r.releaseEndpoint(page, name, number, release.URL) + if err != nil { + return nil, err + } + result.releases[number] = release + } + links := responseLinks(headers) + for rel, values := range links { + result.links[rel] = append(result.links[rel], values...) + } + page, err = nextPage(page, links) + if err != nil { + return nil, err + } + } + return result, nil +} + +func (r *Registry) releaseEndpoint(page, name, number, link string) (string, error) { + endpoint, err := r.urls.endpoint(name, number) + if err != nil || link == "" { + return endpoint, err + } + endpoint, err = resolveLink(page, link) + if err != nil { + return "", err + } + if !sameOrigin(page, endpoint) { + return "", fmt.Errorf("swift registry release %s is on another origin", number) + } + return endpoint, nil +} + +func (l *listing) numbers() []string { + numbers := make([]string, 0, len(l.releases)) + for number := range l.releases { + numbers = append(numbers, number) + } + sort.Slice(numbers, func(i, j int) bool { + if comparison := vers.CompareWithScheme(numbers[i], numbers[j], "semver"); comparison != 0 { + return comparison > 0 + } + return numbers[i] < numbers[j] + }) + return numbers +} + +// latest prefers the registry's latest-version link, then the highest +// available stable release, then the highest available prerelease. +func (l *listing) latest() string { + for _, link := range l.links["latest-version"] { + if ref, err := url.Parse(link); err == nil { + number := path.Base(ref.Path) + if release, ok := l.releases[number]; ok && release.Problem == nil { + return number + } + } + } + var prerelease string + for _, number := range l.numbers() { + if l.releases[number].Problem != nil { + continue + } + if info, err := vers.ParseVersion(number); err == nil && !info.IsPrerelease() { + return number + } + if prerelease == "" { + prerelease = number + } + } + return prerelease +} + +func (r *Registry) FetchPackage(ctx context.Context, name string) (*core.Package, error) { + list, err := r.list(ctx, name) + if err != nil { + return nil, err + } + identity := strings.ToLower(name) + scope, _, _ := strings.Cut(identity, ".") + pkg := &core.Package{Name: identity, Namespace: scope} + for _, link := range list.links["canonical"] { + if repository := urlparser.CanonicalURL(link); repository != "" { + pkg.Repository = repository + break + } + } + if number := list.latest(); number != "" { + info, err := r.release(ctx, name, number, list.releases[number].URL) + if err != nil { + return nil, err + } + pkg.LatestVersion = number + pkg.Metadata = info.Metadata + info.populatePackage(pkg) + } + return pkg, nil +} + +func (r *Registry) FetchVersions(ctx context.Context, name string) ([]core.Version, error) { + list, err := r.list(ctx, name) + if err != nil { + return nil, err + } + versions := make([]core.Version, 0, len(list.releases)) + for _, number := range list.numbers() { + release := list.releases[number] + if release.Problem != nil { + versions = append(versions, core.Version{ + Number: number, Status: core.StatusRetracted, + Metadata: map[string]any{"problem": release.Problem}, + }) + continue + } + info, err := r.release(ctx, name, number, release.URL) + if err != nil { + return nil, err + } + version, err := info.version(r.urls, name) + if err != nil { + return nil, err + } + versions = append(versions, *version) + } + return versions, nil +} + +func (r *Registry) FetchDependencies(_ context.Context, _, _ string) ([]core.Dependency, error) { + return nil, fmt.Errorf("swift registry protocol does not expose structured dependencies: %w", errors.ErrUnsupported) +} + +func (r *Registry) FetchMaintainers(_ context.Context, _ string) ([]core.Maintainer, error) { + return nil, fmt.Errorf("swift registry protocol does not expose maintainers: %w", errors.ErrUnsupported) +} diff --git a/internal/swift/urls.go b/internal/swift/urls.go new file mode 100644 index 0000000..f976c51 --- /dev/null +++ b/internal/swift/urls.go @@ -0,0 +1,70 @@ +// Package swift implements the Swift package registry protocol. +package swift + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/git-pkgs/vers" +) + +var ( + scopePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) + namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+([-_][a-zA-Z0-9]+)*$`) +) + +type URLs struct { + baseURL string +} + +func identityPath(identity string) (string, error) { + const maxScope, maxName = 39, 100 + scope, name, ok := strings.Cut(identity, ".") + if !ok || len(scope) > maxScope || len(name) > maxName || + !scopePattern.MatchString(scope) || !namePattern.MatchString(name) { + return "", fmt.Errorf("invalid Swift registry identity %q: expected scope.name", identity) + } + return strings.ToLower(scope + "/" + name), nil +} + +func (u *URLs) endpoint(name, version string) (string, error) { + path, err := identityPath(name) + if err != nil { + return "", err + } + base, err := url.Parse(u.baseURL) + if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || + base.RawQuery != "" || base.Fragment != "" || base.User != nil { + return "", fmt.Errorf("invalid Swift registry URL %q", u.baseURL) + } + if version != "" { + if !vers.ValidWithScheme(version, "semver") { + return "", fmt.Errorf("invalid Swift release version %q", version) + } + path += "/" + url.PathEscape(version) + } + return strings.TrimRight(u.baseURL, "/") + "/" + path, nil +} + +func (u *URLs) Registry(name, version string) string { + endpoint, _ := u.endpoint(name, version) + return endpoint +} + +func (u *URLs) Download(name, version string) string { + if version == "" { + return "" + } + endpoint, err := u.endpoint(name, version) + if err != nil { + return "" + } + return endpoint + ".zip" +} + +func (u *URLs) Documentation(_, _ string) string { return "" } + +// Swift PURLs identify source repositories, not registry-scoped packages. +func (u *URLs) PURL(_, _ string) string { return "" } diff --git a/registries_test.go b/registries_test.go index 3254fb2..04f0c68 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 { diff --git a/swift_test.go b/swift_test.go new file mode 100644 index 0000000..b8477c7 --- /dev/null +++ b/swift_test.go @@ -0,0 +1,366 @@ +package registries_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/git-pkgs/registries" +) + +const swiftChecksum = "a2ac54cf25fbc1ad0028f03f0aa4b96833b83bb05a14e510892bb27dea4dc812" + +func swiftRelease(version string) map[string]any { + return map[string]any{ + "id": "Mona.Linked-List", "version": version, + "publishedAt": "2026-01-03T04:05:06Z", + "resources": []any{map[string]any{ + "name": "source-archive", "type": "application/zip", "checksum": swiftChecksum, + "signing": map[string]any{"signatureBase64Encoded": "c2lnbmF0dXJl", "signatureFormat": "cms-1.0.0"}, + }}, + "metadata": map[string]any{ + "description": "A linked list.", + "repositoryURLs": []string{"https://github.com/Mona/Linked-List"}, + "licenseURL": "https://example.com/LICENSE", + "originalPublicationTime": "2025-12-01T02:03:04Z", + "author": map[string]any{"name": "Mona", "email": "mona@example.com"}, + }, + } +} + +func newSwiftRegistry(t *testing.T, handler http.HandlerFunc) registries.Registry { //nolint:ireturn + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + reg, err := registries.New("swift", server.URL+"/registry", registries.NewClient(registries.WithMaxRetries(0))) + if err != nil { + t.Fatal(err) + } + return reg +} + +func TestSwiftRegistry(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Accept"); got != "application/vnd.swift.registry.v1+json" { + t.Errorf("Accept = %q", got) + } + w.Header().Set("Content-Version", "1") + switch r.URL.RequestURI() { + case "/registry/mona/linked-list": + w.Header().Add("Link", `; rel="next"; title="Next, please"`) + w.Header().Add("Link", `; rel="canonical"`) + _, _ = fmt.Fprint(w, `{"releases":{"1.0.0":{}, "3.0.0":{"problem":{"status":410,"title":"Gone"}}}}`) + case "/registry/mona/linked-list?page=2": + _, _ = fmt.Fprint(w, `{"releases":{"2.0.0":{"url":"/metadata/linked-list/2.0.0"}}}`) + case "/metadata/linked-list/2.0.0": + _ = json.NewEncoder(w).Encode(swiftRelease("2.0.0")) + case "/registry/mona/linked-list/1.0.0": + _ = json.NewEncoder(w).Encode(swiftRelease("1.0.0")) + default: + t.Errorf("unexpected request %s", r.URL) + http.NotFound(w, r) + } + }) + ctx := context.Background() + pkg, err := reg.FetchPackage(ctx, "Mona.Linked-List") + if err != nil { + t.Fatal(err) + } + if pkg.Name != "mona.linked-list" || pkg.Namespace != "mona" || pkg.LatestVersion != "2.0.0" || + pkg.Repository != "https://github.com/Mona/Linked-List" || pkg.Description != "A linked list." { + t.Errorf("package = %#v", pkg) + } + if pkg.Licenses != "" || len(pkg.Maintainers) != 0 || pkg.Metadata["licenseURL"] != "https://example.com/LICENSE" { + t.Errorf("license URL or author promoted to unsupported metadata: %#v", pkg) + } + versions, err := reg.FetchVersions(ctx, "Mona.Linked-List") + if err != nil { + t.Fatal(err) + } + if len(versions) != 3 || versions[0].Number != "3.0.0" || versions[0].Status != registries.StatusRetracted { + t.Fatalf("versions = %#v", versions) + } + version := versions[1] + if version.Number != "2.0.0" || version.Integrity != "sha256-"+swiftChecksum || + !version.PublishedAt.Equal(time.Date(2025, 12, 1, 2, 3, 4, 0, time.UTC)) { + t.Errorf("release = %#v", version) + } + checkSwiftArtifact(t, version, reg.URLs().Download("mona.linked-list", "2.0.0")) + latest, err := registries.FetchLatestVersion(ctx, reg, "mona.linked-list") + if err != nil || latest == nil || latest.Number != "2.0.0" { + t.Fatalf("latest = %#v, %v", latest, err) + } +} + +func checkSwiftArtifact(t *testing.T, version registries.Version, downloadURL string) { + t.Helper() + if len(version.Artifacts) != 1 { + t.Fatalf("artifacts = %#v", version.Artifacts) + } + artifact := version.Artifacts[0] + if artifact.URL != downloadURL || + artifact.Filename != "2.0.0.zip" || artifact.Integrity != version.Integrity || + artifact.MediaType != "application/zip" || artifact.Metadata["signing"] == nil { + t.Errorf("artifact = %#v", artifact) + } +} + +func TestSwiftRegistryURLsAndIdentity(t *testing.T) { + reg := newSwiftRegistry(t, func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s", r.URL) + }) + urls := reg.URLs() + if got := urls.Registry("Mona.Linked_List", "1.0.0"); !strings.HasSuffix(got, "/registry/mona/linked_list/1.0.0") { + t.Errorf("Registry = %q", got) + } + if urls.Download("mona.linked-list", "") != "" || + urls.PURL("mona.linked-list", "1.0.0") != "" || + urls.Documentation("mona.linked-list", "1.0.0") != "" { + t.Error("unexpected inferred URL") + } + for _, name := range []string{ + "github.com/Mona/Linked-List", "mona", "mona.foo.bar", "../x", "mona.x?y", + "mo_na.foo", "-mona.foo", "mo--na.foo", "mona.foo-_bar", "mona.foo-", + strings.Repeat("a", 40) + ".foo", "mona." + strings.Repeat("a", 101), + } { + if _, err := reg.FetchPackage(context.Background(), name); err == nil { + t.Errorf("accepted invalid identity %q", name) + } + } + for _, version := range []string{"../1", "1.0.0/../../x", "1.0.0?x", "main"} { + if got := urls.Download("mona.linked-list", version); got != "" { + t.Errorf("Download accepted version %q: %q", version, got) + } + } + if _, err := registries.New("swift", "", nil); err == nil { + t.Fatal("missing registry URL accepted") + } + if _, err := registries.FetchPackageFromPURL(context.Background(), + "pkg:swift/github.com/Mona/Linked-List?repository_url=https%3A%2F%2Fexample.com", nil); err == nil { + t.Fatal("source coordinate was treated as a registry identity") + } +} + +func TestSwiftUnsupported(t *testing.T) { + reg := newSwiftRegistry(t, func(_ http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s", r.URL) + }) + if deps, err := reg.FetchDependencies(context.Background(), "mona.linked-list", "1.0.0"); !errors.Is(err, errors.ErrUnsupported) || deps != nil { + t.Errorf("dependencies = %#v, %v", deps, err) + } + if maintainers, err := reg.FetchMaintainers(context.Background(), "mona.linked-list"); !errors.Is(err, errors.ErrUnsupported) || maintainers != nil { + t.Errorf("maintainers = %#v, %v", maintainers, err) + } +} + +func TestSwiftRegistryErrors(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusUnauthorized, http.StatusTooManyRequests, http.StatusServiceUnavailable} { + t.Run(fmt.Sprint(status), func(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = fmt.Fprint(w, `{"detail":"unavailable"}`) + }) + _, err := reg.FetchPackage(context.Background(), "mona.linked-list") + if err == nil || errors.Is(err, registries.ErrNotFound) != (status == http.StatusNotFound) { + t.Errorf("error = %v", err) + } + }) + } + for _, body := range []string{`{`, `{}`, `null`, `{"releases":null}`, `{"releases":{"../bad":{}}}`, `{"releases":{"":{}}}`} { + t.Run(body, func(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, body) + }) + if result, err := reg.FetchVersions(context.Background(), "mona.linked-list"); err == nil || result != nil { + t.Errorf("versions = %#v, %v", result, err) + } + }) + } +} + +func TestSwiftPaginationErrors(t *testing.T) { + for _, link := range []string{ + `; rel="next"`, + `; rel="next"`, + `; rel="next"`, + `; rel="next", ; rel="next"`, + } { + t.Run(link, func(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Link", link) + _, _ = fmt.Fprint(w, `{"releases":{}}`) + }) + if _, err := reg.FetchVersions(context.Background(), "mona.linked-list"); err == nil { + t.Error("invalid pagination accepted") + } + }) + } +} + +func TestSwiftMalformedRelease(t *testing.T) { + for _, field := range []string{"id", "version", "checksum", "publishedAt", "duplicate", "http", "protocol"} { + t.Run(field, func(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/registry/mona/linked-list" { + _, _ = fmt.Fprint(w, `{"releases":{"1.0.0":{}}}`) + return + } + info := swiftRelease("1.0.0") + switch field { + case "id", "version": + info[field] = "wrong" + case "checksum": + info["resources"].([]any)[0].(map[string]any)["checksum"] = "garbage" + case "publishedAt": + info["metadata"].(map[string]any)["originalPublicationTime"] = "yesterday" + case "duplicate": + info["resources"] = append(info["resources"].([]any), info["resources"].([]any)[0]) + case "http": + http.NotFound(w, r) + return + case "protocol": + w.Header().Set("Content-Version", "2") + } + _ = json.NewEncoder(w).Encode(info) + }) + if result, err := reg.FetchVersions(context.Background(), "mona.linked-list"); err == nil || result != nil { + t.Errorf("versions = %#v, %v", result, err) + } + }) + } +} + +func TestSwiftEmptyRegistry(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, `{"releases":{}}`) + }) + pkg, err := reg.FetchPackage(context.Background(), "mona.linked-list") + if err != nil || pkg.LatestVersion != "" || pkg.Repository != "" { + t.Errorf("package = %#v, %v", pkg, err) + } + versions, err := reg.FetchVersions(context.Background(), "mona.linked-list") + if err != nil || len(versions) != 0 { + t.Errorf("versions = %#v, %v", versions, err) + } +} + +func TestSwiftOptionalMetadata(t *testing.T) { + for _, metadata := range []bool{false, true} { + t.Run(fmt.Sprint(metadata), func(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/registry/mona/linked-list" { + _, _ = fmt.Fprint(w, `{"releases":{"1.0.0":{}}}`) + return + } + info := swiftRelease("1.0.0") + delete(info["metadata"].(map[string]any), "originalPublicationTime") + if !metadata { + info["metadata"] = nil + delete(info, "publishedAt") + } + _ = json.NewEncoder(w).Encode(info) + }) + pkg, err := reg.FetchPackage(context.Background(), "mona.linked-list") + if err != nil { + t.Fatal(err) + } + wantRepository := "" + if metadata { + wantRepository = "https://github.com/Mona/Linked-List" + } + if pkg.Repository != wantRepository { + t.Errorf("repository = %q, want %q", pkg.Repository, wantRepository) + } + versions, err := reg.FetchVersions(context.Background(), "mona.linked-list") + if err != nil || len(versions) != 1 { + t.Fatalf("versions = %#v, %v", versions, err) + } + published := versions[0].PublishedAt + if metadata && !published.Equal(time.Date(2026, 1, 3, 4, 5, 6, 0, time.UTC)) || + !metadata && !published.IsZero() { + t.Errorf("publication time = %v", published) + } + }) + } +} + +func TestSwiftPaginationFailureReturnsNoPartialResults(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery != "" { + http.Error(w, "failed", http.StatusServiceUnavailable) + return + } + w.Header().Set("Link", `; rel="next"`) + _, _ = fmt.Fprint(w, `{"releases":{"1.0.0":{}}}`) + }) + if versions, err := reg.FetchVersions(context.Background(), "mona.linked-list"); err == nil || versions != nil { + t.Errorf("versions = %#v, %v", versions, err) + } +} + +func TestSwiftLatestVersion(t *testing.T) { + tests := []struct { + releases, link, want string + }{ + {`{"2.0.0":{},"3.0.0-beta.1":{}}`, "", "2.0.0"}, + {`{"1.0.0-alpha":{},"1.0.0-beta":{}}`, "", "1.0.0-beta"}, + {`{"2.0.0":{},"3.0.0-beta.1":{}}`, `; rel="latest-version"`, "3.0.0-beta.1"}, + {`{"1.0.0":{},"2.0.0":{"problem":{"status":410}}}`, `; rel="latest-version"`, "1.0.0"}, + } + for _, tt := range tests { + t.Run(tt.releases+tt.link, func(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/registry/mona/linked-list" { + if tt.link != "" { + w.Header().Set("Link", tt.link) + } + _, _ = fmt.Fprintf(w, `{"releases":%s}`, tt.releases) + return + } + _ = json.NewEncoder(w).Encode(swiftRelease(r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:])) + }) + pkg, err := reg.FetchPackage(context.Background(), "mona.linked-list") + if err != nil || pkg.LatestVersion != tt.want { + t.Errorf("latest = %q, want %q, err = %v", pkg.LatestVersion, tt.want, err) + } + }) + } +} + +func TestSwiftPaginationRelCaseInsensitive(t *testing.T) { + reg := newSwiftRegistry(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.RequestURI() { + case "/registry/mona/linked-list": + w.Header().Set("Link", `; rel="Next"`) + _, _ = fmt.Fprint(w, `{"releases":{"1.0.0":{"problem":{"status":410}}}}`) + case "/registry/mona/linked-list?page=2": + _, _ = fmt.Fprint(w, `{"releases":{"2.0.0":{"problem":{"status":410}}}}`) + default: + t.Errorf("unexpected request %s", r.URL) + } + }) + versions, err := reg.FetchVersions(context.Background(), "mona.linked-list") + if err != nil || len(versions) != 2 { + t.Errorf("versions = %#v, %v", versions, err) + } +} + +func TestSwiftReleaseURLOnOtherOrigin(t *testing.T) { + other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("request sent to other origin: %s", r.URL) + _ = json.NewEncoder(w).Encode(swiftRelease("1.0.0")) + })) + t.Cleanup(other.Close) + reg := newSwiftRegistry(t, func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprintf(w, `{"releases":{"1.0.0":{"url":%q}}}`, other.URL+"/1.0.0") + }) + if _, err := reg.FetchPackage(context.Background(), "mona.linked-list"); err == nil { + t.Error("cross-origin release URL accepted") + } +}