From 2e672fc5cac40dc20bc6600226260b4ac88da509 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 17 Aug 2026 09:34:23 +0100 Subject: [PATCH 1/2] Add Homebrew JSON API and bottle proxy support --- README.md | 26 +++ config.example.yaml | 6 + docs/configuration.md | 2 + internal/config/config.go | 12 + internal/config/config_test.go | 23 ++ internal/handler/container.go | 70 +++++- internal/handler/container_manifest.go | 57 ++++- internal/handler/container_test.go | 246 ++++++++++++++++++-- internal/handler/handler.go | 50 +++- internal/handler/homebrew.go | 70 ++++++ internal/handler/homebrew_test.go | 307 +++++++++++++++++++++++++ internal/server/server.go | 3 + 12 files changed, 812 insertions(+), 60 deletions(-) create mode 100644 internal/handler/homebrew.go create mode 100644 internal/handler/homebrew_test.go diff --git a/README.md b/README.md index 320a737..a5f8a9d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Resolution order: package override, then ecosystem override, then global default | CRAN | R | | ✓ | | Julia | Julia | | ✓ | | Container | Docker/OCI | | ✓ | +| Homebrew | macOS/Linux | | ✓ | | Debian | Debian/Ubuntu | | ✓ | | RPM | RHEL/Fedora | | ✓ | | Alpine | Alpine Linux | | ✗ | @@ -157,6 +158,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`: @@ -670,7 +694,9 @@ Recently cached: | `GET /cran/*` | CRAN (R) protocol | | `GET /julia/*` | Julia Pkg server 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 /debian/*` | Debian/APT repository protocol | | `GET /rpm/*` | RPM/Yum repository protocol | diff --git a/config.example.yaml b/config.example.yaml index 1df95b3..5bd2ee6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -102,6 +102,12 @@ upstream: # Debian/APT repository URL (used by /debian endpoint) debian: "http://deb.debian.org/debian" + # 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 3b8b935..6f09c93 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -143,6 +143,8 @@ upstream: gradle_plugin_portal: "https://plugins.gradle.org/m2" cargo: "https://index.crates.io" cargo_download: "https://static.crates.io/crates" + homebrew_api: "https://formulae.brew.sh/api" + homebrew_artifact: "https://ghcr.io" # Named HTTP Helm chart repositories, served at /helm/{name}/. helm: diff --git a/internal/config/config.go b/internal/config/config.go index fdf890d..7e4322a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -318,6 +318,14 @@ type UpstreamConfig struct { // Default: http://deb.debian.org/debian Debian string `json:"debian" yaml:"debian"` + // 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. @@ -476,6 +484,8 @@ func Default() *Config { Cargo: "https://index.crates.io", CargoDownload: "https://static.crates.io/crates", Debian: "http://deb.debian.org/debian", + HomebrewAPI: "https://formulae.brew.sh/api", + HomebrewArtifact: "https://ghcr.io", }, Gradle: GradleConfig{ BuildCache: GradleBuildCacheConfig{ @@ -566,6 +576,8 @@ func (c *Config) LoadFromEnv() { setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN") setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL") setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN") + 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.CacheMetadata, "PROXY_CACHE_METADATA") setEnvBool(&c.MirrorAPI, "PROXY_MIRROR_API") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0ccc308..5932134 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -44,6 +44,12 @@ func TestDefault(t *testing.T) { if cfg.Upstream.Debian != "http://deb.debian.org/debian" { t.Errorf("Upstream.Debian = %q, want %q", cfg.Upstream.Debian, "http://deb.debian.org/debian") } + if cfg.Upstream.HomebrewAPI != "https://formulae.brew.sh/api" { + t.Errorf("Upstream.HomebrewAPI = %q, want %q", cfg.Upstream.HomebrewAPI, "https://formulae.brew.sh/api") + } + if cfg.Upstream.HomebrewArtifact != "https://ghcr.io" { + t.Errorf("Upstream.HomebrewArtifact = %q, want %q", cfg.Upstream.HomebrewArtifact, "https://ghcr.io") + } } func TestValidate(t *testing.T) { @@ -217,6 +223,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) @@ -248,6 +257,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 TestLoadJSON(t *testing.T) { @@ -287,6 +302,8 @@ func TestLoadFromEnv(t *testing.T) { t.Setenv("PROXY_UPSTREAM_MAVEN", "https://maven.example.com/repository/maven-public") t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2") t.Setenv("PROXY_UPSTREAM_DEBIAN", "http://archive.ubuntu.com/ubuntu") + t.Setenv("PROXY_UPSTREAM_HOMEBREW_API", "https://homebrew-api.example.com") + t.Setenv("PROXY_UPSTREAM_HOMEBREW_ARTIFACT", "https://homebrew-artifact.example.com") t.Setenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY", "true") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h") @@ -322,6 +339,12 @@ func TestLoadFromEnv(t *testing.T) { if cfg.Upstream.Debian != "http://archive.ubuntu.com/ubuntu" { t.Errorf("Upstream.Debian = %q, want %q", cfg.Upstream.Debian, "http://archive.ubuntu.com/ubuntu") } + 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") + } if !cfg.Gradle.BuildCache.ReadOnly { t.Error("Gradle.BuildCache.ReadOnly = false, want true") } diff --git a/internal/handler/container.go b/internal/handler/container.go index 74819dd..d156b47 100644 --- a/internal/handler/container.go +++ b/internal/handler/container.go @@ -26,6 +26,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. @@ -47,6 +53,36 @@ func NewContainerHandler(proxy *Proxy, proxyURL string, namedRegistries ...map[s 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 { @@ -114,10 +150,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("Content-Type", cached.ContentType) - } else { - w.Header().Set("Content-Type", "application/octet-stream") + if cached.ContentType == "" { + cached.ContentType = "application/octet-stream" } serveArtifact(w, r.Method, cached) return @@ -130,13 +164,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 { @@ -144,16 +179,19 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req h.containerError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry") 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("Content-Type", result.ContentType) - } else { - w.Header().Set("Content-Type", "application/octet-stream") + if result.ContentType == "" { + result.ContentType = "application/octet-stream" } ServeArtifact(w, result) } @@ -241,18 +279,22 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request, } defer func() { _ = resp.Body.Close() }() - for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest"} { + for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "Last-Modified"} { 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" { @@ -265,7 +307,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 cf058ba..c4ff9c4 100644 --- a/internal/handler/container_manifest.go +++ b/internal/handler/container_manifest.go @@ -30,6 +30,7 @@ type cachedContainerManifest struct { contentDigest string etag string size int64 + lastModified time.Time fetchedAt time.Time } @@ -44,7 +45,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 } @@ -71,12 +72,12 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, if err := h.storeContainerManifest(r.Context(), cacheKey, cached); err != nil { h.proxy.Logger.Warn("failed to refresh cached container manifest", "error", err) } - 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) @@ -96,17 +97,27 @@ 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") + if contentDigest == "" { + contentDigest = computedDigest + } + if (immutable && reference != computedDigest) || contentDigest != computedDigest { + h.proxy.Logger.Error("upstream manifest failed digest verification", + "name", name, "reference", reference, "expected", contentDigest, "actual", computedDigest) + h.containerError(w, http.StatusBadGateway, "DIGEST_INVALID", "manifest digest verification failed") + return + } + manifest := &cachedContainerManifest{ body: body, contentType: resp.Header.Get("Content-Type"), - contentDigest: resp.Header.Get("Docker-Content-Digest"), + contentDigest: contentDigest, etag: resp.Header.Get("ETag"), size: int64(len(body)), + lastModified: parseHTTPTime(resp.Header.Get("Last-Modified")), fetchedAt: time.Now(), } - if manifest.contentDigest == "" { - manifest.contentDigest = sha256Digest(body) - } if err := h.storeContainerManifest(r.Context(), cacheKey, manifest); err != nil { h.proxy.Logger.Warn("failed to cache container manifest", "error", err) } @@ -116,13 +127,13 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, h.proxy.Logger.Warn("failed to cache container manifest by digest", "error", err) } } - 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) @@ -172,6 +183,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 } @@ -196,11 +210,12 @@ func (h *ContainerHandler) storeContainerManifest(ctx context.Context, cacheKey ContentType: sql.NullString{String: manifest.contentType, Valid: manifest.contentType != ""}, ContentDigest: sql.NullString{String: manifest.contentDigest, Valid: manifest.contentDigest != ""}, Size: sql.NullInt64{Int64: size, Valid: true}, + LastModified: sql.NullTime{Time: manifest.lastModified, Valid: !manifest.lastModified.IsZero()}, FetchedAt: sql.NullTime{Time: manifest.fetchedAt, Valid: !manifest.fetchedAt.IsZero()}, }) } -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("Content-Type", manifest.contentType) } @@ -211,11 +226,24 @@ func writeContainerManifest(w http.ResponseWriter, method string, manifest *cach if manifest.etag != "" { w.Header().Set("ETag", manifest.etag) } + if !manifest.lastModified.IsZero() { + w.Header().Set("Last-Modified", manifest.lastModified.UTC().Format(http.TimeFormat)) + } if stale { w.Header().Set("Warning", containerStaleWarning) } + if manifest.etag != "" && 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) } } @@ -234,13 +262,18 @@ func containerManifestAccept(r *http.Request) string { } func copyContainerManifestHeaders(destination, source http.Header) { - for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "WWW-Authenticate"} { + for _, header := range []string{"Content-Type", "Content-Length", "Docker-Content-Digest", "ETag", "Last-Modified", "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_test.go b/internal/handler/container_test.go index 04f00a7..8f5217d 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "strconv" + "strings" "testing" "time" @@ -135,17 +136,18 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) { } 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) } @@ -180,8 +182,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 @@ -202,7 +224,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) } @@ -233,8 +255,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) } } @@ -246,10 +268,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 @@ -284,9 +398,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: @@ -316,7 +430,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)) @@ -349,13 +463,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() @@ -366,8 +481,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) } }) } @@ -495,8 +610,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) { @@ -512,6 +628,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) } @@ -543,6 +660,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)) @@ -561,9 +695,72 @@ 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_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) { @@ -610,8 +807,8 @@ func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testi } 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) { @@ -658,7 +855,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 @@ -668,7 +866,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/handler.go b/internal/handler/handler.go index 6c65682..90f4ced 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -633,6 +633,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) @@ -775,6 +777,12 @@ func (p *Proxy) ProxyCached(w http.ResponseWriter, r *http.Request, upstreamURL, func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) { cm := p.lookupCachedMeta(ecosystem, cacheKey) + 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.etag != "" { if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag { w.WriteHeader(http.StatusNotModified) @@ -792,23 +800,19 @@ func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Reque w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Length", strconv.Itoa(len(body))) - 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 @@ -841,7 +845,9 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst } 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) { @@ -858,12 +864,22 @@ 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 // with additional request-specific HTTP headers. func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) { + 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. +func (p *Proxy) GetOrFetchArtifactFromURLWithDigest(ctx context.Context, ecosystem, name, version, filename, downloadURL, digest string) (*CacheResult, error) { + return p.getOrFetchArtifactFromURL(ctx, ecosystem, name, version, filename, downloadURL, nil, digest) +} + +func (p *Proxy) getOrFetchArtifactFromURL(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header, digest string) (*CacheResult, error) { if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil { return nil, err } else if cached != nil { @@ -873,10 +889,10 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys pkgPURL := purl.MakePURLString(ecosystem, name, "") versionPURL := purl.MakePURLString(ecosystem, name, version) - return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers) + return p.fetchAndCacheFromURL(ctx, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL, headers, digest) } -func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header) (*CacheResult, error) { +func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, version, filename, pkgPURL, versionPURL, downloadURL string, headers http.Header, expectedDigest string) (*CacheResult, error) { p.Logger.Info("fetching from upstream", "ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL) @@ -894,6 +910,12 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi if err != nil { return nil, fmt.Errorf("storing artifact: %w", err) } + if expectedDigest != "" && expectedDigest != "sha256:"+hash { + if deleteErr := p.Storage.Delete(ctx, storagePath); deleteErr != nil { + p.Logger.Warn("failed to remove artifact with invalid digest", "path", storagePath, "error", deleteErr) + } + return nil, fmt.Errorf("%w: expected %s, got sha256:%s", ErrArtifactDigestMismatch, expectedDigest, hash) + } if err := p.updateCacheDB(ecosystem, name, filename, pkgPURL, versionPURL, downloadURL, storagePath, hash, size, artifact.ContentType); err != nil { p.Logger.Warn("failed to update cache database", "error", err) @@ -912,3 +934,7 @@ func (p *Proxy) fetchAndCacheFromURL(ctx context.Context, ecosystem, name, versi Cached: false, }, nil } + +// ErrArtifactDigestMismatch indicates that fetched bytes did not match their +// digest-addressed URL and were not recorded in the cache database. +var ErrArtifactDigestMismatch = errors.New("artifact digest mismatch") diff --git a/internal/handler/homebrew.go b/internal/handler/homebrew.go new file mode 100644 index 0000000..6155072 --- /dev/null +++ b/internal/handler/homebrew.go @@ -0,0 +1,70 @@ +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 + } + + accept := r.Header.Get("Accept") + if r.Method == http.MethodHead { + h.proxy.proxyMetadataStream(w, r, upstreamURL, accept) + return + } + h.proxy.ProxyCached(w, r, upstreamURL, homebrewMetadataEcosystem, homebrewMetadataCacheKey(requestPath, r.URL.RawQuery), accept) + }) +} + +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 0000000..2d9b947 --- /dev/null +++ b/internal/handler/homebrew_test.go @@ -0,0 +1,307 @@ +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_HeadPreservesUpstreamMethodWithMetadataCacheEnabled(t *testing.T) { + body := `{"payload":"signed bytes","signatures":[]}` + upstreamMethod := "" + upstreamAuthorization := "" + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamMethod = r.Method + upstreamAuthorization = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Header().Set("ETag", `"head-etag"`) + if r.Method != http.MethodHead { + _, _ = 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() + + headRequest := httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil) + headRequest.Header.Set("Authorization", "Bearer client-secret") + head := httptest.NewRecorder() + h.ServeHTTP(head, headRequest) + + 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 upstreamAuthorization != "" { + t.Errorf("upstream Authorization = %q, want empty", upstreamAuthorization) + } + if head.Body.Len() != 0 { + t.Errorf("body length = %d, want 0", head.Body.Len()) + } + if got := head.Header().Get("Content-Length"); got != strconv.Itoa(len(body)) { + t.Errorf("Content-Length = %q, want %d", got, len(body)) + } + if got := head.Header().Get("ETag"); got != `"head-etag"` { + t.Errorf("ETag = %q, want %q", got, `"head-etag"`) + } +} + +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/server/server.go b/internal/server/server.go index 71af1af..c068a67 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -259,7 +259,9 @@ func (s *Server) Start() error { condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL) cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL) juliaHandler := handler.NewJuliaHandler(proxy, s.cfg.BaseURL) + homebrewHandler := handler.NewHomebrewHandler(proxy, s.cfg.Upstream.HomebrewAPI) containerHandler := handler.NewContainerHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.OCI) + handler.RegisterHomebrewArtifacts(containerHandler, s.cfg.Upstream.HomebrewArtifact) helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm) debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian) rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL) @@ -279,6 +281,7 @@ func (s *Server) Start() error { r.Mount("/conda", http.StripPrefix("/conda", condaHandler.Routes())) r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes())) r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.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("/debian", http.StripPrefix("/debian", debianHandler.Routes())) From 7693321f21a249ad39c0f82df62bdc61036ce2a5 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sat, 29 Aug 2026 12:31:10 +0100 Subject: [PATCH 2/2] Fix Homebrew HEAD offline fallback and non-sha256 OCI manifest handling Route Homebrew API HEAD requests through ProxyCached so a warm cache answers without an upstream call and stale entries are served when the upstream is unreachable. HEAD still reaches upstream as HEAD when metadata caching is disabled. Limit OCI manifest digest verification to sha256 references and Docker-Content-Digest headers so other digest algorithms are proxied instead of rejected, and log the failing expected value. --- internal/handler/container_manifest.go | 14 ++-- internal/handler/container_test.go | 32 +++++++++ internal/handler/homebrew.go | 7 +- internal/handler/homebrew_test.go | 98 ++++++++++++++++++++------ 4 files changed, 118 insertions(+), 33 deletions(-) diff --git a/internal/handler/container_manifest.go b/internal/handler/container_manifest.go index c4ff9c4..b80cfcd 100644 --- a/internal/handler/container_manifest.go +++ b/internal/handler/container_manifest.go @@ -99,15 +99,17 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request, } 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 } - if (immutable && reference != computedDigest) || contentDigest != computedDigest { - h.proxy.Logger.Error("upstream manifest failed digest verification", - "name", name, "reference", reference, "expected", contentDigest, "actual", computedDigest) - h.containerError(w, http.StatusBadGateway, "DIGEST_INVALID", "manifest digest verification failed") - return - } manifest := &cachedContainerManifest{ body: body, diff --git a/internal/handler/container_test.go b/internal/handler/container_test.go index 8f5217d..4522e1d 100644 --- a/internal/handler/container_test.go +++ b/internal/handler/container_test.go @@ -728,6 +728,38 @@ func TestContainerHandler_ManifestDigestMismatchIsNotCached(t *testing.T) { } } +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")) diff --git a/internal/handler/homebrew.go b/internal/handler/homebrew.go index 6155072..cd5bbfa 100644 --- a/internal/handler/homebrew.go +++ b/internal/handler/homebrew.go @@ -55,12 +55,7 @@ func (h *HomebrewHandler) Routes() http.Handler { upstreamURL += "?" + r.URL.RawQuery } - accept := r.Header.Get("Accept") - if r.Method == http.MethodHead { - h.proxy.proxyMetadataStream(w, r, upstreamURL, accept) - return - } - h.proxy.ProxyCached(w, r, upstreamURL, homebrewMetadataEcosystem, homebrewMetadataCacheKey(requestPath, r.URL.RawQuery), accept) + h.proxy.ProxyCached(w, r, upstreamURL, homebrewMetadataEcosystem, homebrewMetadataCacheKey(requestPath, r.URL.RawQuery), r.Header.Get("Accept")) }) } diff --git a/internal/handler/homebrew_test.go b/internal/handler/homebrew_test.go index 2d9b947..e8931ad 100644 --- a/internal/handler/homebrew_test.go +++ b/internal/handler/homebrew_test.go @@ -99,19 +99,22 @@ func TestHomebrewHandler_PreservesSignedResponseAndClientValidators(t *testing.T } } -func TestHomebrewHandler_HeadPreservesUpstreamMethodWithMetadataCacheEnabled(t *testing.T) { +func TestHomebrewHandler_HeadUsesMetadataCacheAndSurvivesOutage(t *testing.T) { body := `{"payload":"signed bytes","signatures":[]}` - upstreamMethod := "" - upstreamAuthorization := "" + available := true + requests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamMethod = r.Method - upstreamAuthorization = r.Header.Get("Authorization") + 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("Content-Length", strconv.Itoa(len(body))) w.Header().Set("ETag", `"head-etag"`) - if r.Method != http.MethodHead { - _, _ = io.WriteString(w, body) - } + _, _ = io.WriteString(w, body) })) defer upstream.Close() @@ -121,28 +124,81 @@ func TestHomebrewHandler_HeadPreservesUpstreamMethodWithMetadataCacheEnabled(t * proxy.HTTPClient = upstream.Client() h := NewHomebrewHandler(proxy, upstream.URL+"/api").Routes() - headRequest := httptest.NewRequest(http.MethodHead, "/formula.jws.json", nil) - headRequest.Header.Set("Authorization", "Bearer client-secret") - head := httptest.NewRecorder() - h.ServeHTTP(head, headRequest) + 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 upstreamAuthorization != "" { - t.Errorf("upstream Authorization = %q, want empty", upstreamAuthorization) - } if head.Body.Len() != 0 { t.Errorf("body length = %d, want 0", head.Body.Len()) } - if got := head.Header().Get("Content-Length"); got != strconv.Itoa(len(body)) { - t.Errorf("Content-Length = %q, want %d", got, len(body)) - } - if got := head.Header().Get("ETag"); got != `"head-etag"` { - t.Errorf("ETag = %q, want %q", got, `"head-etag"`) + if got := head.Header().Get("Content-Length"); got != "42" { + t.Errorf("Content-Length = %q, want 42", got) } }