diff --git a/docs/architecture.md b/docs/architecture.md index 660d8102..87397aa0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -304,6 +304,7 @@ HTTP protocol handlers for each registry type. **SwiftHandler:** - Proxies the Swift Package Registry v1 read endpoints - Rewrites release URLs and caches source archives +- With `cache_metadata`, caches release metadata, release lists and manifests (with their `Link` header) and serves them when the upstream fails **GitHandler:** - Serves `/git/{name}/{repo}` from `internal/gitcache`, read-only over smart HTTP diff --git a/docs/configuration.md b/docs/configuration.md index f10e4b23..ff93fc28 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1330,6 +1330,8 @@ Some metadata never changes once published, and is cached like an artifact, with Composer archive downloads look up their upstream URL in the same cached metadata, so an archive is resolved without asking Packagist again and stays installable while it is down. Only a version or file the cached metadata does not list yet is looked up upstream. With or without `cache_metadata`, a cached archive of a tagged release from the default repository is served without any metadata lookup; with cooldown enabled it is checked against the publish time recorded when the archive was first resolved. Branch (dev) versions, routed packages and archives picked by a query string such as `?sha=` are always resolved from the metadata. +Swift release lists and manifests are cached with the upstream's `Link` header, which carries the release list's pages and the version-specific manifests a client chooses from. + The `proxy mirror` command always enables metadata caching regardless of this setting. ### Metadata TTL @@ -1571,6 +1573,8 @@ A Helm chart is mirrored from a repository in `upstream.helm`, named as the name A Julia package is mirrored with the source tarball `upstream.julia` serves at `/package/{uuid}/{tree-hash}`, cached under the package's name and tree hash as `/julia/package/...` serves it. The PURL needs the `uuid` qualifier, for example `pkg:julia/Example@0.5.5?uuid=7876af07-990d-54b4-ab0e-23690620f79a`. Only packages of the General registry can be mirrored: the uuid and tree hash are read from General on GitHub, also with another `upstream.julia`, since they are the same on every Pkg server serving General. The tarball isn't checked by the proxy, as when serving; Pkg checks the tree hash of what it unpacks, except on Windows. Dependencies, the artifacts in a package's `Artifacts.toml` and the registry aren't mirrored. An offline `Pkg.add` also needs the registry: one installed on the client, or the one the proxy cached when a client last fetched it, which it serves offline with `cache_metadata` on. +A Swift release is mirrored from `upstream.swift` with its source archive, checked against the SHA-256 checksum in the release metadata and cached under the key `/swift/{scope}/{name}/{version}.zip` serves. Give the registry scope as the namespace, for example `pkg:swift/apple/swift-argument-parser@1.5.0` for `apple.swift-argument-parser`, or the source repository, as SBOM tools write it: `pkg:swift/github.com/apple/swift-argument-parser@1.5.0` is looked up through the upstream's `/identifiers`, and the first identity it lists is mirrored. The release metadata, the package's release list and the release's `Package.swift` with its version-specific manifests are stored in the metadata cache too, so `swift package resolve` works with the upstream unreachable. Serving them, and the archive, offline needs `cache_metadata` on the server, since the archive is served with the checksum from the release metadata. + | Flag | Default | Description | |------|---------|-------------| | `--sbom` | | Path to CycloneDX or SPDX SBOM file | @@ -1591,7 +1595,7 @@ proxy mirror pkg:npm/lodash@4.17.21 pkg:cargo/serde@1.0.0 ### Unversioned PURLs -A PURL without a version, here or in a mirror API job, mirrors every version the configured upstream lists, through the same client, so `upstream.auth` and `upstream.allow_private_hosts` apply. npm packages in `upstream.npm_routes` are listed from their route. Listing works for npm, gem, golang, hex (from `upstream.hex_api`), pub, pypi, maven, nuget, composer, cran and brew. For cargo and conda the upstream doesn't serve the API used for listing, so versions come from the public registry while the upstream is the default one; with another upstream, and for Composer packages in `upstream.composer_routes` or any other ecosystem, give a version. Julia versions are always listed from the General registry. +A PURL without a version, here or in a mirror API job, mirrors every version the configured upstream lists, through the same client, so `upstream.auth` and `upstream.allow_private_hosts` apply. npm packages in `upstream.npm_routes` are listed from their route. Listing works for npm, gem, golang, hex (from `upstream.hex_api`), pub, pypi, maven, nuget, composer, cran, brew and swift. For cargo and conda the upstream doesn't serve the API used for listing, so versions come from the public registry while the upstream is the default one; with another upstream, and for Composer packages in `upstream.composer_routes` or any other ecosystem, give a version. Julia versions are always listed from the General registry. ### Container images diff --git a/internal/handler/relay_test.go b/internal/handler/relay_test.go index 72d20f38..33db80bd 100644 --- a/internal/handler/relay_test.go +++ b/internal/handler/relay_test.go @@ -53,7 +53,6 @@ func TestRelayRoutes(t *testing.T) { "/conan/v2/conans/demo/1.0/user/stable/revisions/rev/files/other.txt", "/composer/search.json", "/pypi/simple/", "/gem/api/v1/dependencies", "/conda/conda-forge/noarch/repodata.json", - "/swift/scope/demo/1.0.0/Package.swift", "/v2/library/demo/manifests/latest", "/v2/library/demo/tags/list", "/npm/-/npm/v1/keys", } { diff --git a/internal/handler/swift.go b/internal/handler/swift.go index d7d403f5..7de0cd12 100644 --- a/internal/handler/swift.go +++ b/internal/handler/swift.go @@ -104,23 +104,23 @@ func (h *SwiftHandler) handlePackageReleases(w http.ResponseWriter, r *http.Requ scope, name = canonicalSwiftPackage(scope, name) upstreamURL := h.buildUpstreamURL(scope, name, "", "", r.URL.RawQuery) - body, contentType, responseHeaders, err := h.fetchMetadataWithHeaders( - r.Context(), upstreamURL, requestAccept(r, swiftAcceptJSON), + doc, err := h.fetchDocument( + r.Context(), swiftReleasesCacheKey(scope, name, r.URL.RawQuery), upstreamURL, requestAccept(r, swiftAcceptJSON), ) if err != nil { h.writeMetadataError(w, err) return } - rewritten, err := h.rewriteReleaseURLs(scope, name, body) + rewritten, err := h.rewriteReleaseURLs(scope, name, doc.body) if err != nil { h.proxy.Logger.Warn("failed to rewrite Swift release URLs", "error", err) - rewritten = body + rewritten = doc.body } - for _, link := range responseHeaders.Values("Link") { - w.Header().Add("Link", h.rewriteLinkHeader(link, upstreamURL)) + if doc.link != "" { + w.Header().Set("Link", h.rewriteLinkHeader(doc.link, upstreamURL)) } - writeSwiftMetadata(w, r, rewritten, contentType) + writeSwiftMetadata(w, r, rewritten, doc.contentType) } func (h *SwiftHandler) handleRelease(w http.ResponseWriter, r *http.Request) { @@ -161,7 +161,17 @@ func (h *SwiftHandler) handleManifest(w http.ResponseWriter, r *http.Request) { scope, name = canonicalSwiftPackage(scope, name) upstreamURL := h.buildUpstreamURL(scope, name, version, "Package.swift", r.URL.RawQuery) - h.proxySwiftResource(w, r, upstreamURL, swiftAcceptManifest) + doc, err := h.fetchDocument( + r.Context(), swiftManifestCacheKey(scope, name, version, r.URL.RawQuery), upstreamURL, requestAccept(r, swiftAcceptManifest), + ) + if err != nil { + h.writeMetadataError(w, err) + return + } + if doc.link != "" { + w.Header().Set("Link", h.rewriteLinkHeader(doc.link, upstreamURL)) + } + writeSwiftMetadata(w, r, doc.body, doc.contentType) } func (h *SwiftHandler) handleIdentifiers(w http.ResponseWriter, r *http.Request) { @@ -194,14 +204,13 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques } scope, name = canonicalSwiftPackage(scope, name) - packageName := scope + "/" + name - filename := fmt.Sprintf("%s-%s.zip", name, version) - upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery) - packagePURL, versionPURL := packageurl.MakeCacheStrings("swift", packageName, version) - if packagePURL == "" || versionPURL == "" { - h.writeArtifactError(w, fmt.Errorf("%w: swift %q", errUnsupportedPackageIdentity, packageName)) + key, err := swiftSourceArchiveKey(scope, name, version) + if err != nil { + h.writeArtifactError(w, err) return } + filename, packagePURL, versionPURL := key.filename, key.packagePURL, key.versionPURL + upstreamURL := h.buildUpstreamURL(scope, name, version+".zip", "", r.URL.RawQuery) archiveInfo, infoErr := h.fetchArchiveInfo(r.Context(), scope, name, version) if infoErr != nil { h.writeArtifactError(w, fmt.Errorf("fetching release metadata: %w", infoErr)) @@ -215,10 +224,7 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques headers := make(http.Header) headers.Set("Accept", requestAccept(r, swiftAcceptArchive)) - result, err := h.proxy.getOrFetchArtifactFromURLWithCachePURLs( - r.Context(), "swift", packageName, version, filename, packagePURL, versionPURL, - upstreamURL, headers, archiveInfo.checksum, "", - ) + result, err := h.fetchSourceArchive(r.Context(), key, version, upstreamURL, headers, archiveInfo) if err != nil { h.writeArtifactError(w, err) return @@ -229,6 +235,32 @@ func (h *SwiftHandler) handleSourceArchive(w http.ResponseWriter, r *http.Reques ServeArtifactRequest(w, r, result) } +// swiftArchiveKey is the cache key a release's source archive is stored +// under. +type swiftArchiveKey struct { + packageName, filename, packagePURL, versionPURL string +} + +// swiftSourceArchiveKey returns the cache key of the source archive of the +// canonical (lowercased) scope and name at version. +func swiftSourceArchiveKey(scope, name, version string) (swiftArchiveKey, error) { + key := swiftArchiveKey{packageName: scope + "/" + name, filename: fmt.Sprintf("%s-%s.zip", name, version)} + key.packagePURL, key.versionPURL = packageurl.MakeCacheStrings("swift", key.packageName, version) + if key.packagePURL == "" || key.versionPURL == "" { + return swiftArchiveKey{}, fmt.Errorf("%w: swift %q", errUnsupportedPackageIdentity, key.packageName) + } + return key, nil +} + +// fetchSourceArchive returns the source archive under key from the cache, or +// fetches it from upstreamURL, verified against the checksum in info. +func (h *SwiftHandler) fetchSourceArchive(ctx context.Context, key swiftArchiveKey, version, upstreamURL string, headers http.Header, info swiftArchiveInfo) (*CacheResult, error) { + return h.proxy.getOrFetchArtifactFromURLWithCachePURLs( + ctx, "swift", key.packageName, version, key.filename, key.packagePURL, key.versionPURL, + upstreamURL, headers, info.checksum, "", + ) +} + func (h *SwiftHandler) handleSourceArchiveHead( w http.ResponseWriter, r *http.Request, @@ -424,56 +456,6 @@ func swiftDigestHeader(checksum string) string { return "sha-256=" + base64.StdEncoding.EncodeToString(digest) } -func (h *SwiftHandler) proxySwiftResource(w http.ResponseWriter, r *http.Request, upstreamURL, defaultAccept string) { - req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil) - if err != nil { - writeSwiftProblem(w, http.StatusInternalServerError, "failed to create upstream request") - return - } - req.Header.Set("Accept", requestAccept(r, defaultAccept)) - for _, name := range []string{"If-Modified-Since", "If-None-Match"} { - if value := r.Header.Get(name); value != "" { - req.Header.Set(name, value) - } - } - - resp, err := h.proxy.HTTPClient.Do(req) - if err != nil { - if writeUpstreamHeld(w, err) { - return - } - writeSwiftProblem(w, http.StatusBadGateway, "upstream request failed") - return - } - defer func() { _ = resp.Body.Close() }() - - h.proxy.relayResponse(w, r, resp, func(dst, src http.Header) { - copySwiftResponseHeaders(dst, src) - if location := src.Get("Location"); location != "" { - dst.Set("Location", h.rewriteRegistryURL(location, upstreamURL)) - } - for _, link := range src.Values("Link") { - dst.Add("Link", h.rewriteLinkHeader(link, upstreamURL)) - } - if dst.Get("Content-Version") == "" { - dst.Set("Content-Version", swiftContentVersion) - } - }) -} - -func copySwiftResponseHeaders(dst, src http.Header) { - for _, name := range []string{ - "Cache-Control", "Content-Disposition", "Content-Language", headerContentLength, - headerContentType, "Content-Version", "Digest", headerETag, headerLastModified, - "Retry-After", "Vary", "Warning", "X-Swift-Package-Signature", - "X-Swift-Package-Signature-Format", - } { - for _, value := range src.Values(name) { - dst.Add(name, value) - } - } -} - func (h *SwiftHandler) rewriteLinkHeader(value, upstreamRequestURL string) string { var result strings.Builder for len(value) > 0 { diff --git a/internal/handler/swift_cache.go b/internal/handler/swift_cache.go new file mode 100644 index 00000000..d7781791 --- /dev/null +++ b/internal/handler/swift_cache.go @@ -0,0 +1,128 @@ +package handler + +import ( + "bytes" + "context" + "database/sql" + "errors" + "net/url" + "strings" + "time" + + "github.com/git-pkgs/proxy/internal/database" +) + +// swiftDocument is a release list or manifest as the upstream sent it. +type swiftDocument struct { + body []byte + contentType string + // link is the upstream Link header, its values joined, with the + // upstream's URLs. + link string + fetchedAt time.Time +} + +// fetchDocument returns the document at upstreamURL, from the metadata cache +// under cacheKey while it is fresh or when the upstream fails, and otherwise +// from upstream, storing it. The upstream Link header is cached with it, +// since it carries the pagination of release lists and the version-specific +// manifests a client picks from. +func (h *SwiftHandler) fetchDocument(ctx context.Context, cacheKey, upstreamURL, accept string) (*swiftDocument, error) { + cached := h.loadDocument(ctx, cacheKey) + if cached != nil && h.proxy.MetadataTTL > 0 && time.Since(cached.fetchedAt) < h.proxy.MetadataTTL { + return cached, nil + } + body, contentType, headers, err := h.fetchMetadataWithHeaders(ctx, upstreamURL, accept) + if err != nil { + if cached == nil || errors.Is(err, ErrUpstreamNotFound) { + return nil, err + } + h.proxy.Logger.Warn("upstream Swift metadata fetch failed, serving cached copy", + "url", upstreamURL, "error", err) + return cached, nil + } + doc := &swiftDocument{ + body: body, + contentType: contentType, + link: strings.Join(headers.Values("Link"), ", "), + fetchedAt: time.Now(), + } + h.storeDocument(ctx, cacheKey, doc) + return doc, nil +} + +func (h *SwiftHandler) documentsCached() bool { + return h.proxy.CacheMetadata && h.proxy.DB != nil && h.proxy.Storage != nil +} + +// loadDocument returns the cached document under cacheKey, or nil. +func (h *SwiftHandler) loadDocument(ctx context.Context, cacheKey string) *swiftDocument { + if !h.documentsCached() { + return nil + } + entry := h.proxy.storedMetadataEntry("swift", cacheKey, true) + if entry == nil { + return nil + } + reader, err := h.proxy.openMetadataEntry(ctx, "swift", cacheKey, entry) + if err != nil { + return nil + } + defer func() { _ = reader.Close() }() + body, err := h.proxy.ReadMetadata(reader) + if err != nil { + return nil + } + h.proxy.recordMetadataRead(entry) + return &swiftDocument{ + body: body, + contentType: entry.ContentType.String, + link: entry.Link.String, + fetchedAt: entry.FetchedAt.Time, + } +} + +// storeDocument caches doc under cacheKey. A failure is logged; the +// document is still served. +func (h *SwiftHandler) storeDocument(ctx context.Context, cacheKey string, doc *swiftDocument) { + if !h.documentsCached() { + return + } + storagePath := metadataStoragePath("swift", cacheKey) + size, hash, err := h.proxy.Storage.Store(ctx, storagePath, bytes.NewReader(doc.body)) + if err == nil { + err = h.proxy.DB.UpsertMetadataCache(&database.MetadataCacheEntry{ + Ecosystem: "swift", + Name: cacheKey, + StoragePath: storagePath, + Link: sql.NullString{String: doc.link, Valid: doc.link != ""}, + ContentType: sql.NullString{String: doc.contentType, Valid: doc.contentType != ""}, + ContentDigest: sql.NullString{String: "sha256:" + hash, Valid: hash != ""}, + Size: sql.NullInt64{Int64: size, Valid: true}, + FetchedAt: sql.NullTime{Time: doc.fetchedAt, Valid: true}, + }) + } + if err != nil { + h.proxy.Logger.Warn("failed to cache Swift metadata", "key", cacheKey, "error", err) + } +} + +func swiftReleasesCacheKey(scope, name, rawQuery string) string { + return swiftMetadataCacheKey("releases", scope, name, rawQuery) +} + +// swiftManifestCacheKey keys a manifest by its swift-version as a tools +// version, so the 5.8 of a manifest's alternate link and the 5.8.0 a client +// requests for it share a key. +func swiftManifestCacheKey(scope, name, version, rawQuery string) string { + if query, err := url.ParseQuery(rawQuery); err == nil { + if tools := query.Get("swift-version"); tools != "" { + for strings.Count(tools, ".") > 0 && strings.HasSuffix(tools, ".0") { + tools = strings.TrimSuffix(tools, ".0") + } + query.Set("swift-version", tools) + } + rawQuery = query.Encode() + } + return swiftMetadataCacheKey("manifest", scope, name, version, rawQuery) +} diff --git a/internal/handler/swift_cache_test.go b/internal/handler/swift_cache_test.go new file mode 100644 index 00000000..2b194a55 --- /dev/null +++ b/internal/handler/swift_cache_test.go @@ -0,0 +1,124 @@ +package handler + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestSwiftReleaseListAndManifestServedFromCacheWhenUpstreamFails(t *testing.T) { + var down atomic.Bool + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if down.Load() { + http.Error(w, "down", http.StatusServiceUnavailable) + return + } + w.Header().Set("Link", `; rel="next"`) + if strings.HasSuffix(r.URL.Path, "/Package.swift") { + w.Header().Set("Content-Type", "text/x-swift") + _, _ = io.WriteString(w, "// swift-tools-version:5.9") + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"releases":{"1.0.0":{}}}`) + })) + defer upstream.Close() + + for _, cacheMetadata := range []bool{true, false} { + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = cacheMetadata + routes := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes() + get := func(path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + return w + } + down.Store(false) + for _, path := range []string{"/apple/example", "/apple/example/1.0.0/Package.swift"} { + if w := get(path); w.Code != http.StatusOK { + t.Fatalf("%s: status = %d with the upstream up", path, w.Code) + } + } + + down.Store(true) + for path, body := range map[string]string{"/apple/example": `"1.0.0"`, "/apple/example/1.0.0/Package.swift": "swift-tools-version:5.9"} { + w := get(path) + if !cacheMetadata { + if w.Code == http.StatusOK { + t.Errorf("%s: served without cache_metadata while the upstream is down", path) + } + continue + } + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), body) { + t.Errorf("%s: status = %d, body %q; want the cached copy", path, w.Code, w.Body.String()) + } + if link := w.Header().Get("Link"); link != `; rel="next"` { + t.Errorf("%s: Link = %q, want the cached Link rewritten", path, link) + } + } + } +} + +// TestSwiftCorruptManifestRefetched checks that a cached manifest whose +// stored bytes no longer match the recorded digest is fetched again, even +// within metadata_ttl. +func TestSwiftCorruptManifestRefetched(t *testing.T) { + var hits atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.Header().Set("Content-Type", "text/x-swift") + _, _ = io.WriteString(w, "// swift-tools-version:5.9") + })) + defer upstream.Close() + + proxy, db, store, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + routes := NewSwiftHandler(proxy, "https://proxy.example", upstream.URL+"/registry").Routes() + get := func() *httptest.ResponseRecorder { + w := httptest.NewRecorder() + routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/apple/example/1.0.0/Package.swift", nil)) + return w + } + + get() + entry, err := db.GetMetadataCache("swift", swiftManifestCacheKey("apple", "example", "1.0.0", "")) + if err != nil || entry == nil { + t.Fatalf("manifest not cached: %v", err) + } + if !strings.HasPrefix(entry.ContentDigest.String, "sha256:") { + t.Fatalf("ContentDigest = %q, want a sha256 digest", entry.ContentDigest.String) + } + if w := get(); hits.Load() != 1 || w.Code != http.StatusOK { + t.Fatalf("fresh cached manifest: status %d, %d upstream fetches; want it served from the cache", w.Code, hits.Load()) + } + + if _, _, err := store.Store(context.Background(), entry.StoragePath, strings.NewReader("// tampered")); err != nil { + t.Fatal(err) + } + w := get() + if hits.Load() != 2 { + t.Errorf("upstream fetches = %d, want the corrupt manifest fetched again", hits.Load()) + } + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "swift-tools-version:5.9") { + t.Errorf("status = %d, body %q; want the upstream manifest", w.Code, w.Body.String()) + } +} + +func TestSwiftManifestCacheKeyIgnoresToolsVersionZeros(t *testing.T) { + key := swiftManifestCacheKey("apple", "example", "1.0.0", "swift-version=5.8") + if got := swiftManifestCacheKey("apple", "example", "1.0.0", "swift-version=5.8.0"); got != key { + t.Error("5.8.0 and 5.8 have different keys") + } + if got := swiftManifestCacheKey("apple", "example", "1.0.0", "swift-version=5.9"); got == key { + t.Error("5.9 and 5.8 share a key") + } + if got := swiftManifestCacheKey("apple", "example", "1.0.0", ""); got == key { + t.Error("Package.swift and the 5.8 manifest share a key") + } +} diff --git a/internal/handler/swift_mirror.go b/internal/handler/swift_mirror.go new file mode 100644 index 00000000..d3944e3b --- /dev/null +++ b/internal/handler/swift_mirror.go @@ -0,0 +1,157 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" +) + +// MirrorRelease caches release version of scope.name so that a client can +// resolve and fetch the release without the upstream. +func (h *SwiftHandler) MirrorRelease(ctx context.Context, scope, name, version string) (*CacheResult, error) { + if !validSwiftPackageReference(scope, name, version) { + return nil, fmt.Errorf("invalid Swift registry release %s.%s@%s", scope, name, version) + } + scope, name = canonicalSwiftPackage(scope, name) + key, err := swiftSourceArchiveKey(scope, name, version) + if err != nil { + return nil, err + } + if h.documentsCached() { + if err := h.mirrorReleaseList(ctx, scope, name); err != nil { + return nil, fmt.Errorf("fetching release list: %w", err) + } + if err := h.mirrorManifests(ctx, scope, name, version); err != nil { + return nil, fmt.Errorf("fetching manifest: %w", err) + } + } + info, err := h.fetchArchiveInfo(ctx, scope, name, version) + if err != nil { + return nil, fmt.Errorf("fetching release metadata: %w", err) + } + headers := http.Header{"Accept": {swiftAcceptArchive}} + return h.fetchSourceArchive(ctx, key, version, h.buildUpstreamURL(scope, name, version+".zip", "", ""), headers, info) +} + +// maxSwiftReleasePages bounds the release list pages a mirror follows. +const maxSwiftReleasePages = 100 + +// mirrorReleaseList caches every page of the release list, under the keys +// the release list route reads for the page links it rewrites. +func (h *SwiftHandler) mirrorReleaseList(ctx context.Context, scope, name string) error { + query := "" + seen := map[string]bool{} + for page := 0; page < maxSwiftReleasePages && !seen[query]; page++ { + seen[query] = true + upstreamURL := h.buildUpstreamURL(scope, name, "", "", query) + doc, err := h.fetchDocument(ctx, swiftReleasesCacheKey(scope, name, query), upstreamURL, swiftAcceptJSON) + if err != nil { + return err + } + next := swiftLinkQueries(doc.link, "next", upstreamURL) + if len(next) == 0 { + return nil + } + query = next[0] + } + return nil +} + +// mirrorManifests caches Package.swift of a release and the version-specific +// manifests its Link header lists, under the keys the manifest route reads +// for the alternate links it rewrites. +func (h *SwiftHandler) mirrorManifests(ctx context.Context, scope, name, version string) error { + upstreamURL := h.buildUpstreamURL(scope, name, version, "Package.swift", "") + doc, err := h.fetchDocument(ctx, swiftManifestCacheKey(scope, name, version, ""), upstreamURL, swiftAcceptManifest) + if err != nil { + return err + } + for _, query := range swiftLinkQueries(doc.link, "alternate", upstreamURL) { + alternateURL := h.buildUpstreamURL(scope, name, version, "Package.swift", query) + if _, err := h.fetchDocument(ctx, swiftManifestCacheKey(scope, name, version, query), alternateURL, swiftAcceptManifest); err != nil { + return fmt.Errorf("%s: %w", query, err) + } + } + return nil +} + +// swiftLinkQueries returns the queries of the links with relation rel in +// link, a Link header, that point at requestURL's own path with a query, +// such as the next page of a release list or a version-specific manifest. +func swiftLinkQueries(link, rel, requestURL string) []string { + base, err := url.Parse(requestURL) + if err != nil { + return nil + } + var queries []string + for _, value := range strings.Split(link, ",") { + target, params, ok := strings.Cut(strings.TrimSpace(value), ";") + if !ok || !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") || !swiftLinkHasRel(params, rel) { + continue + } + ref, err := url.Parse(strings.TrimSpace(target[1 : len(target)-1])) + if err != nil { + continue + } + resolved := base.ResolveReference(ref) + if resolved.Host != base.Host || resolved.EscapedPath() != base.EscapedPath() || resolved.RawQuery == "" { + continue + } + queries = append(queries, resolved.RawQuery) + } + return queries +} + +func swiftLinkHasRel(params, rel string) bool { + for _, param := range strings.Split(params, ";") { + key, value, ok := strings.Cut(strings.TrimSpace(param), "=") + if !ok || !strings.EqualFold(strings.TrimSpace(key), "rel") { + continue + } + for _, r := range strings.Fields(strings.Trim(strings.TrimSpace(value), `"`)) { + if strings.EqualFold(r, rel) { + return true + } + } + } + return false +} + +// ResolveIdentity returns the registry scope and name the upstream lists for +// the source repository at sourceURL. The answer is cached as the identifiers +// route caches it. +func (h *SwiftHandler) ResolveIdentity(ctx context.Context, sourceURL string) (scope, name string, _ error) { + query := url.Values{"url": {sourceURL}}.Encode() + body, _, err := h.proxy.FetchOrCacheMetadata(ctx, "swift", swiftMetadataCacheKey("identifiers", query), + h.upstreamURL+"/identifiers?"+query, swiftAcceptJSON) + if errors.Is(err, ErrUpstreamNotFound) { + return "", "", fmt.Errorf("the Swift registry has no package for %s", sourceURL) + } + if err != nil { + return "", "", err + } + return SwiftIdentityOf(body, sourceURL) +} + +// SwiftIdentityOf returns the scope and name of the first identity in body, +// a registry's answer to an identifiers lookup for sourceURL. +func SwiftIdentityOf(body []byte, sourceURL string) (scope, name string, _ error) { + var answer struct { + Identifiers []string `json:"identifiers"` + } + if err := json.Unmarshal(body, &answer); err != nil { + return "", "", fmt.Errorf("parsing the Swift registry's identifiers for %s: %w", sourceURL, err) + } + if len(answer.Identifiers) == 0 { + return "", "", fmt.Errorf("the Swift registry has no package for %s", sourceURL) + } + scope, name, ok := strings.Cut(answer.Identifiers[0], ".") + if !ok || scope == "" || name == "" { + return "", "", fmt.Errorf("the Swift registry answered %s with the invalid identity %q", sourceURL, answer.Identifiers[0]) + } + return scope, name, nil +} diff --git a/internal/handler/swift_mirror_test.go b/internal/handler/swift_mirror_test.go new file mode 100644 index 00000000..8359a5ed --- /dev/null +++ b/internal/handler/swift_mirror_test.go @@ -0,0 +1,22 @@ +package handler + +import ( + "reflect" + "testing" +) + +func TestSwiftLinkQueries(t *testing.T) { + const manifest = "https://registry.example/swift/apple/example/1.0.0/Package.swift" + link := `; rel="alternate"; filename="Package@swift-5.8.swift", ` + + `; rel=alternate, ` + + `; rel="alternate", ` + + `; rel="alternate", ` + + `; rel="canonical"` + + got := swiftLinkQueries(link, "alternate", manifest) + + want := []string{"swift-version=5.8", "swift-version=5.7"} + if !reflect.DeepEqual(got, want) { + t.Errorf("swiftLinkQueries() = %q, want %q", got, want) + } +} diff --git a/internal/mirror/mirror.go b/internal/mirror/mirror.go index dd879b09..9b55a8c8 100644 --- a/internal/mirror/mirror.go +++ b/internal/mirror/mirror.go @@ -251,6 +251,10 @@ func (m *Mirror) mirrorOne(ctx context.Context, pv PackageVersion, tracker *prog m.mirrorJulia(ctx, pv, tracker) return } + if pv.Ecosystem == "swift" { + m.mirrorSwift(ctx, pv, tracker) + return + } result, err := m.proxy.GetOrFetchArtifact(ctx, pv.Ecosystem, pv.Name, pv.Version, "") if err != nil { m.recordFailure(tracker, pv, err) diff --git a/internal/mirror/swift.go b/internal/mirror/swift.go new file mode 100644 index 00000000..4616c8f2 --- /dev/null +++ b/internal/mirror/swift.go @@ -0,0 +1,67 @@ +package mirror + +import ( + "context" + "fmt" + "strings" + + "github.com/git-pkgs/proxy/internal/handler" +) + +// mirrorSwift mirrors the source archive of a Swift registry release, with +// its release metadata, from the registry the proxy serves Swift packages +// from. +func (m *Mirror) mirrorSwift(ctx context.Context, pv PackageVersion, tracker *progressTracker) { + upstream, err := m.registryURL("swift", pv.Name) + if err != nil { + m.recordFailure(tracker, pv, err) + return + } + h := handler.NewSwiftHandler(m.proxy, "", upstream) + var scope, name string + if source := swiftSourceURL(pv.Name); source != "" { + scope, name, err = h.ResolveIdentity(ctx, source) + } else { + scope, name, err = swiftScopeAndName(pv.Name) + } + if err != nil { + m.recordFailure(tracker, pv, err) + return + } + result, err := h.MirrorRelease(ctx, scope, name, pv.Version) + if err != nil { + m.recordFailure(tracker, pv, err) + return + } + m.recordResult(tracker, pv, result) +} + +// registryURL returns the registry base URL Versions lists purlType packages +// from, which is the configured upstream, or empty for the default one. +func (m *Mirror) registryURL(purlType, name string) (string, error) { + if r, ok := m.Versions.(RegistryVersions); ok && r.URL != nil { + return r.URL(purlType, name) + } + return "", nil +} + +// swiftSourceURL returns the repository URL of a pkg:swift name whose +// namespace starts with a host, as in pkg:swift/github.com/apple/swift-nio, +// or "" for a name whose namespace is a registry scope. +func swiftSourceURL(name string) string { + if host, _, ok := strings.Cut(name, "/"); ok && strings.Contains(host, ".") { + return "https://" + name + } + return "" +} + +// swiftScopeAndName splits the name of a pkg:swift PURL whose namespace is +// a registry scope, as in pkg:swift/apple/swift-nio for the identity +// apple.swift-nio. +func swiftScopeAndName(name string) (scope, pkg string, _ error) { + scope, pkg, ok := strings.Cut(name, "/") + if !ok || scope == "" || pkg == "" || strings.ContainsAny(scope, ".:") || strings.Contains(pkg, "/") { + return "", "", fmt.Errorf("pkg:swift/%s needs a registry scope or a source repository as the namespace, such as pkg:swift/apple/swift-nio or pkg:swift/github.com/apple/swift-nio", name) + } + return scope, pkg, nil +} diff --git a/internal/mirror/swift_test.go b/internal/mirror/swift_test.go new file mode 100644 index 00000000..4ae0764a --- /dev/null +++ b/internal/mirror/swift_test.go @@ -0,0 +1,43 @@ +package mirror + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/git-pkgs/registries" +) + +func TestSwiftIdentity(t *testing.T) { + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/identifiers" || r.Header.Get("Accept") != "application/vnd.swift.registry.v1+json" { + http.Error(w, "unexpected request", http.StatusBadRequest) + return + } + switch r.URL.Query().Get("url") { + case "https://github.com/apple/swift-nio": + _, _ = w.Write([]byte(`{"identifiers":["apple.swift-nio","mirror.swift-nio"]}`)) + case "https://github.com/acme/empty": + _, _ = w.Write([]byte(`{"identifiers":[]}`)) + default: + http.NotFound(w, r) + } + })) + defer registry.Close() + + for name, want := range map[string]string{ + "apple/swift-nio": "apple.swift-nio", + "github.com/apple/swift-nio": "apple.swift-nio", + "github.com/acme/empty": "", + "github.com/acme/unknown": "", + "swift-nio": "", + "apple/": "", + "apple/swift-nio/extra": "", + } { + got, err := swiftIdentity(context.Background(), registries.DefaultClient(), registry.URL, name) + if got != want || (want == "") != (err != nil) { + t.Errorf("swiftIdentity(%q) = %q, %v; want %q", name, got, err, want) + } + } +} diff --git a/internal/mirror/versions.go b/internal/mirror/versions.go index f56fba62..8e10edab 100644 --- a/internal/mirror/versions.go +++ b/internal/mirror/versions.go @@ -2,9 +2,15 @@ package mirror import ( "context" + "encoding/json" + "errors" "fmt" + "net/http" + "net/url" "strings" + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/handler" "github.com/git-pkgs/purl" "github.com/git-pkgs/registries" _ "github.com/git-pkgs/registries/all" @@ -62,10 +68,19 @@ func (r RegistryVersions) fetchVersions(ctx context.Context, ecosystem, name str if err != nil { return nil, err } - if purl.EcosystemToPURLType(ecosystem) == "helm" { + switch purl.EcosystemToPURLType(ecosystem) { + case "helm": // The namespace names the repository, which URL resolves to the // index the chart is listed in. _, name, _ = strings.Cut(name, "/") + case "swift": + var baseURL string + if baseURL, err = r.baseURL("swift", name); err != nil { + return nil, err + } + if name, err = swiftIdentity(ctx, r.client(), baseURL, name); err != nil { + return nil, err + } } return reg.FetchVersions(ctx, name) } @@ -73,16 +88,54 @@ func (r RegistryVersions) fetchVersions(ctx context.Context, ecosystem, name str // registry returns the client for the registry name is listed in. func (r RegistryVersions) registry(ecosystem, name string) (registries.Registry, error) { //nolint:ireturn // registries.New returns the interface purlType := purl.EcosystemToPURLType(ecosystem) - var baseURL string - if r.URL != nil { - var err error - if baseURL, err = r.URL(purlType, name); err != nil { - return nil, err + baseURL, err := r.baseURL(purlType, name) + if err != nil { + return nil, err + } + return registries.New(purlType, baseURL, r.client()) +} + +func (r RegistryVersions) baseURL(purlType, name string) (string, error) { + if r.URL == nil { + return "", nil + } + return r.URL(purlType, name) +} + +func (r RegistryVersions) client() *registries.Client { + if r.Client == nil { + return registries.DefaultClient() + } + return r.Client +} + +// swiftIdentity returns the registry identity, scope.name, of the name of a +// pkg:swift PURL, looking a source repository up on the registry at baseURL. +func swiftIdentity(ctx context.Context, client *registries.Client, baseURL, name string) (string, error) { + source := swiftSourceURL(name) + if source == "" { + scope, pkg, err := swiftScopeAndName(name) + if err != nil { + return "", err } + return scope + "." + pkg, nil } - client := r.Client - if client == nil { - client = registries.DefaultClient() + if baseURL == "" { + baseURL = config.DefaultSwiftUpstream + } + var body json.RawMessage + _, err := client.GetJSONWithHeaders(ctx, + strings.TrimSuffix(baseURL, "/")+"/identifiers?"+url.Values{"url": {source}}.Encode(), + http.Header{"Accept": {"application/vnd.swift.registry.v1+json"}}, &body) + if httpErr := (*registries.HTTPError)(nil); errors.As(err, &httpErr) && httpErr.IsNotFound() { + return "", fmt.Errorf("the Swift registry has no package for %s", source) + } + if err != nil { + return "", err + } + scope, pkg, err := handler.SwiftIdentityOf(body, source) + if err != nil { + return "", err } - return registries.New(purlType, baseURL, client) + return scope + "." + pkg, nil } diff --git a/internal/server/mirror_swift_test.go b/internal/server/mirror_swift_test.go new file mode 100644 index 00000000..a7050c37 --- /dev/null +++ b/internal/server/mirror_swift_test.go @@ -0,0 +1,223 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/git-pkgs/proxy/internal/config" + "github.com/git-pkgs/proxy/internal/database" + "github.com/git-pkgs/proxy/internal/handler" + "github.com/git-pkgs/proxy/internal/mirror" + "github.com/git-pkgs/proxy/internal/storage" +) + +// swiftUpstream serves the Swift registry API for acme.widget with releases +// 1.0.0, on the first page of the release list, and 1.1.0, on the second. +// Each release has a Package.swift that links a manifest for Swift 5.8. +type swiftUpstream struct { + *httptest.Server + // checksum overrides the archive checksum the release metadata lists. + checksum string + hits atomic.Int32 + archiveHits atomic.Int32 +} + +func swiftArchive(version string) string { return "zip of acme.widget " + version } + +func newSwiftUpstream(t *testing.T) *swiftUpstream { + t.Helper() + u := &swiftUpstream{} + u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + u.hits.Add(1) + w.Header().Set("Content-Version", "1") + rest, ok := strings.CutPrefix(r.URL.Path, "/acme/widget") + switch { + case r.URL.Path == "/identifiers": + if r.URL.Query().Get("url") != "https://github.com/acme/widget" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"identifiers":["acme.widget"]}`) + case !ok: + http.NotFound(w, r) + case rest == "": + page := map[string]string{"": "1.0.0", "page=2": "1.1.0"}[r.URL.RawQuery] + if page == "" { + http.NotFound(w, r) + return + } + if r.URL.RawQuery == "" { + w.Header().Set("Link", fmt.Sprintf(`<%s/acme/widget?page=2>; rel="next"`, u.URL)) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"releases": map[string]any{ + page: map[string]string{"url": u.URL + "/acme/widget/" + page}, + }}) + case strings.HasSuffix(rest, ".zip"): + u.archiveHits.Add(1) + _, _ = io.WriteString(w, swiftArchive(strings.TrimSuffix(rest[1:], ".zip"))) + case strings.HasSuffix(rest, "/Package.swift"): + version := strings.TrimSuffix(rest[1:], "/Package.swift") + w.Header().Set("Content-Type", "text/x-swift") + if r.URL.Query().Get("swift-version") == "5.8" { + _, _ = io.WriteString(w, "// swift-tools-version:5.8\n// "+version) + return + } + w.Header().Set("Link", fmt.Sprintf(`<%s/acme/widget/%s/Package.swift?swift-version=5.8>; rel="alternate"; filename="Package@swift-5.8.swift"; swift-tools-version="5.8"`, u.URL, version)) + _, _ = io.WriteString(w, "// swift-tools-version:5.9\n// "+version) + default: + version := rest[1:] + sum := sha256.Sum256([]byte(swiftArchive(version))) + checksum := hex.EncodeToString(sum[:]) + if u.checksum != "" { + checksum = u.checksum + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "acme.widget", "version": version, + "resources": []map[string]string{{"name": "source-archive", "type": "application/zip", "checksum": checksum}}, + }) + } + })) + t.Cleanup(u.Close) + return u +} + +// mirrorSwift mirrors purls from upstream the way proxy mirror does, with +// metadata caching on. +func mirrorSwift(t *testing.T, upstream *swiftUpstream, purls ...string) (*mirror.Progress, *handler.Proxy) { + t.Helper() + cfg := config.Default() + cfg.Upstream.Swift = upstream.URL + cfg.Upstream.AllowLoopback = true + db, err := database.Create(t.TempDir() + "/test.db") + if err != nil { + t.Fatal(err) + } + if err := db.MigrateSchema(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + store, err := storage.OpenBucket(context.Background(), "file://"+t.TempDir()) + if err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + proxy, _ := NewMirrorProxy(cfg, db, store, logger) + proxy.CacheMetadata = true + m := mirror.New(proxy, db, store, logger, 1) + m.Versions = NewMirrorVersions(cfg.Upstream, proxy.HTTPClient) + progress, err := m.Run(context.Background(), &mirror.PURLSource{PURLs: purls, Versions: m.Versions}) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + return progress, proxy +} + +func swiftRegistryGet(t *testing.T, routes http.Handler, path, accept string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Accept", accept) + rec := httptest.NewRecorder() + routes.ServeHTTP(rec, req) + return rec +} + +func TestMirrorSwiftReleaseIsServedFromCache(t *testing.T) { + upstream := newSwiftUpstream(t) + + progress, proxy := mirrorSwift(t, upstream, "pkg:swift/Acme/Widget@1.0.0") + + if progress.Completed != 1 || upstream.archiveHits.Load() != 1 { + t.Fatalf("completed = %d, archive hits = %d, errors = %v", progress.Completed, upstream.archiveHits.Load(), progress.Errors) + } + + // SwiftPM resolves and downloads the release with the upstream down: + // the release list and its pages, the release metadata, both manifests + // and the archive come from the cache. + upstream.Close() + routes := handler.NewSwiftHandler(proxy, "http://proxy.test", upstream.URL).Routes() + const jsonType = "application/vnd.swift.registry.v1+json" + const swiftType = "application/vnd.swift.registry.v1+swift" + checks := []struct { + path, accept, body, link string + }{ + {"/acme/widget", jsonType, "http://proxy.test/swift/acme/widget/1.0.0", ""}, + {"/acme/widget?page=2", jsonType, "http://proxy.test/swift/acme/widget/1.1.0", ""}, + {"/acme/widget/1.0.0", jsonType, `"source-archive"`, ""}, + {"/acme/widget/1.0.0/Package.swift", swiftType, "swift-tools-version:5.9", ""}, + {"/acme/widget/1.0.0/Package.swift?swift-version=5.8.0", swiftType, "swift-tools-version:5.8", ""}, + {"/acme/widget/1.0.0.zip", "application/vnd.swift.registry.v1+zip", swiftArchive("1.0.0"), ""}, + } + for _, check := range checks { + rec := swiftRegistryGet(t, routes, check.path, check.accept) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), check.body) { + t.Errorf("%s: status = %d, body %q; want 200 with %q", check.path, rec.Code, strings.TrimSpace(rec.Body.String()), check.body) + } + if link := rec.Header().Get("Link"); !strings.Contains(link, check.link) { + t.Errorf("%s: Link = %q, want %q", check.path, link, check.link) + } + } +} + +func TestMirrorSwiftRefusesChecksumMismatch(t *testing.T) { + upstream := newSwiftUpstream(t) + upstream.checksum = strings.Repeat("0", 64) + + progress, proxy := mirrorSwift(t, upstream, "pkg:swift/acme/widget@1.0.0") + + if progress.Failed != 1 || progress.Completed != 0 { + t.Fatalf("completed = %d, failed = %d, errors = %v; want the archive refused", progress.Completed, progress.Failed, progress.Errors) + } + upstream.Close() + routes := handler.NewSwiftHandler(proxy, "http://proxy.test", upstream.URL).Routes() + if rec := swiftRegistryGet(t, routes, "/acme/widget/1.0.0.zip", "application/vnd.swift.registry.v1+zip"); rec.Code == http.StatusOK { + t.Error("the refused archive was cached") + } +} + +func TestMirrorSwiftListsReleases(t *testing.T) { + upstream := newSwiftUpstream(t) + + progress, _ := mirrorSwift(t, upstream, "pkg:swift/acme/widget") + + if progress.Total != 2 || progress.Completed != 2 || upstream.archiveHits.Load() != 2 { + t.Errorf("total = %d, completed = %d, archive hits = %d, errors = %v; want both releases mirrored", + progress.Total, progress.Completed, upstream.archiveHits.Load(), progress.Errors) + } +} + +func TestMirrorSwiftResolvesRepositoryPURLs(t *testing.T) { + upstream := newSwiftUpstream(t) + + progress, proxy := mirrorSwift(t, upstream, "pkg:swift/github.com/acme/widget@1.0.0", "pkg:swift/github.com/acme/widget", "pkg:swift/github.com/acme/other@1.0.0") + + // The listed PURL's 1.0.0 is the versioned one, so it's mirrored once. + if progress.Completed != 2 || progress.Failed != 1 || upstream.archiveHits.Load() != 2 { + t.Fatalf("completed = %d, failed = %d, archive hits = %d, errors = %v; want both acme.widget releases mirrored and the unknown repository refused", + progress.Completed, progress.Failed, upstream.archiveHits.Load(), progress.Errors) + } + if len(progress.Errors) != 1 || !strings.Contains(progress.Errors[0].Error, "no package for https://github.com/acme/other") { + t.Errorf("errors = %v; want the unknown repository named", progress.Errors) + } + + // The lookup is cached too, for clients that resolve a repository URL + // through the registry. + upstream.Close() + routes := handler.NewSwiftHandler(proxy, "http://proxy.test", upstream.URL).Routes() + rec := swiftRegistryGet(t, routes, "/identifiers?url=https%3A%2F%2Fgithub.com%2Facme%2Fwidget", "application/vnd.swift.registry.v1+json") + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "acme.widget") { + t.Errorf("identifiers: status = %d, body %q; want the cached lookup", rec.Code, rec.Body.String()) + } +} diff --git a/internal/server/mirror_versions.go b/internal/server/mirror_versions.go index 4fca4b99..59c6c3ff 100644 --- a/internal/server/mirror_versions.go +++ b/internal/server/mirror_versions.go @@ -103,6 +103,8 @@ func mirrorVersionsURL(u config.UpstreamConfig, purlType, name string) (string, return "", fmt.Errorf("unknown Helm repository %q: it isn't in upstream.helm", repository) } return strings.TrimSuffix(repositoryURL, "/"), nil + case "swift": + return upstreamOr(u.Swift, d.Swift), nil default: return "", fmt.Errorf("listing %s versions isn't supported; give a version", purlType) } diff --git a/internal/server/mirror_versions_test.go b/internal/server/mirror_versions_test.go index f707bb7a..a57060ec 100644 --- a/internal/server/mirror_versions_test.go +++ b/internal/server/mirror_versions_test.go @@ -100,6 +100,7 @@ func TestMirrorVersionsURL(t *testing.T) { custom.Cargo = "https://index.example.com" custom.Conda = "https://conda.example.com" custom.Julia = "https://julia.example.com" + custom.Swift = "https://swift.example.com/registry/" tests := []struct { name string @@ -132,7 +133,7 @@ func TestMirrorVersionsURL(t *testing.T) { {"helm", custom, "helm", "stable/nginx", "https://charts.example.com/stable", ""}, {"helm without repository", custom, "helm", "nginx", "", "namespace"}, {"helm unknown repository", custom, "helm", "other/nginx", "", "upstream.helm"}, - {"swift", custom, "swift", "apple/swift-nio", "", "give a version"}, + {"swift", custom, "swift", "apple.swift-nio", "https://swift.example.com/registry", ""}, {"apk", custom, "apk", "curl", "", "give a version"}, } for _, tt := range tests {