From 2e672fc5cac40dc20bc6600226260b4ac88da509 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 17 Aug 2026 09:34:23 +0100 Subject: [PATCH] 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()))