Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-08-23 12:24:57 -04:00

Compare commits

...
Author SHA1 Message Date
Andrew Nesbitt
2e672fc5ca
Add Homebrew JSON API and bottle proxy support 2026-08-17 09:40:49 +01:00
12 changed files with 812 additions and 60 deletions

View file

@ -40,6 +40,7 @@ Resolution order: package override, then ecosystem override, then global default
| CRAN | R | | ✓ | | CRAN | R | | ✓ |
| Julia | Julia | | ✓ | | Julia | Julia | | ✓ |
| Container | Docker/OCI | | ✓ | | Container | Docker/OCI | | ✓ |
| Homebrew | macOS/Linux | | ✓ |
| Debian | Debian/Ubuntu | | ✓ | | Debian | Debian/Ubuntu | | ✓ |
| RPM | RHEL/Fedora | | ✓ | | RPM | RHEL/Fedora | | ✓ |
| Alpine | Alpine Linux | | ✗ | | Alpine | Alpine Linux | | ✗ |
@ -157,6 +158,29 @@ export GOPROXY=http://localhost:8080/go,direct
Or in your shell profile for persistence. 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) ### Hex (Elixir)
Configure in `~/.hex/hex.config`: Configure in `~/.hex/hex.config`:
@ -670,7 +694,9 @@ Recently cached:
| `GET /cran/*` | CRAN (R) protocol | | `GET /cran/*` | CRAN (R) protocol |
| `GET /julia/*` | Julia Pkg server protocol | | `GET /julia/*` | Julia Pkg server protocol |
| `GET /helm/{repository}/*` | HTTP Helm chart repository protocol | | `GET /helm/{repository}/*` | HTTP Helm chart repository protocol |
| `GET /homebrew/*` | Homebrew JSON API |
| `GET /v2/*` | OCI/Docker registry protocol | | `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 /debian/*` | Debian/APT repository protocol |
| `GET /rpm/*` | RPM/Yum repository protocol | | `GET /rpm/*` | RPM/Yum repository protocol |

View file

@ -102,6 +102,12 @@ upstream:
# Debian/APT repository URL (used by /debian endpoint) # Debian/APT repository URL (used by /debian endpoint)
debian: "http://deb.debian.org/debian" 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}/) # Named HTTP Helm chart repositories (used by /helm/{name}/)
# helm: # helm:
# bitnami: "https://charts.bitnami.com/bitnami" # bitnami: "https://charts.bitnami.com/bitnami"

View file

@ -143,6 +143,8 @@ upstream:
gradle_plugin_portal: "https://plugins.gradle.org/m2" gradle_plugin_portal: "https://plugins.gradle.org/m2"
cargo: "https://index.crates.io" cargo: "https://index.crates.io"
cargo_download: "https://static.crates.io/crates" 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}/. # Named HTTP Helm chart repositories, served at /helm/{name}/.
helm: helm:

View file

@ -318,6 +318,14 @@ type UpstreamConfig struct {
// Default: http://deb.debian.org/debian // Default: http://deb.debian.org/debian
Debian string `json:"debian" yaml:"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. // Helm maps repository names to HTTP Helm chart repository URLs.
// Requests use /helm/{name}/index.yaml and chart URLs in the index are // Requests use /helm/{name}/index.yaml and chart URLs in the index are
// rewritten to the same named proxy endpoint. // rewritten to the same named proxy endpoint.
@ -476,6 +484,8 @@ func Default() *Config {
Cargo: "https://index.crates.io", Cargo: "https://index.crates.io",
CargoDownload: "https://static.crates.io/crates", CargoDownload: "https://static.crates.io/crates",
Debian: "http://deb.debian.org/debian", Debian: "http://deb.debian.org/debian",
HomebrewAPI: "https://formulae.brew.sh/api",
HomebrewArtifact: "https://ghcr.io",
}, },
Gradle: GradleConfig{ Gradle: GradleConfig{
BuildCache: GradleBuildCacheConfig{ BuildCache: GradleBuildCacheConfig{
@ -566,6 +576,8 @@ func (c *Config) LoadFromEnv() {
setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN") setEnvString(&c.Upstream.Maven, "PROXY_UPSTREAM_MAVEN")
setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL") setEnvString(&c.Upstream.GradlePluginPortal, "PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL")
setEnvString(&c.Upstream.Debian, "PROXY_UPSTREAM_DEBIAN") 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") setEnvString(&c.Cooldown.Default, "PROXY_COOLDOWN_DEFAULT")
setEnvBool(&c.CacheMetadata, "PROXY_CACHE_METADATA") setEnvBool(&c.CacheMetadata, "PROXY_CACHE_METADATA")
setEnvBool(&c.MirrorAPI, "PROXY_MIRROR_API") setEnvBool(&c.MirrorAPI, "PROXY_MIRROR_API")

View file

@ -44,6 +44,12 @@ func TestDefault(t *testing.T) {
if cfg.Upstream.Debian != "http://deb.debian.org/debian" { if cfg.Upstream.Debian != "http://deb.debian.org/debian" {
t.Errorf("Upstream.Debian = %q, want %q", 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) { func TestValidate(t *testing.T) {
@ -217,6 +223,9 @@ log:
format: "json" format: "json"
access_log: access_log:
path: "/var/log/proxy/access.jsonl" 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 { if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("writing config file: %v", err) t.Fatalf("writing config file: %v", err)
@ -248,6 +257,12 @@ access_log:
if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" { if cfg.AccessLog.Path != "/var/log/proxy/access.jsonl" {
t.Errorf("AccessLog.Path = %q, want %q", 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) { 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_MAVEN", "https://maven.example.com/repository/maven-public")
t.Setenv("PROXY_UPSTREAM_GRADLE_PLUGIN_PORTAL", "https://plugins.example.com/m2") 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_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_READ_ONLY", "true")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB") t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_UPLOAD_SIZE", "32MB")
t.Setenv("PROXY_GRADLE_BUILD_CACHE_MAX_AGE", "12h") 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" { if cfg.Upstream.Debian != "http://archive.ubuntu.com/ubuntu" {
t.Errorf("Upstream.Debian = %q, want %q", 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 { if !cfg.Gradle.BuildCache.ReadOnly {
t.Error("Gradle.BuildCache.ReadOnly = false, want true") t.Error("Gradle.BuildCache.ReadOnly = false, want true")
} }

View file

@ -26,6 +26,12 @@ type ContainerHandler struct {
registryURL string registryURL string
proxyURL string proxyURL string
namedRegistries map[string]string namedRegistries map[string]string
registries []containerRegistry
}
type containerRegistry struct {
repositoryPrefix string
registryURL string
} }
// NewContainerHandler creates a new container registry protocol handler. // NewContainerHandler creates a new container registry protocol handler.
@ -47,6 +53,36 @@ func NewContainerHandler(proxy *Proxy, proxyURL string, namedRegistries ...map[s
return h 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. // Routes returns the HTTP handler for container registry requests.
// Mount this at /v2 on your router. // Mount this at /v2 on your router.
func (h *ContainerHandler) Routes() http.Handler { func (h *ContainerHandler) Routes() http.Handler {
@ -114,10 +150,8 @@ func (h *ContainerHandler) handleBlobDownload(w http.ResponseWriter, r *http.Req
} }
if cached != nil { if cached != nil {
w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Docker-Content-Digest", digest)
if cached.ContentType != "" { if cached.ContentType == "" {
w.Header().Set("Content-Type", cached.ContentType) cached.ContentType = "application/octet-stream"
} else {
w.Header().Set("Content-Type", "application/octet-stream")
} }
serveArtifact(w, r.Method, cached) serveArtifact(w, r.Method, cached)
return 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. // 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(), r.Context(),
"oci", "oci",
cacheName, cacheName,
digest, // use digest as version digest, // use digest as version
filename, filename,
fmt.Sprintf("%s/v2/%s/blobs/%s", registryURL, upstreamName, digest), fmt.Sprintf("%s/v2/%s/blobs/%s", registryURL, upstreamName, digest),
digest,
) )
if err != nil { 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") h.containerError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry")
return 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.proxy.Logger.Error("failed to fetch blob", "error", err)
h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob") h.containerError(w, http.StatusBadGateway, "INTERNAL_ERROR", "failed to fetch blob")
return return
} }
w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Docker-Content-Digest", digest)
if result.ContentType != "" { if result.ContentType == "" {
w.Header().Set("Content-Type", result.ContentType) result.ContentType = "application/octet-stream"
} else {
w.Header().Set("Content-Type", "application/octet-stream")
} }
ServeArtifact(w, result) ServeArtifact(w, result)
} }
@ -241,18 +279,22 @@ func (h *ContainerHandler) proxyBlobHead(w http.ResponseWriter, r *http.Request,
} }
defer func() { _ = resp.Body.Close() }() 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 != "" { if v := resp.Header.Get(header); v != "" {
w.Header().Set(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) w.WriteHeader(resp.StatusCode)
} }
// registryForName resolves a client-visible OCI repository name to an upstream // registryForName resolves a client-visible OCI repository name to an upstream
// registry and its repository name. Named upstreams use upstream/{name}/ as a // 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) { func (h *ContainerHandler) registryForName(name string) (registryURL, upstreamName, cacheName string, ok bool) {
parts := strings.SplitN(name, "/", registrySelectorParts) parts := strings.SplitN(name, "/", registrySelectorParts)
if len(parts) >= 2 && parts[0] == "upstream" { 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 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. // containerError writes an OCI-compliant error response.

View file

@ -30,6 +30,7 @@ type cachedContainerManifest struct {
contentDigest string contentDigest string
etag string etag string
size int64 size int64
lastModified time.Time
fetchedAt time.Time fetchedAt time.Time
} }
@ -44,7 +45,7 @@ func (h *ContainerHandler) serveManifest(w http.ResponseWriter, r *http.Request,
immutable := manifestDigestReferencePattern.MatchString(reference) immutable := manifestDigestReferencePattern.MatchString(reference)
if cached != nil && (immutable || h.containerManifestFresh(cached)) { if cached != nil && (immutable || h.containerManifestFresh(cached)) {
writeContainerManifest(w, r.Method, cached, false) writeContainerManifest(w, r, cached, false)
return 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 { if err := h.storeContainerManifest(r.Context(), cacheKey, cached); err != nil {
h.proxy.Logger.Warn("failed to refresh cached container manifest", "error", err) h.proxy.Logger.Warn("failed to refresh cached container manifest", "error", err)
} }
writeContainerManifest(w, r.Method, cached, false) writeContainerManifest(w, r, cached, false)
return return
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
if cached != nil && shouldServeStaleManifest(resp.StatusCode) { if cached != nil && shouldServeStaleManifest(resp.StatusCode) {
writeContainerManifest(w, r.Method, cached, true) writeContainerManifest(w, r, cached, true)
return return
} }
copyContainerManifestHeaders(w.Header(), resp.Header) 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)) h.serveStaleManifestOrError(w, r, cached, fmt.Errorf("reading manifest: %w", err))
return 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{ manifest := &cachedContainerManifest{
body: body, body: body,
contentType: resp.Header.Get("Content-Type"), contentType: resp.Header.Get("Content-Type"),
contentDigest: resp.Header.Get("Docker-Content-Digest"), contentDigest: contentDigest,
etag: resp.Header.Get("ETag"), etag: resp.Header.Get("ETag"),
size: int64(len(body)), size: int64(len(body)),
lastModified: parseHTTPTime(resp.Header.Get("Last-Modified")),
fetchedAt: time.Now(), fetchedAt: time.Now(),
} }
if manifest.contentDigest == "" {
manifest.contentDigest = sha256Digest(body)
}
if err := h.storeContainerManifest(r.Context(), cacheKey, manifest); err != nil { if err := h.storeContainerManifest(r.Context(), cacheKey, manifest); err != nil {
h.proxy.Logger.Warn("failed to cache container manifest", "error", err) 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) 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) { func (h *ContainerHandler) serveStaleManifestOrError(w http.ResponseWriter, r *http.Request, cached *cachedContainerManifest, err error) {
if cached != nil { if cached != nil {
h.proxy.Logger.Warn("upstream manifest fetch failed, serving stale cache", "error", err) 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 return
} }
h.proxy.Logger.Error("failed to fetch manifest", "error", err) 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 { if entry.Size.Valid {
manifest.size = entry.Size.Int64 manifest.size = entry.Size.Int64
} }
if entry.LastModified.Valid {
manifest.lastModified = entry.LastModified.Time
}
if entry.FetchedAt.Valid { if entry.FetchedAt.Valid {
manifest.fetchedAt = entry.FetchedAt.Time 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 != ""}, ContentType: sql.NullString{String: manifest.contentType, Valid: manifest.contentType != ""},
ContentDigest: sql.NullString{String: manifest.contentDigest, Valid: manifest.contentDigest != ""}, ContentDigest: sql.NullString{String: manifest.contentDigest, Valid: manifest.contentDigest != ""},
Size: sql.NullInt64{Int64: size, Valid: true}, 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()}, 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 != "" { if manifest.contentType != "" {
w.Header().Set("Content-Type", 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 != "" { if manifest.etag != "" {
w.Header().Set("ETag", 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 { if stale {
w.Header().Set("Warning", containerStaleWarning) 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) w.WriteHeader(http.StatusOK)
if method != http.MethodHead { if r.Method != http.MethodHead {
_, _ = w.Write(manifest.body) _, _ = w.Write(manifest.body)
} }
} }
@ -234,13 +262,18 @@ func containerManifestAccept(r *http.Request) string {
} }
func copyContainerManifestHeaders(destination, source http.Header) { 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 != "" { if value := source.Get(header); value != "" {
destination.Set(header, value) destination.Set(header, value)
} }
} }
} }
func parseHTTPTime(value string) time.Time {
parsed, _ := http.ParseTime(value)
return parsed
}
func shouldServeStaleManifest(status int) bool { func shouldServeStaleManifest(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
} }

View file

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strconv" "strconv"
"strings"
"testing" "testing"
"time" "time"
@ -135,17 +136,18 @@ func TestContainerHandler_parseTagsListPath(t *testing.T) {
} }
func TestContainerHandler_NamedOCIRegistryServesHelmArtifacts(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 + `"}]}` 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) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/v2/owner/demo/manifests/1.0.0": case "/v2/owner/demo/manifests/1.0.0":
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") 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) _, _ = io.WriteString(w, manifest)
case "/v2/owner/demo/blobs/" + digest: case "/v2/owner/demo/blobs/" + digest:
w.Header().Set("Content-Type", "application/vnd.cncf.helm.chart.content.v1.tar+gzip") w.Header().Set("Content-Type", "application/vnd.cncf.helm.chart.content.v1.tar+gzip")
_, _ = io.WriteString(w, "chart archive") _, _ = io.WriteString(w, blob)
default: default:
http.NotFound(w, r) 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) { func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd" blob := "upstream blob"
digest := sha256Digest([]byte(blob))
registryRequests := 0 registryRequests := 0
tokenRequests := 0 tokenRequests := 0
var upstream *httptest.Server var upstream *httptest.Server
@ -202,7 +224,7 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
return return
} }
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.WriteString(w, "upstream blob") _, _ = io.WriteString(w, blob)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
@ -233,8 +255,8 @@ func TestContainerHandler_BlobDownload_DiscoversBearerChallenge(t *testing.T) {
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String()) t.Fatalf("status = %d, want %d; body: %s", w.Code, http.StatusOK, w.Body.String())
} }
if got := w.Body.String(); got != "upstream blob" { if got := w.Body.String(); got != blob {
t.Errorf("body = %q, want %q", got, "upstream 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) { func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *testing.T) {
digest := "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
blob := "cached image blob" blob := "cached image blob"
manifestDigest := sha256Digest([]byte(manifest))
blobDigest := sha256Digest([]byte(blob))
registryAvailable := true registryAvailable := true
tokenAvailable := true tokenAvailable := true
registryRequests := 0 registryRequests := 0
@ -284,9 +398,9 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
switch r.URL.Path { switch r.URL.Path {
case "/v2/library/nginx/manifests/latest": case "/v2/library/nginx/manifests/latest":
w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") 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) _, _ = io.WriteString(w, manifest)
case "/v2/library/nginx/blobs/" + digest: case "/v2/library/nginx/blobs/" + blobDigest:
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.WriteString(w, blob) _, _ = io.WriteString(w, blob)
default: default:
@ -316,7 +430,7 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
body string body string
}{ }{
{path: "/library/nginx/manifests/latest", body: manifest}, {path: "/library/nginx/manifests/latest", body: manifest},
{path: "/library/nginx/blobs/" + digest, body: blob}, {path: "/library/nginx/blobs/" + blobDigest, body: blob},
} { } {
response := httptest.NewRecorder() response := httptest.NewRecorder()
warmHandler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, request.path, nil)) warmHandler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, request.path, nil))
@ -349,13 +463,14 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
}).Routes() }).Routes()
for _, request := range []struct { for _, request := range []struct {
name string name string
path string path string
body string body string
digest string
}{ }{
{name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest}, {name: "tag manifest", path: "/library/nginx/manifests/latest", body: manifest, digest: manifestDigest},
{name: "digest manifest", path: "/library/nginx/manifests/" + digest, body: manifest}, {name: "digest manifest", path: "/library/nginx/manifests/" + manifestDigest, body: manifest, digest: manifestDigest},
{name: "blob", path: "/library/nginx/blobs/" + digest, body: blob}, {name: "blob", path: "/library/nginx/blobs/" + blobDigest, body: blob, digest: blobDigest},
} { } {
t.Run(request.name, func(t *testing.T) { t.Run(request.name, func(t *testing.T) {
response := httptest.NewRecorder() response := httptest.NewRecorder()
@ -366,8 +481,8 @@ func TestContainerHandler_CachedImagePullSurvivesRegistryAndTokenOutages(t *test
if got := response.Body.String(); got != request.body { if got := response.Body.String(); got != request.body {
t.Errorf("body = %q, want %q", got, request.body) t.Errorf("body = %q, want %q", got, request.body)
} }
if got := response.Header().Get("Docker-Content-Digest"); got != digest { if got := response.Header().Get("Docker-Content-Digest"); got != request.digest {
t.Errorf("Docker-Content-Digest = %q, want %q", got, 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) { func TestContainerHandler_ManifestByDigest_CacheHitSkipsUpstream(t *testing.T) {
digest := "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` 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 upstreamAvailable := true
upstreamRequests := 0 upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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("Content-Type", "application/vnd.oci.image.manifest.v1+json")
w.Header().Set("Docker-Content-Digest", digest) w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("ETag", `"manifest-etag"`) w.Header().Set("ETag", `"manifest-etag"`)
w.Header().Set("Last-Modified", lastModified.Format(http.TimeFormat))
if r.Method != http.MethodHead { if r.Method != http.MethodHead {
_, _ = io.WriteString(w, manifest) _, _ = 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 { if got := second.Header().Get("Docker-Content-Digest"); got != digest {
t.Errorf("cached Docker-Content-Digest = %q, want %q", 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() head := httptest.NewRecorder()
h.Routes().ServeHTTP(head, httptest.NewRequest(http.MethodHead, "/library/nginx/manifests/"+digest, nil)) 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) { func TestContainerHandler_ManifestByTag_UsesStaleCacheOnUpstreamFailure(t *testing.T) {
digest := "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}` manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json"}`
digest := sha256Digest([]byte(manifest))
upstreamAvailable := true upstreamAvailable := true
upstreamRequests := 0 upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 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) { func TestContainerHandler_ManifestByTag_CachesDigestAlias(t *testing.T) {
digest := "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}` manifest := `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json"}`
digest := sha256Digest([]byte(manifest))
upstreamAvailable := true upstreamAvailable := true
upstreamRequests := 0 upstreamRequests := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) { func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) {
oldDigest := "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" manifest := `{"schemaVersion":2}`
oldDigest := sha256Digest([]byte(manifest))
newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" newDigest := "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
currentDigest := oldDigest currentDigest := oldDigest
upstreamRequests := 0 upstreamRequests := 0
@ -668,7 +866,7 @@ func TestContainerHandler_ManifestByTag_StaleHeadChecksUpstream(t *testing.T) {
w.Header().Set("Docker-Content-Digest", currentDigest) w.Header().Set("Docker-Content-Digest", currentDigest)
w.Header().Set("ETag", `"`+currentDigest+`"`) w.Header().Set("ETag", `"`+currentDigest+`"`)
if r.Method != http.MethodHead { if r.Method != http.MethodHead {
_, _ = io.WriteString(w, `{"schemaVersion":2}`) _, _ = io.WriteString(w, manifest)
} }
})) }))
defer upstream.Close() defer upstream.Close()

View file

@ -633,6 +633,8 @@ func (p *Proxy) fetchUpstreamMetadata(ctx context.Context, upstreamURL string, e
if entry != nil && entry.ETag.Valid { if entry != nil && entry.ETag.Valid {
req.Header.Set("If-None-Match", entry.ETag.String) 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) 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) { func (p *Proxy) writeMetadataCachedResponse(w http.ResponseWriter, r *http.Request, ecosystem, cacheKey string, body []byte, contentType string) {
cm := p.lookupCachedMeta(ecosystem, cacheKey) 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 cm.etag != "" {
if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag { if match := r.Header.Get("If-None-Match"); match != "" && match == cm.etag {
w.WriteHeader(http.StatusNotModified) 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-Type", contentType)
w.Header().Set("Content-Length", strconv.Itoa(len(body))) 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 { if cm.stale {
w.Header().Set("Warning", `110 - "Response is Stale"`) w.Header().Set("Warning", `110 - "Response is Stale"`)
} }
w.WriteHeader(http.StatusOK) 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 // proxyMetadataStream forwards an upstream metadata response by streaming it to the client
// without buffering the full body in memory. // without buffering the full body in memory.
func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upstreamURL string, acceptHeaders ...string) { 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 { if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError) http.Error(w, "failed to create request", http.StatusInternalServerError)
return return
@ -841,7 +845,9 @@ func (p *Proxy) proxyMetadataStream(w http.ResponseWriter, r *http.Request, upst
} }
w.WriteHeader(resp.StatusCode) 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) { 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. // GetOrFetchArtifactFromURL retrieves an artifact from cache or fetches from a specific URL.
// This is useful for registries where download URLs are determined from metadata. // 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) { 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 // GetOrFetchArtifactFromURLWithHeaders retrieves an artifact from cache or fetches from a URL
// with additional request-specific HTTP headers. // with additional request-specific HTTP headers.
func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosystem, name, version, filename, downloadURL string, headers http.Header) (*CacheResult, error) { 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 { if cached, err := p.GetCachedArtifact(ctx, ecosystem, name, version, filename); err != nil {
return nil, err return nil, err
} else if cached != nil { } else if cached != nil {
@ -873,10 +889,10 @@ func (p *Proxy) GetOrFetchArtifactFromURLWithHeaders(ctx context.Context, ecosys
pkgPURL := purl.MakePURLString(ecosystem, name, "") pkgPURL := purl.MakePURLString(ecosystem, name, "")
versionPURL := purl.MakePURLString(ecosystem, name, version) 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", p.Logger.Info("fetching from upstream",
"ecosystem", ecosystem, "name", name, "version", version, "url", downloadURL) "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 { if err != nil {
return nil, fmt.Errorf("storing artifact: %w", err) 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 { 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) 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, Cached: false,
}, nil }, 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")

View file

@ -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[:])
}

View file

@ -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)
}
}

View file

@ -259,7 +259,9 @@ func (s *Server) Start() error {
condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL) condaHandler := handler.NewCondaHandler(proxy, s.cfg.BaseURL)
cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL) cranHandler := handler.NewCRANHandler(proxy, s.cfg.BaseURL)
juliaHandler := handler.NewJuliaHandler(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) 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) helmHandler := handler.NewHelmHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Helm)
debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian) debianHandler := handler.NewDebianHandler(proxy, s.cfg.BaseURL, s.cfg.Upstream.Debian)
rpmHandler := handler.NewRPMHandler(proxy, s.cfg.BaseURL) 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("/conda", http.StripPrefix("/conda", condaHandler.Routes()))
r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes())) r.Mount("/cran", http.StripPrefix("/cran", cranHandler.Routes()))
r.Mount("/julia", http.StripPrefix("/julia", juliaHandler.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("/v2", http.StripPrefix("/v2", containerHandler.Routes()))
r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes())) r.Mount("/helm", http.StripPrefix("/helm", helmHandler.Routes()))
r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes())) r.Mount("/debian", http.StripPrefix("/debian", debianHandler.Routes()))