diff --git a/README.md b/README.md index 16eb45a2..b6338b1e 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ The proxy never uploads artifact bytes to a scanner. Each scanner is notified wi | Julia | Julia | | ✓ | | Swift | Swift | | ✓ | | Container | Docker/OCI | | ✓ | +| Homebrew | macOS/Linux | | ✓ | | Debian | Debian/Ubuntu | | ✓ | | RPM | RHEL/Fedora | | ✓ | | Alpine | Alpine Linux | | ✓ | @@ -177,6 +178,29 @@ export GOPROXY=http://localhost:8080/go,direct Or in your shell profile for persistence. +### Homebrew + +Point Homebrew's JSON API and artifact domain at the proxy: + +```bash +export HOMEBREW_API_DOMAIN=http://localhost:8080/homebrew +export HOMEBREW_ARTIFACT_DOMAIN=http://localhost:8080 +``` + +The artifact domain proxies manifests and bottle blobs under `/v2/homebrew/core/`. GHCR routing is limited to that repository. Source archives, cask application downloads, custom tap artifacts, and legacy flat-file bottle mirrors use Homebrew's normal fallback URLs. Keep fallback enabled by leaving `HOMEBREW_ARTIFACT_DOMAIN_NO_FALLBACK` unset. + +Enable `cache_metadata` or set `PROXY_CACHE_METADATA=true` to retain Homebrew JSON API responses for offline fallback. Bottle blobs and their OCI manifests are cached without this setting. + +The upstreams default to `https://formulae.brew.sh/api` for the JSON API and `https://ghcr.io` for artifacts. To chain this proxy to another proxy, configure its Homebrew endpoints as the upstreams: + +```yaml +upstream: + homebrew_api: "https://upstream-proxy.example.com/homebrew" + homebrew_artifact: "https://upstream-proxy.example.com" +``` + +The equivalent environment variables are `PROXY_UPSTREAM_HOMEBREW_API` and `PROXY_UPSTREAM_HOMEBREW_ARTIFACT`. + ### Hex (Elixir) Configure in `~/.hex/hex.config`: @@ -810,7 +834,9 @@ Recently cached: | `GET /julia/*` | Julia Pkg server protocol | | `GET /swift/*` | Swift Package Registry v1 protocol | | `GET /helm/{repository}/*` | HTTP Helm chart repository protocol | +| `GET /homebrew/*` | Homebrew JSON API | | `GET /v2/*` | OCI/Docker registry protocol | +| `GET /v2/homebrew/core/*` | Homebrew core bottle manifests and blobs from GHCR | | `GET /apk/{repository}/*` | Alpine APK repository protocol | | `GET /debian/*` | Debian/APT repository protocol | | `GET /rpm/*` | RPM/Yum repository protocol | diff --git a/config.example.yaml b/config.example.yaml index f348ba4f..404c1c39 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -177,6 +177,12 @@ upstream: # RPM repository URL (used by /rpm endpoint) rpm: "https://dl.fedoraproject.org/pub/fedora/linux" + # Homebrew JSON API URL (used by /homebrew endpoint) + homebrew_api: "https://formulae.brew.sh/api" + + # Homebrew artifact registry URL (used for /v2/homebrew/core requests) + homebrew_artifact: "https://ghcr.io" + # Named HTTP Helm chart repositories (used by /helm/{name}/) # helm: # bitnami: "https://charts.bitnami.com/bitnami" diff --git a/docs/configuration.md b/docs/configuration.md index 6bf1954a..1ca63526 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -165,6 +165,8 @@ Each upstream used by a built-in package route can be set in YAML or JSON under | `upstream.oci_default` | `PROXY_UPSTREAM_OCI_DEFAULT` | `https://registry-1.docker.io` | | `upstream.debian` | `PROXY_UPSTREAM_DEBIAN` | `http://deb.debian.org/debian` | | `upstream.rpm` | `PROXY_UPSTREAM_RPM` | `https://dl.fedoraproject.org/pub/fedora/linux` | +| `upstream.homebrew_api` | `PROXY_UPSTREAM_HOMEBREW_API` | `https://formulae.brew.sh/api` | +| `upstream.homebrew_artifact` | `PROXY_UPSTREAM_HOMEBREW_ARTIFACT` | `https://ghcr.io` | Private, ULA, CGNAT, and loopback addresses are rejected by default. Add each private upstream hostname or IP address to `upstream.allow_private_hosts`. The matching environment variable accepts a comma-separated list. Loopback upstreams also require `upstream.allow_loopback: true`. That setting permits upstream requests and redirects to reach any loopback address. diff --git a/internal/config/config.go b/internal/config/config.go index eef19b3c..9a856ab9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -558,6 +558,14 @@ type UpstreamConfig struct { // Default: https://dl.fedoraproject.org/pub/fedora/linux RPM string `json:"rpm" yaml:"rpm"` + // HomebrewAPI is the upstream Homebrew JSON API URL. + // Default: https://formulae.brew.sh/api + HomebrewAPI string `json:"homebrew_api" yaml:"homebrew_api"` + + // HomebrewArtifact is the upstream registry URL for Homebrew artifacts. + // Default: https://ghcr.io + HomebrewArtifact string `json:"homebrew_artifact" yaml:"homebrew_artifact"` + // Helm maps repository names to HTTP Helm chart repository URLs. // Requests use /helm/{name}/index.yaml and chart URLs in the index are // rewritten to the same named proxy endpoint. @@ -748,6 +756,8 @@ func Default() *Config { OCIDefault: "https://registry-1.docker.io", Debian: "http://deb.debian.org/debian", RPM: "https://dl.fedoraproject.org/pub/fedora/linux", + HomebrewAPI: "https://formulae.brew.sh/api", + HomebrewArtifact: "https://ghcr.io", }, Gradle: GradleConfig{ BuildCache: GradleBuildCacheConfig{ @@ -879,6 +889,8 @@ func (c *Config) LoadFromEnv() { setEnvString(&c.Upstream.OCIDefault, "PROXY_UPSTREAM_OCI_DEFAULT") setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN") setEnvString(&c.Upstream.RPM, "PROXY_UPSTREAM_RPM") + setEnvString(&c.Upstream.HomebrewAPI, "PROXY_UPSTREAM_HOMEBREW_API") + setEnvString(&c.Upstream.HomebrewArtifact, "PROXY_UPSTREAM_HOMEBREW_ARTIFACT") setEnvString(&c.Cooldown.Default, "PROXY_COOLDOWN_DEFAULT") setEnvBool(&c.Scanning.Enabled, "PROXY_SCANNING_ENABLED") setEnvBool(&c.Scanning.FailOpen, "PROXY_SCANNING_FAIL_OPEN") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1735c212..3e806a8f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -40,6 +40,8 @@ func upstreamConfigValues(upstream UpstreamConfig) map[string]string { "oci_default": upstream.OCIDefault, "debian": upstream.Debian, "rpm": upstream.RPM, + "homebrew_api": upstream.HomebrewAPI, + "homebrew_artifact": upstream.HomebrewArtifact, } } @@ -69,6 +71,8 @@ func defaultUpstreamValues() map[string]string { "oci_default": "https://registry-1.docker.io", "debian": "http://deb.debian.org/debian", "rpm": "https://dl.fedoraproject.org/pub/fedora/linux", + "homebrew_api": "https://formulae.brew.sh/api", + "homebrew_artifact": "https://ghcr.io", } } @@ -98,6 +102,8 @@ func upstreamEnvironmentVariables() map[string]string { "oci_default": "PROXY_UPSTREAM_OCI_DEFAULT", "debian": "PROXY_UPSTREAM_DEBIAN", "rpm": "PROXY_UPSTREAM_RPM", + "homebrew_api": "PROXY_UPSTREAM_HOMEBREW_API", + "homebrew_artifact": "PROXY_UPSTREAM_HOMEBREW_ARTIFACT", } } @@ -312,6 +318,9 @@ log: format: "json" access_log: path: "/var/log/proxy/access.jsonl" +upstream: + homebrew_api: "https://homebrew-api.example.com" + homebrew_artifact: "https://homebrew-artifact.example.com" ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("writing config file: %v", err) @@ -343,6 +352,12 @@ access_log: if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" { t.Errorf("AccessLog.Path = %q, want %q", cfg.AccessLog.Path, "/var/log/proxy/access.jsonl") } + if cfg.Upstream.HomebrewAPI != "https://homebrew-api.example.com" { + t.Errorf("Upstream.HomebrewAPI = %q, want %q", cfg.Upstream.HomebrewAPI, "https://homebrew-api.example.com") + } + if cfg.Upstream.HomebrewArtifact != "https://homebrew-artifact.example.com" { + t.Errorf("Upstream.HomebrewArtifact = %q, want %q", cfg.Upstream.HomebrewArtifact, "https://homebrew-artifact.example.com") + } } func TestLoadYAMLUpstreams(t *testing.T) { @@ -377,6 +392,8 @@ upstream: oci_default: "https://upstream.example.com/oci_default" debian: "https://upstream.example.com/debian" rpm: "https://upstream.example.com/rpm" + homebrew_api: "https://upstream.example.com/homebrew_api" + homebrew_artifact: "https://upstream.example.com/homebrew_artifact" ` if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatalf("writing config file: %v", err) diff --git a/internal/handler/container.go b/internal/handler/container.go index 7467fa9b..4da538bc 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -25,6 +25,12 @@ type ContainerHandler struct { registryURL string proxyURL string namedRegistries map[string]string + registries []containerRegistry +} + +type containerRegistry struct { + repositoryPrefix string + registryURL string } // NewContainerHandler creates a new container registry protocol handler. @@ -58,6 +64,36 @@ func NewContainerHandlerWithRegistry( return h } +// RegisterRegistry routes a repository and its descendants to a specific OCI +// registry. The longest matching repository prefix wins. +func (h *ContainerHandler) RegisterRegistry(repositoryPrefix, registryURL string) { + h.registries = append(h.registries, containerRegistry{ + repositoryPrefix: strings.Trim(repositoryPrefix, "/"), + registryURL: strings.TrimSuffix(registryURL, "/"), + }) +} + +// BlockRegistry prevents a repository and its descendants from falling back to +// the default OCI registry. A more specific registered repository still wins. +func (h *ContainerHandler) BlockRegistry(repositoryPrefix string) { + h.RegisterRegistry(repositoryPrefix, "") +} + +func (h *ContainerHandler) registryURLFor(name string) string { + registryURL := h.registryURL + matchLength := 0 + for _, registry := range h.registries { + if name != registry.repositoryPrefix && !strings.HasPrefix(name, registry.repositoryPrefix+"/") { + continue + } + if len(registry.repositoryPrefix) > matchLength { + registryURL = registry.registryURL + matchLength = len(registry.repositoryPrefix) + } + } + return registryURL +} + // Routes returns the HTTP handler for container registry requests. // Mount this at /v2 on your router. func (h *ContainerHandler) Routes() http.Handler { @@ -125,10 +161,8 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req } if cached != nil { w.Header().Set("Docker-Content-Digest", digest) - if cached.ContentType != "" { - w.Header().Set(headerContentType, cached.ContentType) - } else { - w.Header().Set(headerContentType, "application/octet-stream") + if cached.ContentType == "" { + cached.ContentType = "application/octet-stream" } serveArtifact(w, r.Method, cached) return @@ -141,13 +175,14 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req } // Try to get from cache, or fetch from the authentication-aware upstream client. - result, err := h.proxy.GetOrFetchArtifactFromURL( + result, err := h.proxy.GetOrFetchArtifactFromURLWithDigest( r.Context(), "oci", cacheName, digest, // use digest as version filename, fmt.Sprintf("%s/v2/%s/blobs/%s", registryURL, upstreamName, digest), + digest, ) if err != nil { @@ -159,16 +194,19 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req h.containerError(w, http.StatusForbidden, "DENIED", err.Error()) return } + if errors.Is(err, ErrArtifactDigestMismatch) { + h.proxy.Logger.Error("upstream blob failed digest verification", "error", err) + h.containerError(w, http.StatusBadGateway, "DIGEST_INVALID", "blob digest verification failed") + return + } h.proxy.Logger.Error("failed to fetch blob", "error", err) h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob") return } w.Header().Set("Docker-Content-Digest", digest) - if result.ContentType != "" { - w.Header().Set(headerContentType, result.ContentType) - } else { - w.Header().Set(headerContentType, "application/octet-stream") + if result.ContentType == "" { + result.ContentType = "application/octet-stream" } ServeArtifact(w, result) } @@ -236,18 +274,22 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request, } defer func() { _ = resp.Body.Close() }() - for _, header := range []string{headerContentType, headerContentLength, "Docker-Content-Digest"} { + for _, header := range []string{headerContentType, headerContentLength, "Docker-Content-Digest", headerETag, headerLastModified} { if v := resp.Header.Get(header); v != "" { w.Header().Set(header, v) } } + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices && w.Header().Get("Docker-Content-Digest") == "" { + w.Header().Set("Docker-Content-Digest", digest) + } w.WriteHeader(resp.StatusCode) } // registryForName resolves a client-visible OCI repository name to an upstream // registry and its repository name. Named upstreams use upstream/{name}/ as a -// reserved prefix; all other names continue to target Docker Hub. +// reserved prefix. Other names are matched against registered repository +// prefixes, falling back to Docker Hub when no prefix matches. func (h *ContainerHandler) registryForName(name string) (registryURL, upstreamName, cacheName string, ok bool) { parts := strings.SplitN(name, "/", registrySelectorParts) if len(parts) >= 2 && parts[0] == "upstream" { @@ -260,7 +302,11 @@ func (h *ContainerHandler) registryForName(name string) (registryURL, upstreamNa } return registryURL, parts[2], name, true } - return h.registryURL, name, name, true + registryURL = h.registryURLFor(name) + if registryURL == "" { + return "", "", "", false + } + return registryURL, name, name, true } // containerError writes an OCI-compliant error response. diff --git a/internal/handler/container_manifest.go b/internal/handler/container_manifest.go index a471330c..7b030868 100644 --- a/internal/handler/container_manifest.go +++ b/internal/handler/container_manifest.go @@ -32,6 +32,7 @@ type cachedContainerManifest struct { contentDigest string etag string size int64 + lastModified time.Time fetchedAt time.Time } @@ -43,7 +44,7 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, immutable := manifestDigestReferencePattern.MatchString(reference) if cached != nil && (immutable || h.containerManifestFresh(cached)) { - writeContainerManifest(w, r.Method, cached, false) + writeContainerManifest(w, r, cached, false) return } @@ -68,12 +69,12 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, if resp.StatusCode == http.StatusNotModified && cached != nil { cached.fetchedAt = time.Now() h.storeContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheAccept, cached) - writeContainerManifest(w, r.Method, cached, false) + writeContainerManifest(w, r, cached, false) return } if resp.StatusCode != http.StatusOK { if cached != nil && shouldServeStaleManifest(resp.StatusCode) { - writeContainerManifest(w, r.Method, cached, true) + writeContainerManifest(w, r, cached, true) return } copyContainerManifestHeaders(w.Header(), resp.Header) @@ -93,28 +94,40 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, h.serveStaleManifestOrError(w, r, cached, fmt.Errorf("reading manifest: %w", err)) return } + computedDigest := sha256Digest(body) + contentDigest := resp.Header.Get("Docker-Content-Digest") + for _, expected := range []string{reference, contentDigest} { + if strings.HasPrefix(expected, "sha256:") && expected != computedDigest { + h.proxy.Logger.Error("upstream manifest failed digest verification", + "name", name, "reference", reference, "expected", expected, "actual", computedDigest) + h.containerError(w, http.StatusBadGateway, "DIGEST_INVALID", "manifest digest verification failed") + return + } + } + if contentDigest == "" { + contentDigest = computedDigest + } + manifest := &cachedContainerManifest{ body: body, contentType: resp.Header.Get(headerContentType), - contentDigest: resp.Header.Get("Docker-Content-Digest"), - etag: resp.Header.Get("ETag"), + contentDigest: contentDigest, + etag: resp.Header.Get(headerETag), size: int64(len(body)), + lastModified: parseHTTPTime(resp.Header.Get(headerLastModified)), fetchedAt: time.Now(), } - if manifest.contentDigest == "" { - manifest.contentDigest = sha256Digest(body) - } h.storeContainerManifestForAccept(r.Context(), registryURL, name, reference, accept, cacheAccept, manifest) if manifest.contentDigest != reference && manifestDigestReferencePattern.MatchString(manifest.contentDigest) { h.storeContainerManifestForAccept(r.Context(), registryURL, name, manifest.contentDigest, accept, cacheAccept, manifest) } - writeContainerManifest(w, r.Method, manifest, false) + writeContainerManifest(w, r, manifest, false) } func (h *ContainerHandler) serveStaleManifestOrError(w http.ResponseWriter, r *http.Request, cached *cachedContainerManifest, err error) { if cached != nil { h.proxy.Logger.Warn("upstream manifest fetch failed, serving stale cache", "error", err) - writeContainerManifest(w, r.Method, cached, true) + writeContainerManifest(w, r, cached, true) return } h.proxy.Logger.Error("failed to fetch manifest", "error", err) @@ -210,6 +223,9 @@ func (h *ContainerHandler) loadContainerManifest(ctx context.Context, cacheKey s if entry.Size.Valid { manifest.size = entry.Size.Int64 } + if entry.LastModified.Valid { + manifest.lastModified = entry.LastModified.Time + } if entry.FetchedAt.Valid { manifest.fetchedAt = entry.FetchedAt.Time } @@ -218,7 +234,7 @@ func (h *ContainerHandler) loadContainerManifest(ctx context.Context, cacheKey s func (h *ContainerHandler) storeContainerManifest(ctx context.Context, cacheKey string, manifest *cachedContainerManifest) error { size, err := h.storeContainerMetadata(ctx, containerManifestCacheEcosystem, cacheKey, manifest.body, - manifest.etag, "", manifest.contentType, manifest.contentDigest, manifest.fetchedAt) + manifest.etag, "", manifest.contentType, manifest.contentDigest, manifest.lastModified, manifest.fetchedAt) if err != nil { return fmt.Errorf("storing manifest: %w", err) } @@ -226,7 +242,7 @@ func (h *ContainerHandler) storeContainerManifest(ctx context.Context, cacheKey return nil } -func writeContainerManifest(w http.ResponseWriter, method string, manifest *cachedContainerManifest, stale bool) { +func writeContainerManifest(w http.ResponseWriter, r *http.Request, manifest *cachedContainerManifest, stale bool) { if manifest.contentType != "" { w.Header().Set(headerContentType, manifest.contentType) } @@ -235,13 +251,26 @@ func writeContainerManifest(w http.ResponseWriter, method string, manifest *cach w.Header().Set("Docker-Content-Digest", manifest.contentDigest) } if manifest.etag != "" { - w.Header().Set("ETag", manifest.etag) + w.Header().Set(headerETag, manifest.etag) + } + if !manifest.lastModified.IsZero() { + w.Header().Set(headerLastModified, manifest.lastModified.UTC().Format(http.TimeFormat)) } if stale { w.Header().Set("Warning", containerStaleWarning) } + if ifNoneMatchHits(r.Header.Get("If-None-Match"), manifest.etag) { + w.WriteHeader(http.StatusNotModified) + return + } + if !manifest.lastModified.IsZero() { + if modifiedSince, err := http.ParseTime(r.Header.Get("If-Modified-Since")); err == nil && !manifest.lastModified.After(modifiedSince) { + w.WriteHeader(http.StatusNotModified) + return + } + } w.WriteHeader(http.StatusOK) - if method != http.MethodHead { + if r.Method != http.MethodHead { _, _ = w.Write(manifest.body) } } @@ -383,13 +412,18 @@ func containerAcceptQuality(params map[string]string) float64 { } func copyContainerManifestHeaders(destination, source http.Header) { - for _, header := range []string{headerContentType, headerContentLength, "Docker-Content-Digest", "ETag", "WWW-Authenticate"} { + for _, header := range []string{headerContentType, headerContentLength, "Docker-Content-Digest", headerETag, headerLastModified, "WWW-Authenticate"} { if value := source.Get(header); value != "" { destination.Set(header, value) } } } +func parseHTTPTime(value string) time.Time { + parsed, _ := http.ParseTime(value) + return parsed +} + func shouldServeStaleManifest(status int) bool { return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError } diff --git a/internal/handler/container_metadata.go b/internal/handler/container_metadata.go index 0a9e44be..edc78ada 100644 --- a/internal/handler/container_metadata.go +++ b/internal/handler/container_metadata.go @@ -10,7 +10,7 @@ import ( "github.com/git-pkgs/proxy/internal/database" ) -func (h *ContainerHandler) storeContainerMetadata(ctx context.Context, ecosystem, cacheKey string, body []byte, etag, link, contentType, contentDigest string, fetchedAt time.Time) (int64, error) { +func (h *ContainerHandler) storeContainerMetadata(ctx context.Context, ecosystem, cacheKey string, body []byte, etag, link, contentType, contentDigest string, lastModified, fetchedAt time.Time) (int64, error) { if h.proxy.DB == nil || h.proxy.Storage == nil { return int64(len(body)), nil } @@ -29,6 +29,7 @@ func (h *ContainerHandler) storeContainerMetadata(ctx context.Context, ecosystem ContentType: sql.NullString{String: contentType, Valid: contentType != ""}, ContentDigest: sql.NullString{String: contentDigest, Valid: contentDigest != ""}, Size: sql.NullInt64{Int64: size, Valid: true}, + LastModified: sql.NullTime{Time: lastModified, Valid: !lastModified.IsZero()}, FetchedAt: sql.NullTime{Time: fetchedAt, Valid: !fetchedAt.IsZero()}, }) if err != nil { diff --git a/internal/handler/container_tags.go b/internal/handler/container_tags.go index 0e9e3eba..dcc08f81 100644 --- a/internal/handler/container_tags.go +++ b/internal/handler/container_tags.go @@ -87,7 +87,7 @@ func (h *ContainerHandler) serveTagsList(w http.ResponseWriter, r *http.Request, tags := &cachedContainerTags{ body: body, contentType: resp.Header.Get(headerContentType), - etag: resp.Header.Get("ETag"), + etag: resp.Header.Get(headerETag), link: h.rewriteContainerTagsLink(strings.Join(resp.Header.Values("Link"), ", "), registryURL, r.URL.Path), size: int64(len(body)), fetchedAt: time.Now(), @@ -160,7 +160,7 @@ func (h *ContainerHandler) loadContainerTags(ctx context.Context, cacheKey strin func (h *ContainerHandler) storeContainerTags(ctx context.Context, cacheKey string, tags *cachedContainerTags) error { size, err := h.storeContainerMetadata(ctx, containerTagsCacheEcosystem, cacheKey, tags.body, - tags.etag, tags.link, tags.contentType, "", tags.fetchedAt) + tags.etag, tags.link, tags.contentType, "", time.Time{}, tags.fetchedAt) if err != nil { return fmt.Errorf("storing tag list: %w", err) } @@ -172,7 +172,7 @@ func writeContainerTags(w http.ResponseWriter, tags *cachedContainerTags, stale w.Header().Set(headerContentType, tags.contentType) w.Header().Set(headerContentLength, strconv.FormatInt(tags.size, 10)) if tags.etag != "" { - w.Header().Set("ETag", tags.etag) + w.Header().Set(headerETag, tags.etag) } if tags.link != "" { w.Header().Set("Link", tags.link) @@ -185,7 +185,7 @@ func writeContainerTags(w http.ResponseWriter, tags *cachedContainerTags, stale } func copyContainerTagsHeaders(destination, source http.Header) { - for _, header := range []string{headerContentType, headerContentLength, "ETag", "Link", "WWW-Authenticate"} { + for _, header := range []string{headerContentType, headerContentLength, headerETag, "Link", "WWW-Authenticate"} { if value := source.Get(header); value != "" { destination.Set(header, value) } diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go index d4a78f4d..5b7aa032 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -302,17 +302,18 @@ func TestContainerHandler_TagsListRewritesRelativePaginationLinkForNamedRegistry } func TestContainerHandler_NamedOCIRegistryServesHelmArtifacts(t *testing.T) { - digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" + const blob = "chart archive" + digest := "sha256:" + sha256Hex(blob) manifest := `{"schemaVersion":2,"config":{"mediaType":"application/vnd.cncf.helm.config.v1+json"},"layers":[{"mediaType":"application/vnd.cncf.helm.chart.content.v1.tar+gzip","digest":"` + digest + `"}]}` upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v2/owner/demo/manifests/1.0.0": w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.Header().Set("Docker-Content-Digest", digest) + w.Header().Set("Docker-Content-Digest", "sha256:"+sha256Hex(manifest)) _, _ = io.WriteString(w, manifest) case "/v2/owner/demo/blobs/" + digest: w.Header().Set("Content-Type", "application/vnd.cncf.helm.chart.content.v1.tar+gzip") - _, _ = io.WriteString(w, "chart archive") + _, _ = io.WriteString(w, blob) default: http.NotFound(w, r) } @@ -347,8 +348,28 @@ func TestContainerHandler_NamedOCIRegistryServesHelmArtifacts(t *testing.T) { } } +func TestContainerHandler_registryURLForUsesLongestRepositoryPrefix(t *testing.T) { + h := &ContainerHandler{registryURL: "https://registry-1.docker.io"} + h.RegisterRegistry("homebrew", "https://example.test") + h.RegisterRegistry("homebrew/core", "https://ghcr.io/") + + tests := map[string]string{ + "homebrew/core": "https://ghcr.io", + "homebrew/core/jq": "https://ghcr.io", + "homebrew/portable-ruby": "https://example.test", + "homebrew-core/jq": "https://registry-1.docker.io", + "library/homebrew/core/jq": "https://registry-1.docker.io", + } + for name, want := range tests { + if got := h.registryURLFor(name); got != want { + t.Errorf("registryURLFor(%q) = %q, want %q", name, got, want) + } + } +} + func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) { - digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" + blob := "upstream blob" + digest := sha256Digest([]byte(blob)) registryRequests := 0 tokenRequests := 0 var upstream *httptest.Server @@ -369,7 +390,7 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) { return } w.Header().Set("Content-Type", "application/octet-stream") - _, _ = io.WriteString(w, "upstream blob") + _, _ = io.WriteString(w, blob) default: http.NotFound(w, r) } @@ -400,8 +421,8 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) { if w.Code != http.StatusOK { t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) } - if got := w.Body.String(); got != "upstream blob" { - t.Errorf("body = %q, want %q", got, "upstream blob") + if got := w.Body.String(); got != blob { + t.Errorf("body = %q, want %q", got, blob) } } @@ -413,10 +434,102 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) { } } +func TestContainerHandler_HomebrewBlobDoesNotForwardClientCredentialsToRegistryOrCDN(t *testing.T) { + blob := "homebrew bottle" + digest := sha256Digest([]byte(blob)) + var registryAuthorization, cdnAuthorization string + + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cdnAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/vnd.homebrew.bottle") + _, _ = io.WriteString(w, blob) + })) + defer cdn.Close() + + registry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + registryAuthorization = r.Header.Get("Authorization") + http.Redirect(w, r, cdn.URL+"/bottle", http.StatusTemporaryRedirect) + })) + defer registry.Close() + + defaultRequests := 0 + defaultRegistry := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defaultRequests++ + http.NotFound(w, r) + })) + defer defaultRegistry.Close() + + proxy, _, _, _ := setupTestProxy(t) + client := registry.Client() + artifactFetcher := fetch.NewFetcher( + fetch.WithHTTPClient(client), + fetch.WithMaxRetries(0), + ) + t.Cleanup(func() { _ = artifactFetcher.Close() }) + proxy.Fetcher = artifactFetcher + + h := &ContainerHandler{proxy: proxy, registryURL: defaultRegistry.URL} + h.RegisterRegistry("homebrew/core", registry.URL) + req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/blobs/"+digest, nil) + req.Header.Set("Authorization", "Bearer client-secret") + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if got := w.Body.String(); got != blob { + t.Errorf("body = %q, want %q", got, blob) + } + if got := w.Header().Get("Content-Type"); got != "application/vnd.homebrew.bottle" { + t.Errorf("Content-Type = %q, want application/vnd.homebrew.bottle", got) + } + if registryAuthorization != "" { + t.Errorf("registry Authorization = %q, want empty", registryAuthorization) + } + if cdnAuthorization != "" { + t.Errorf("CDN Authorization = %q, want empty", cdnAuthorization) + } + if defaultRequests != 0 { + t.Errorf("default registry requests = %d, want 0", defaultRequests) + } +} + +func TestContainerHandler_BlobDigestMismatchIsNotCached(t *testing.T) { + proxy, _, store, fetcher := setupTestProxy(t) + fetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("wrong bottle")), + ContentType: "application/octet-stream", + } + digest := sha256Digest([]byte("expected bottle")) + h := &ContainerHandler{proxy: proxy, registryURL: "https://registry.example.test"} + + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/blobs/"+digest, nil)) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "DIGEST_INVALID") { + t.Errorf("body = %q, want DIGEST_INVALID", w.Body.String()) + } + cached, err := proxy.GetCachedArtifact(t.Context(), "oci", "homebrew/core/jq", digest, digest) + if err != nil { + t.Fatalf("checking cache: %v", err) + } + if cached != nil { + t.Error("digest-mismatched blob was recorded in the cache") + } + if len(store.files) != 0 { + t.Errorf("stored files = %d, want 0", len(store.files)) + } +} + func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *testing.T) { - digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` blob := "cached image blob" + manifestDigest := sha256Digest([]byte(manifest)) + blobDigest := sha256Digest([]byte(blob)) registryAvailable := true tokenAvailable := true registryRequests := 0 @@ -451,9 +564,9 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test switch r.URL.Path { case "/v2/library/nginx/manifests/latest": w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.Header().Set("Docker-Content-Digest", digest) + w.Header().Set("Docker-Content-Digest", manifestDigest) _, _ = io.WriteString(w, manifest) - case "/v2/library/nginx/blobs/" + digest: + case "/v2/library/nginx/blobs/" + blobDigest: w.Header().Set("Content-Type", "application/octet-stream") _, _ = io.WriteString(w, blob) default: @@ -483,7 +596,7 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test body string }{ {path: "/library/nginx/manifests/latest", body: manifest}, - {path: "/library/nginx/blobs/" + digest, body: blob}, + {path: "/library/nginx/blobs/" + blobDigest, body: blob}, } { response := httptest.NewRecorder() warmHandler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, request.path, nil)) @@ -516,13 +629,14 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test }).Routes() for _, request := range []struct { - name string - path string - body string + name string + path string + body string + digest string }{ - {name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest}, - {name: "digest manifest", path: "/library/nginx/manifests/" + digest, body: manifest}, - {name: "blob", path: "/library/nginx/blobs/" + digest, body: blob}, + {name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest, digest: manifestDigest}, + {name: "digest manifest", path: "/library/nginx/manifests/" + manifestDigest, body: manifest, digest: manifestDigest}, + {name: "blob", path: "/library/nginx/blobs/" + blobDigest, body: blob, digest: blobDigest}, } { t.Run(request.name, func(t *testing.T) { response := httptest.NewRecorder() @@ -533,8 +647,8 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test if got := response.Body.String(); got != request.body { t.Errorf("body = %q, want %q", got, request.body) } - if got := response.Header().Get("Docker-Content-Digest"); got != digest { - t.Errorf("Docker-Content-Digest = %q, want %q", got, digest) + if got := response.Header().Get("Docker-Content-Digest"); got != request.digest { + t.Errorf("Docker-Content-Digest = %q, want %q", got, request.digest) } }) } @@ -662,8 +776,9 @@ func TestContainerHandler_BlobHead_DirectServeRedirects(t *testing.T) { } func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) { - digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` + digest := sha256Digest([]byte(manifest)) + lastModified := time.Date(2026, time.August, 14, 9, 30, 0, 0, time.UTC) upstreamAvailable := true upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -679,6 +794,7 @@ func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) { w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("ETag", `"manifest-etag"`) + w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat)) if r.Method != http.MethodHead { _, _ = io.WriteString(w, manifest) } @@ -710,6 +826,23 @@ func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) { if got := second.Header().Get("Docker-Content-Digest"); got != digest { t.Errorf("cached Docker-Content-Digest = %q, want %q", got, digest) } + if got := second.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) { + t.Errorf("cached Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat)) + } + + conditionalRequest := httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+digest, nil) + conditionalRequest.Header.Set("If-None-Match", `"manifest-etag"`) + conditional := httptest.NewRecorder() + h.Routes().ServeHTTP(conditional, conditionalRequest) + if conditional.Code != http.StatusNotModified { + t.Fatalf("conditional status = %d, want %d", conditional.Code, http.StatusNotModified) + } + if got := conditional.Header().Get("ETag"); got != `"manifest-etag"` { + t.Errorf("conditional ETag = %q, want %q", got, `"manifest-etag"`) + } + if conditional.Body.Len() != 0 { + t.Errorf("conditional body length = %d, want 0", conditional.Body.Len()) + } head := httptest.NewRecorder() h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/"+digest, nil)) @@ -728,9 +861,104 @@ func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) { } } +func TestContainerHandler_ManifestDigestMismatchIsNotCached(t *testing.T) { + manifest := `{"schemaVersion":2}` + digest := sha256Digest([]byte("different manifest")) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Header().Set("Docker-Content-Digest", digest) + _, _ = io.WriteString(w, manifest) + })) + defer upstream.Close() + + proxy, db, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL} + req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/manifests/"+digest, nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "DIGEST_INVALID") { + t.Errorf("body = %q, want DIGEST_INVALID", w.Body.String()) + } + cacheKey := h.containerManifestCacheKey(upstream.URL, "homebrew/core/jq", digest, containerManifestAccept(req)) + entry, err := db.GetMetadataCache(containerManifestCacheEcosystem, cacheKey) + if err != nil { + t.Fatalf("checking manifest cache: %v", err) + } + if entry != nil { + t.Error("digest-mismatched manifest was recorded in the cache") + } +} + +func TestContainerHandler_ManifestNonSHA256DigestReferenceIsProxied(t *testing.T) { + manifest := `{"schemaVersion":2}` + sha512Reference := "sha512:" + strings.Repeat("a", 128) + sha512Header := "sha512:" + strings.Repeat("b", 128) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Header().Set("Docker-Content-Digest", sha512Header) + _, _ = io.WriteString(w, manifest) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL} + + for _, reference := range []string{sha512Reference, "latest"} { + t.Run(reference, func(t *testing.T) { + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/library/nginx/manifests/"+reference, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if got := w.Body.String(); got != manifest { + t.Errorf("body = %q, want %q", got, manifest) + } + if got := w.Header().Get("Docker-Content-Digest"); got != sha512Header { + t.Errorf("Docker-Content-Digest = %q, want %q", got, sha512Header) + } + }) + } +} + +func TestContainerHandler_ManifestTagWithInvalidDigestIsNotAliased(t *testing.T) { + manifest := `{"schemaVersion":2}` + invalidDigest := sha256Digest([]byte("different manifest")) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Header().Set("Docker-Content-Digest", invalidDigest) + _, _ = io.WriteString(w, manifest) + })) + defer upstream.Close() + + proxy, db, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL} + req := httptest.NewRequest(http.MethodGet, "/homebrew/core/jq/manifests/latest", nil) + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, req) + + if w.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusBadGateway, w.Body.String()) + } + cacheKey := h.containerManifestCacheKey(upstream.URL, "homebrew/core/jq", invalidDigest, containerManifestAccept(req)) + entry, err := db.GetMetadataCache(containerManifestCacheEcosystem, cacheKey) + if err != nil { + t.Fatalf("checking manifest cache: %v", err) + } + if entry != nil { + t.Error("tag manifest was cached under an unverified digest") + } +} + func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) { - digest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` + digest := sha256Digest([]byte(manifest)) upstreamAvailable := true upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -777,8 +1005,8 @@ func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testi } func TestContainerHandler_ManifestVariantCacheNormalizesCompatibleAccept(t *testing.T) { - digest := "sha256:abababababababababababababababababababababababababababababababab" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` + digest := sha256Digest([]byte(manifest)) upstreamAvailable := true upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -880,8 +1108,8 @@ func TestContainerHandler_ManifestMigratesLegacyAcceptCacheKey(t *testing.T) { } func TestContainerHandler_ManifestDualWritesLegacyAcceptCacheKeys(t *testing.T) { - digest := "sha256:dededededededededededededededededededededededededededededededede" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` + digest := sha256Digest([]byte(manifest)) upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v2/library/nginx/manifests/latest" { http.NotFound(w, r) @@ -918,7 +1146,8 @@ func TestContainerHandler_ManifestDualWritesLegacyAcceptCacheKeys(t *testing.T) } func TestContainerHandler_ManifestVariantCacheHonorsSpecificAcceptExclusions(t *testing.T) { - digest := "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd" + manifest := `{"schemaVersion":2}` + digest := sha256Digest([]byte(manifest)) upstreamAvailable := true upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { if !upstreamAvailable { @@ -927,7 +1156,7 @@ func TestContainerHandler_ManifestVariantCacheHonorsSpecificAcceptExclusions(t * } w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json") w.Header().Set("Docker-Content-Digest", digest) - _, _ = io.WriteString(w, `{"schemaVersion":2}`) + _, _ = io.WriteString(w, manifest) })) defer upstream.Close() @@ -992,8 +1221,8 @@ func TestContainerHandler_ManifestVariantCacheHonorsParameterizedAcceptExclusion } func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) { - digest := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` + digest := sha256Digest([]byte(manifest)) upstreamAvailable := true upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1040,7 +1269,8 @@ func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) { } func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) { - oldDigest := "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + manifest := `{"schemaVersion":2}` + oldDigest := sha256Digest([]byte(manifest)) newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" currentDigest := oldDigest upstreamRequests := 0 @@ -1050,7 +1280,7 @@ func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) { w.Header().Set("Docker-Content-Digest", currentDigest) w.Header().Set("ETag", `"`+currentDigest+`"`) if r.Method != http.MethodHead { - _, _ = io.WriteString(w, `{"schemaVersion":2}`) + _, _ = io.WriteString(w, manifest) } })) defer upstream.Close() diff --git a/internal/handler/gradle.go b/internal/handler/gradle.go index cc7e33f6..9c74a6ac 100644 --- a/internal/handler/gradle.go +++ b/internal/handler/gradle.go @@ -172,7 +172,7 @@ func (h *GradleBuildCacheHandler) handlePut(w http.ResponseWriter, r *http.Reque } w.Header().Set(headerContentLength, "0") - w.Header().Set("ETag", `"`+hash+`"`) + w.Header().Set(headerETag, `"`+hash+`"`) w.WriteHeader(http.StatusCreated) } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 7c022896..33bf3afc 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -97,8 +97,30 @@ const ( headerContentType = "Content-Type" headerContentLength = "Content-Length" headerContentEncoding = "Content-Encoding" + headerETag = "ETag" + headerLastModified = "Last-Modified" ) +// ifNoneMatchHits reports whether the given If-None-Match header value +// matches the current entity tag using weak comparison, so "*" matches any +// tag, W/ prefixes are ignored on both sides, and a comma-separated list is +// scanned. An empty header or an empty stored tag never match. +func ifNoneMatchHits(header, etag string) bool { + if etag == "" || header == "" { + return false + } + if header == "*" { + return true + } + etag = strings.TrimPrefix(etag, "W/") + for value := range strings.SplitSeq(header, ",") { + if strings.TrimPrefix(strings.TrimSpace(value), "W/") == etag { + return true + } + } + return false +} + // defaultMetadataMaxSize is used when Proxy.MetadataMaxSize is unset. const defaultMetadataMaxSize = 100 << 20 @@ -401,7 +423,7 @@ func (p *Proxy) storeArtifact(ctx context.Context, ecosystem, name, version, fil if delErr := p.Storage.Delete(ctx, storagePath); delErr != nil { p.Logger.Warn("failed to discard artifact with mismatched checksum", "path", storagePath, "error", delErr) } - return nil, fmt.Errorf("artifact checksum mismatch: upstream declared %s, got %s", upstreamHash, hash) + return nil, fmt.Errorf("%w: upstream declared %s, got %s", ErrArtifactDigestMismatch, upstreamHash, hash) } if p.Scanners != nil && p.Scanners.Enabled() { @@ -530,7 +552,7 @@ func ServeArtifact(w http.ResponseWriter, result *CacheResult) { func serveArtifact(w http.ResponseWriter, method string, result *CacheResult) { if result.RedirectURL != "" { if result.Hash != "" { - w.Header().Set("ETag", `"`+result.Hash+`"`) + w.Header().Set(headerETag, `"`+result.Hash+`"`) } w.Header().Set("Location", result.RedirectURL) w.WriteHeader(http.StatusFound) @@ -548,7 +570,7 @@ func serveArtifact(w http.ResponseWriter, method string, result *CacheResult) { w.Header().Set(headerContentLength, strconv.FormatInt(result.Size, 10)) } if result.Hash != "" { - w.Header().Set("ETag", `"`+result.Hash+`"`) + w.Header().Set(headerETag, `"`+result.Hash+`"`) } w.WriteHeader(http.StatusOK) @@ -790,6 +812,8 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e if entry != nil && entry.ETag.Valid { req.Header.Set("If-None-Match", entry.ETag.String) + } else if entry != nil && entry.LastModified.Valid { + req.Header.Set("If-Modified-Since", entry.LastModified.Time.UTC().Format(http.TimeFormat)) } resp, err := p.HTTPClient.Do(req) @@ -838,12 +862,12 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e body: body, contentType: resp.Header.Get(headerContentType), contentEncoding: resp.Header.Get(headerContentEncoding), - etag: resp.Header.Get("ETag"), + etag: resp.Header.Get(headerETag), } if meta.contentType == "" { meta.contentType = contentTypeJSON } - if lm := resp.Header.Get("Last-Modified"); lm != "" { + if lm := resp.Header.Get(headerLastModified); lm != "" { meta.lastModified, _ = http.ParseTime(lm) } return meta, nil @@ -940,10 +964,14 @@ func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Reque cm := p.lookupCachedMeta(ecosystem, cacheKey) if cm.etag != "" { - if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag { - w.WriteHeader(http.StatusNotModified) - return - } + w.Header().Set(headerETag, cm.etag) + } + if !cm.lastModified.IsZero() { + w.Header().Set(headerLastModified, cm.lastModified.UTC().Format(http.TimeFormat)) + } + if ifNoneMatchHits(r.Header.Get("If-None-Match"), cm.etag) { + w.WriteHeader(http.StatusNotModified) + return } if !cm.lastModified.IsZero() { if ims := r.Header.Get("If-Modified-Since"); ims != "" { @@ -959,23 +987,19 @@ func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Reque if cm.contentEncoding != "" { w.Header().Set(headerContentEncoding, cm.contentEncoding) } - if cm.etag != "" { - w.Header().Set("ETag", cm.etag) - } - if !cm.lastModified.IsZero() { - w.Header().Set("Last-Modified", cm.lastModified.UTC().Format(http.TimeFormat)) - } if cm.stale { w.Header().Set("Warning", `110 - "Response is Stale"`) } w.WriteHeader(http.StatusOK) - _, _ = w.Write(body) + if r.Method != http.MethodHead { + _, _ = w.Write(body) + } } // proxyMetadataStream forwards an upstream metadata response by streaming it to the client // without buffering the full body in memory. func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upstreamURL string, acceptHeaders ...string) { - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil) + req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL, nil) if err != nil { http.Error(w, "failed to create request", http.StatusInternalServerError) return @@ -1005,14 +1029,16 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst } defer func() { _ = resp.Body.Close() }() - for _, header := range []string{headerContentType, headerContentLength, headerContentEncoding, "Last-Modified", "ETag"} { + for _, header := range []string{headerContentType, headerContentLength, headerContentEncoding, headerLastModified, headerETag} { if v := resp.Header.Get(header); v != "" { w.Header().Set(header, v) } } w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) + if r.Method != http.MethodHead { + _, _ = io.Copy(w, resp.Body) + } } func (p *Proxy) applyUpstreamAuth(req *http.Request) { @@ -1029,7 +1055,7 @@ func (p *Proxy) applyUpstreamAuth(req *http.Request) { // GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL. // This is useful for registries where download URLs are determined from metadata. func (p *Proxy) GetOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string) (*CacheResult, error) { - return p.GetOrFetchArtifactFromURLWithHeaders(ctx, ecosystem, name, version, filename, downloadURL, nil) + return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, "") } // GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL @@ -1038,6 +1064,17 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, headers, "") } +// GetOrFetchArtifactFromURLWithDigest retrieves an artifact and verifies its +// SHA-256 digest before adding a newly fetched response to the cache. +// Non-sha256 digests are proxied without verification. +func (p *Proxy) GetOrFetchArtifactFromURLWithDigest(ctx context.Context, ecosystem, name, version, filename, downloadURL, digest string) (*CacheResult, error) { + upstreamHash, _ := strings.CutPrefix(digest, "sha256:") + if upstreamHash == digest { + upstreamHash = "" + } + return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, upstreamHash) +} + func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, upstreamHash string) (*CacheResult, error) { pkgPURL, versionPURL, err := packagePURLStrings(ecosystem, name, version) if err != nil { @@ -1101,6 +1138,10 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi return p.storeArtifact(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, upstreamHash, artifact) } +// ErrArtifactDigestMismatch indicates that fetched bytes did not match the +// checksum the upstream declared and were not recorded in the cache database. +var ErrArtifactDigestMismatch = errors.New("artifact digest mismatch") + func artifactHashMatches(got, expected string) bool { return expected == "" || strings.EqualFold(got, expected) } diff --git a/internal/handler/homebrew.go b/internal/handler/homebrew.go new file mode 100644 index 00000000..0fd5b62f --- /dev/null +++ b/internal/handler/homebrew.go @@ -0,0 +1,65 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + "strings" +) + +const ( + homebrewArtifactNamespace = "homebrew" + homebrewArtifactRepository = "homebrew/core" + homebrewMetadataEcosystem = "homebrew" +) + +// HomebrewHandler proxies Homebrew's JSON API without modifying signed files. +type HomebrewHandler struct { + proxy *Proxy + apiUpstream string +} + +// NewHomebrewHandler creates a Homebrew JSON API handler. +func NewHomebrewHandler(proxy *Proxy, apiUpstream string) *HomebrewHandler { + return &HomebrewHandler{ + proxy: proxy, + apiUpstream: strings.TrimSuffix(apiUpstream, "/"), + } +} + +// RegisterHomebrewArtifacts routes homebrew/core OCI requests to its configured +// registry and blocks other homebrew repositories from reaching the default +// OCI registry. +func RegisterHomebrewArtifacts(container *ContainerHandler, artifactUpstream string) { + container.BlockRegistry(homebrewArtifactNamespace) + container.RegisterRegistry(homebrewArtifactRepository, artifactUpstream) +} + +// Routes returns the Homebrew JSON API handler. Mount this at /homebrew. +func (h *HomebrewHandler) Routes() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + requestPath := strings.TrimPrefix(r.URL.EscapedPath(), "/") + if requestPath == "" || containsPathTraversal(requestPath) { + http.NotFound(w, r) + return + } + + upstreamURL := h.apiUpstream + "/" + requestPath + if r.URL.RawQuery != "" { + upstreamURL += "?" + r.URL.RawQuery + } + + h.proxy.ProxyCached(w, r, upstreamURL, homebrewMetadataEcosystem, homebrewMetadataCacheKey(requestPath, r.URL.RawQuery), "*/*") + }) +} + +func homebrewMetadataCacheKey(requestPath, rawQuery string) string { + sum := sha256.Sum256([]byte(requestPath + "\x00" + rawQuery)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/handler/homebrew_test.go b/internal/handler/homebrew_test.go new file mode 100644 index 00000000..e8931ad5 --- /dev/null +++ b/internal/handler/homebrew_test.go @@ -0,0 +1,363 @@ +package handler + +import ( + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "github.com/git-pkgs/registries/fetch" +) + +func TestHomebrewHandler_PreservesSignedResponseAndClientValidators(t *testing.T) { + body := " {\n \"payload\": \"signed bytes\",\n \"signatures\": []\n}\n" + etag := `"homebrew-api-etag"` + lastModified := time.Date(2026, time.August, 14, 9, 30, 0, 0, time.UTC) + requests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if r.Method != http.MethodGet { + t.Errorf("upstream method = %s, want GET", r.Method) + } + if r.URL.Path != "/api/internal/packages.arm64_tahoe.jws.json" { + t.Errorf("upstream path = %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("upstream Authorization = %q, want empty", got) + } + if got := r.Header.Get("Cookie"); got != "" { + t.Errorf("upstream Cookie = %q, want empty", got) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", etag) + w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat)) + _, _ = io.WriteString(w, body) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + proxy.HTTPClient = upstream.Client() + h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes() + + req := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil) + req.Header.Set("Authorization", "Bearer client-secret") + req.Header.Set("Cookie", "session=client-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) + } + if got := w.Body.String(); got != body { + t.Errorf("body = %q, want byte-for-byte %q", got, body) + } + if got := w.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want application/json", got) + } + wantContentLength := strconv.Itoa(len(body)) + if got := w.Header().Get("Content-Length"); got != wantContentLength { + t.Errorf("Content-Length = %q, want %q", got, wantContentLength) + } + if got := w.Header().Get("ETag"); got != etag { + t.Errorf("ETag = %q, want %q", got, etag) + } + if got := w.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) { + t.Errorf("Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat)) + } + + conditionalRequest := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil) + conditionalRequest.Header.Set("If-None-Match", etag) + conditional := httptest.NewRecorder() + h.ServeHTTP(conditional, conditionalRequest) + if conditional.Code != http.StatusNotModified { + t.Fatalf("conditional status = %d, want %d", conditional.Code, http.StatusNotModified) + } + if got := conditional.Header().Get("ETag"); got != etag { + t.Errorf("conditional ETag = %q, want %q", got, etag) + } + if conditional.Body.Len() != 0 { + t.Errorf("conditional body length = %d, want 0", conditional.Body.Len()) + } + + modifiedSinceRequest := httptest.NewRequest(http.MethodGet, "/internal/packages.arm64_tahoe.jws.json", nil) + modifiedSinceRequest.Header.Set("If-Modified-Since", lastModified.Format(http.TimeFormat)) + modifiedSince := httptest.NewRecorder() + h.ServeHTTP(modifiedSince, modifiedSinceRequest) + if modifiedSince.Code != http.StatusNotModified { + t.Fatalf("If-Modified-Since status = %d, want %d", modifiedSince.Code, http.StatusNotModified) + } + if got := modifiedSince.Header().Get("Last-Modified"); got != lastModified.Format(http.TimeFormat) { + t.Errorf("conditional Last-Modified = %q, want %q", got, lastModified.Format(http.TimeFormat)) + } + if requests != 1 { + t.Errorf("upstream requests = %d, want 1", requests) + } +} + +func TestHomebrewHandler_HeadUsesMetadataCacheAndSurvivesOutage(t *testing.T) { + body := `{"payload":"signed bytes","signatures":[]}` + available := true + requests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + if got := r.Header.Get("Authorization"); got != "" { + t.Errorf("upstream Authorization = %q, want empty", got) + } + if !available { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"head-etag"`) + _, _ = io.WriteString(w, body) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + proxy.HTTPClient = upstream.Client() + h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes() + + coldRequest := httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil) + coldRequest.Header.Set("Authorization", "Bearer client-secret") + cold := httptest.NewRecorder() + h.ServeHTTP(cold, coldRequest) + if cold.Code != http.StatusOK { + t.Fatalf("cold status = %d, want %d", cold.Code, http.StatusOK) + } + if cold.Body.Len() != 0 { + t.Errorf("cold body length = %d, want 0", cold.Body.Len()) + } + if got := cold.Header().Get("Content-Length"); got != strconv.Itoa(len(body)) { + t.Errorf("Content-Length = %q, want %d", got, len(body)) + } + if got := cold.Header().Get("ETag"); got != `"head-etag"` { + t.Errorf("ETag = %q, want %q", got, `"head-etag"`) + } + if requests != 1 { + t.Fatalf("cold upstream requests = %d, want 1", requests) + } + + warm := httptest.NewRecorder() + h.ServeHTTP(warm, httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil)) + if warm.Code != http.StatusOK { + t.Fatalf("warm status = %d, want %d", warm.Code, http.StatusOK) + } + if warm.Body.Len() != 0 { + t.Errorf("warm body length = %d, want 0", warm.Body.Len()) + } + if requests != 1 { + t.Errorf("warm upstream requests = %d, want 1", requests) + } + + proxy.MetadataTTL = time.Nanosecond + available = false + stale := httptest.NewRecorder() + h.ServeHTTP(stale, httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil)) + if stale.Code != http.StatusOK { + t.Fatalf("stale status = %d, want %d; body: %s", stale.Code, http.StatusOK, stale.Body.String()) + } + if stale.Body.Len() != 0 { + t.Errorf("stale body length = %d, want 0", stale.Body.Len()) + } + if got := stale.Header().Get("Warning"); got != containerStaleWarning { + t.Errorf("Warning = %q, want %q", got, containerStaleWarning) + } +} + +func TestHomebrewHandler_HeadWithoutMetadataCachePreservesUpstreamMethod(t *testing.T) { + upstreamMethod := "" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamMethod = r.Method + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "42") + w.Header().Set("ETag", `"head-etag"`) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = false + proxy.HTTPClient = upstream.Client() + h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes() + + head := httptest.NewRecorder() + h.ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil)) + if head.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", head.Code, http.StatusOK) + } + if upstreamMethod != http.MethodHead { + t.Errorf("upstream method = %q, want HEAD", upstreamMethod) + } + if head.Body.Len() != 0 { + t.Errorf("body length = %d, want 0", head.Body.Len()) + } + if got := head.Header().Get("Content-Length"); got != "42" { + t.Errorf("Content-Length = %q, want 42", got) + } +} + +func TestHomebrewHandler_ServesStaleCachedResponseWhenUpstreamFails(t *testing.T) { + body := `{"payload":"signed bytes","signatures":[]}` + available := true + requests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requests++ + if !available { + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("ETag", `"stale-etag"`) + _, _ = io.WriteString(w, body) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = 5 * time.Millisecond + proxy.HTTPClient = upstream.Client() + h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes() + + first := httptest.NewRecorder() + h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/formula.jws.json", nil)) + if first.Code != http.StatusOK { + t.Fatalf("warm status = %d, want %d", first.Code, http.StatusOK) + } + + time.Sleep(10 * time.Millisecond) + available = false + stale := httptest.NewRecorder() + h.ServeHTTP(stale, httptest.NewRequest(http.MethodGet, "/formula.jws.json", nil)) + if stale.Code != http.StatusOK { + t.Fatalf("stale status = %d, want %d; body: %s", stale.Code, http.StatusOK, stale.Body.String()) + } + if got := stale.Body.String(); got != body { + t.Errorf("stale body = %q, want %q", got, body) + } + if got := stale.Header().Get("Warning"); got != containerStaleWarning { + t.Errorf("Warning = %q, want %q", got, containerStaleWarning) + } + if requests != 2 { + t.Errorf("upstream requests = %d, want 2", requests) + } +} + +func TestHomebrewHandler_ProxiesSupportedAPIPaths(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.URL.RequestURI()) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes() + + paths := []string{ + "/formula.jws.json", + "/cask.jws.json", + "/formula/jq.json", + "/cask/firefox.json", + "/internal/packages.arm64_tahoe.jws.json?download=1", + } + for _, requestPath := range paths { + t.Run(requestPath, func(t *testing.T) { + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, requestPath, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", w.Code, http.StatusOK) + } + if got, want := w.Body.String(), "/api"+requestPath; got != want { + t.Errorf("upstream request = %q, want %q", got, want) + } + }) + } +} + +func TestHomebrewHandler_RejectsUnsupportedRequests(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + h := NewHomebrewHandler(proxy, "https://example.test/api").Routes() + + method := httptest.NewRecorder() + h.ServeHTTP(method, httptest.NewRequest(http.MethodPost, "/formula.jws.json", nil)) + if method.Code != http.StatusMethodNotAllowed { + t.Errorf("POST status = %d, want %d", method.Code, http.StatusMethodNotAllowed) + } + if got := method.Header().Get("Allow"); got != "GET, HEAD" { + t.Errorf("Allow = %q, want GET, HEAD", got) + } + + root := httptest.NewRecorder() + h.ServeHTTP(root, httptest.NewRequest(http.MethodGet, "/", nil)) + if root.Code != http.StatusNotFound { + t.Errorf("root status = %d, want %d", root.Code, http.StatusNotFound) + } + + traversal := httptest.NewRecorder() + h.ServeHTTP(traversal, httptest.NewRequest(http.MethodGet, "/%2e%2e/secret", nil)) + if traversal.Code != http.StatusNotFound { + t.Errorf("traversal status = %d, want %d", traversal.Code, http.StatusNotFound) + } +} + +func TestRegisterHomebrewArtifacts(t *testing.T) { + h := &ContainerHandler{registryURL: dockerHubRegistry} + artifactUpstream := "https://homebrew-proxy.example.com" + RegisterHomebrewArtifacts(h, artifactUpstream+"/") + + if got := h.registryURLFor("homebrew/core/jq"); got != artifactUpstream { + t.Errorf("homebrew/core registry = %q, want %q", got, artifactUpstream) + } + if got := h.registryURLFor("homebrew/cask/firefox"); got != "" { + t.Errorf("other Homebrew registry = %q, want blocked", got) + } + if got := h.registryURLFor("library/nginx"); got != dockerHubRegistry { + t.Errorf("unrelated registry = %q, want %q", got, dockerHubRegistry) + } +} + +func TestRegisterHomebrewArtifactsRejectsOtherHomebrewRoutes(t *testing.T) { + upstreamRequests := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamRequests++ + _, _ = io.WriteString(w, "unexpected upstream response") + })) + defer upstream.Close() + + proxy, _, _, fetcher := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + fetcher.artifact = &fetch.Artifact{ + Body: io.NopCloser(strings.NewReader("unexpected upstream blob")), + ContentType: "application/octet-stream", + } + h := &ContainerHandler{proxy: proxy, registryURL: upstream.URL} + RegisterHomebrewArtifacts(h, "https://ghcr.io") + + const digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + paths := []string{ + "/homebrew/cask/firefox/blobs/" + digest, + "/homebrew/cask/firefox/manifests/latest", + "/homebrew/cask/firefox/tags/list", + } + for _, path := range paths { + t.Run(path, func(t *testing.T) { + w := httptest.NewRecorder() + h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d; body: %s", w.Code, http.StatusNotFound, w.Body.String()) + } + }) + } + + if fetcher.fetchCalled { + t.Error("blocked Homebrew blob reached the artifact fetcher") + } + if upstreamRequests != 0 { + t.Errorf("blocked Homebrew routes made %d upstream requests, want 0", upstreamRequests) + } +} diff --git a/internal/handler/if_none_match_test.go b/internal/handler/if_none_match_test.go new file mode 100644 index 00000000..b659254c --- /dev/null +++ b/internal/handler/if_none_match_test.go @@ -0,0 +1,31 @@ +package handler + +import "testing" + +func TestIfNoneMatchHits(t *testing.T) { + tests := []struct { + header string + etag string + want bool + }{ + {`"abc"`, `"abc"`, true}, + {`"abc"`, `"def"`, false}, + {"", `"abc"`, false}, + {`"abc"`, "", false}, + {"*", `"abc"`, true}, + {"*", "", false}, + {`W/"abc"`, `"abc"`, true}, + {`"abc"`, `W/"abc"`, true}, + {`W/"abc"`, `W/"abc"`, true}, + {`"abc", "def"`, `"def"`, true}, + {`"abc","def"`, `"def"`, true}, + {` "abc" , W/"def" `, `"def"`, true}, + {`"abc", "def"`, `"ghi"`, false}, + } + + for _, tt := range tests { + if got := ifNoneMatchHits(tt.header, tt.etag); got != tt.want { + t.Errorf("ifNoneMatchHits(%q, %q) = %v, want %v", tt.header, tt.etag, got, tt.want) + } + } +} diff --git a/internal/handler/swift.go b/internal/handler/swift.go index 9acc8813..a67c95e7 100644 --- a/internal/handler/swift.go +++ b/internal/handler/swift.go @@ -431,7 +431,7 @@ func (h *SwiftHandler) proxySwiftResource(w http.ResponseWriter, r *http.Request func copySwiftResponseHeaders(dst, src http.Header) { for _, name := range []string{ "Cache-Control", "Content-Disposition", "Content-Language", headerContentLength, - headerContentType, "Content-Version", "Digest", "ETag", "Last-Modified", + headerContentType, "Content-Version", "Digest", headerETag, headerLastModified, "Retry-After", "Vary", "Warning", "X-Swift-Package-Signature", "X-Swift-Package-Signature-Format", } { @@ -566,8 +566,8 @@ func writeSwiftMetadata(w http.ResponseWriter, r *http.Request, body []byte, con etag := fmt.Sprintf(`"%x"`, digest) w.Header().Set(headerContentType, contentType) w.Header().Set("Content-Version", swiftContentVersion) - w.Header().Set("ETag", etag) - if r.Header.Get("If-None-Match") == etag { + w.Header().Set(headerETag, etag) + if ifNoneMatchHits(r.Header.Get("If-None-Match"), etag) { w.WriteHeader(http.StatusNotModified) return } diff --git a/internal/server/server.go b/internal/server/server.go index 0eb5194e..e158eb19 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -352,9 +352,6 @@ func (s *Server) serve(listener net.Listener) error { return s.http.ListenAndServe() } -// configureScanning builds the scanner group from cfg and wires it into -// proxy, returning the group so the caller can decide whether to mount the -// internal scan-fetch route. // mountProtocolHandlers constructs every ecosystem handler and mounts it on // r under its protocol prefix. func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { @@ -404,12 +401,14 @@ func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { cranHandler := handler.NewCRANHandlerWithUpstream(proxy, s.cfg.BaseURL, s.cfg.Upstream.CRAN) juliaHandler := handler.NewJuliaHandlerWithUpstream(proxy, s.cfg.Upstream.Julia) swiftHandler := handler.NewSwiftHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Swift) + homebrewHandler := handler.NewHomebrewHandler(proxy, s.cfg.Upstream.HomebrewAPI) containerHandler := handler.NewContainerHandlerWithRegistry( proxy, s.cfg.BaseURL, s.cfg.Upstream.OCIDefault, s.cfg.Upstream.OCI, ) + handler.RegisterHomebrewArtifacts(containerHandler, s.cfg.Upstream.HomebrewArtifact) helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm) apkHandler := handler.NewAPKHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.APK) debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian) @@ -431,6 +430,7 @@ func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes())) r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.Routes())) r.Mount("/swift", http.StripPrefix("/swift", swiftHandler.Routes())) + r.Mount("/homebrew", http.StripPrefix("/homebrew", homebrewHandler.Routes())) r.Mount("/v2", http.StripPrefix("/v2", containerHandler.Routes())) r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes())) r.Mount("/apk", http.StripPrefix("/apk", apkHandler.Routes())) @@ -438,6 +438,9 @@ func (s *Server) mountProtocolHandlers(r chi.Router, proxy *handler.Proxy) { r.Mount("/rpm", http.StripPrefix("/rpm", rpmHandler.Routes())) } +// configureScanning builds the scanner group from cfg and wires it into +// proxy, returning the group so the caller can decide whether to mount the +// internal scan-fetch route. func configureScanning(proxy *handler.Proxy, cfg config.ScanningConfig, baseURL string, logger *slog.Logger) (*scanner.Group, error) { scanGroup, err := scanner.NewGroup(cfg, logger) if err != nil { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 2f195dd4..a52a5b55 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -187,7 +187,6 @@ func testStartUsesConfiguredLoopbackUpstreams(t *testing.T) { _, _ = io.WriteString(w, `{"meta":{"api-version":"1.4"},"name":"ruff","files":[]}`) case "/v2/library/demo/manifests/latest": w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") - w.Header().Set("Docker-Content-Digest", "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") _, _ = io.WriteString(w, `{"schemaVersion":2}`) default: t.Errorf("unexpected upstream path: %q", r.URL.Path)