Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-12 09:12:16 +01:00
|
|
|
"context"
|
2026-10-20 20:03:06 +02:00
|
|
|
"io"
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-10-12 09:12:16 +01:00
|
|
|
"regexp"
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
"strings"
|
|
|
|
|
"sync/atomic"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-10-12 09:12:16 +01:00
|
|
|
"github.com/git-pkgs/proxy/internal/config"
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
upstreamhttp "github.com/git-pkgs/proxy/internal/httpclient"
|
2026-10-19 15:43:57 +08:00
|
|
|
"github.com/git-pkgs/proxy/internal/retention"
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
"github.com/git-pkgs/registries/fetch"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const testReleaseAssetPath = "/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64"
|
|
|
|
|
|
2026-10-19 15:43:57 +08:00
|
|
|
func TestGenericRetentionConfig(t *testing.T) {
|
|
|
|
|
cfg := config.Default()
|
|
|
|
|
cfg.Storage.Retention.Ecosystems = map[string]string{"generic": "30d"}
|
|
|
|
|
cfg.Storage.Retention.Packages = map[string]string{"pkg:generic/jqlang%2Fjq": "90d"}
|
|
|
|
|
|
|
|
|
|
rules, err := cfg.RetentionRules(retention.Default)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("RetentionRules() error = %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := rules.For(genericEcosystem, "pkg:generic/jqlang%2Fjq"); got != 90*24*time.Hour {
|
|
|
|
|
t.Errorf("jqlang/jq retention = %v, want 90d", got)
|
|
|
|
|
}
|
|
|
|
|
if got := rules.For(genericEcosystem, "pkg:generic/other%2Frepo"); got != 30*24*time.Hour {
|
|
|
|
|
t.Errorf("generic ecosystem retention = %v, want 30d", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
func TestParseGitHubReleaseAsset(t *testing.T) {
|
|
|
|
|
tests := []struct {
|
|
|
|
|
path string
|
|
|
|
|
want githubReleaseAsset
|
|
|
|
|
ok bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
"jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64",
|
|
|
|
|
githubReleaseAsset{owner: "jqlang", repo: "jq", tag: "jq-1.7.1", filename: "jq-linux-amd64"},
|
|
|
|
|
true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"cli/cli/releases/download/v2.63.2/gh_2.63.2_linux_amd64.tar.gz",
|
|
|
|
|
githubReleaseAsset{owner: "cli", repo: "cli", tag: "v2.63.2", filename: "gh_2.63.2_linux_amd64.tar.gz"},
|
|
|
|
|
true,
|
|
|
|
|
},
|
|
|
|
|
// Mutable: resolves to whatever is latest today.
|
|
|
|
|
{"jqlang/jq/releases/latest/download/jq-linux-amd64", githubReleaseAsset{}, false},
|
|
|
|
|
// API lookups and tag listings are not assets.
|
|
|
|
|
{"repos/jqlang/jq/releases/tags/jq-1.7.1", githubReleaseAsset{}, false},
|
|
|
|
|
{"jqlang/jq/releases/tag/jq-1.7.1", githubReleaseAsset{}, false},
|
|
|
|
|
// Source archives are a different shape.
|
|
|
|
|
{"jqlang/jq/archive/refs/tags/jq-1.7.1.tar.gz", githubReleaseAsset{}, false},
|
|
|
|
|
// Extra or missing segments.
|
|
|
|
|
{"jqlang/jq/releases/download/jq-1.7.1", githubReleaseAsset{}, false},
|
|
|
|
|
{"jqlang/jq/releases/download/jq-1.7.1/dir/asset", githubReleaseAsset{}, false},
|
|
|
|
|
{"", githubReleaseAsset{}, false},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
got, ok := parseGitHubReleaseAsset(tt.path)
|
|
|
|
|
if ok != tt.ok || got != tt.want {
|
|
|
|
|
t.Errorf("parseGitHubReleaseAsset(%q) = (%+v, %v), want (%+v, %v)", tt.path, got, ok, tt.want, tt.ok)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 09:12:16 +01:00
|
|
|
const testMavenPattern = `dist/(?P<name>maven)/maven-3/(?P<version>[^/]+)/binaries/(?P<file>[^/]+)`
|
|
|
|
|
|
|
|
|
|
func TestMatchArtifactPattern(t *testing.T) {
|
|
|
|
|
named, err := config.CompileGenericArtifactPattern(testMavenPattern)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
unnamed, err := config.CompileGenericArtifactPattern(`dist/v(?P<version>[^/]+)/(?P<file>.+)`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
pattern *regexp.Regexp
|
|
|
|
|
path string
|
|
|
|
|
want genericArtifact
|
|
|
|
|
ok bool
|
|
|
|
|
}{
|
|
|
|
|
{
|
|
|
|
|
"name group",
|
|
|
|
|
named,
|
|
|
|
|
"dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz",
|
|
|
|
|
genericArtifact{name: "maven", version: "3.9.9", filename: "apache-maven-3.9.9-bin.tar.gz"},
|
|
|
|
|
true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name defaults to upstream",
|
|
|
|
|
unnamed,
|
|
|
|
|
"dist/v20.15.0/node-v20.15.0-linux-x64.tar.xz",
|
|
|
|
|
genericArtifact{name: "apache", version: "20.15.0", filename: "node-v20.15.0-linux-x64.tar.xz"},
|
|
|
|
|
true,
|
|
|
|
|
},
|
|
|
|
|
// Patterns match whole paths only.
|
|
|
|
|
{"prefix only", named, "dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz/extra", genericArtifact{}, false},
|
|
|
|
|
{"suffix only", named, "mirror/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz", genericArtifact{}, false},
|
|
|
|
|
{"directory listing", named, "dist/maven/maven-3/3.9.9/binaries/", genericArtifact{}, false},
|
|
|
|
|
// A file group spanning segments would nest storage paths.
|
|
|
|
|
{"file with slash", unnamed, "dist/v20.15.0/win-x64/node.exe", genericArtifact{}, false},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
got, ok := matchArtifactPattern(tt.pattern, "apache", tt.path)
|
|
|
|
|
if ok != tt.ok || got != tt.want {
|
|
|
|
|
t.Errorf("matchArtifactPattern(%q) = (%+v, %v), want (%+v, %v)", tt.path, got, ok, tt.want, tt.ok)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 15:39:23 +01:00
|
|
|
func TestGenericHandler_BuiltinArtifactPatterns(t *testing.T) {
|
|
|
|
|
h := NewGenericHandler(testProxy(), map[string]string{
|
|
|
|
|
"apache": "https://archive.apache.org/",
|
|
|
|
|
"node": "https://nodejs.org",
|
|
|
|
|
"golang": "https://dl.google.com/go",
|
|
|
|
|
"mirror": "https://mirror.example.com",
|
|
|
|
|
}, map[string][]string{
|
2026-10-12 19:22:42 +01:00
|
|
|
"apache": {`extra/(?P<version>[^/]+)/(?P<file>[^/]+)`, testMavenPattern},
|
2026-10-12 15:39:23 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
repository string
|
|
|
|
|
path string
|
|
|
|
|
want genericArtifact
|
|
|
|
|
ok bool
|
|
|
|
|
}{
|
2026-10-12 19:22:42 +01:00
|
|
|
// The configured testMavenPattern overlaps the built-in and takes precedence.
|
2026-10-12 15:39:23 +01:00
|
|
|
{"apache", "dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz",
|
2026-10-12 19:22:42 +01:00
|
|
|
genericArtifact{name: "maven", version: "3.9.9", filename: "apache-maven-3.9.9-bin.tar.gz"}, true},
|
2026-10-12 15:39:23 +01:00
|
|
|
{"apache", "dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz.sha512",
|
2026-10-12 19:22:42 +01:00
|
|
|
genericArtifact{name: "maven", version: "3.9.9", filename: "apache-maven-3.9.9-bin.tar.gz.sha512"}, true},
|
|
|
|
|
{"apache", "dist/maven/maven-4/4.0.0/binaries/apache-maven-4.0.0-bin.tar.gz",
|
|
|
|
|
genericArtifact{name: "maven/maven-4", version: "4.0.0", filename: "apache-maven-4.0.0-bin.tar.gz"}, true},
|
2026-10-12 15:39:23 +01:00
|
|
|
{"apache", "dist/maven/mvnd/1.0.6/maven-mvnd-1.0.6-linux-amd64.tar.gz",
|
|
|
|
|
genericArtifact{name: "maven/mvnd", version: "1.0.6", filename: "maven-mvnd-1.0.6-linux-amd64.tar.gz"}, true},
|
|
|
|
|
{"apache", "dist/maven/maven-3/", genericArtifact{}, false},
|
|
|
|
|
{"apache", "dist/maven/maven-3/3.9.9/source/apache-maven-3.9.9-src.tar.gz", genericArtifact{}, false},
|
|
|
|
|
{"apache", "extra/1.0/file.txt", genericArtifact{name: "apache", version: "1.0", filename: "file.txt"}, true},
|
|
|
|
|
{"node", "dist/v22.11.0/node-v22.11.0-linux-x64.tar.gz",
|
|
|
|
|
genericArtifact{name: "node", version: "v22.11.0", filename: "node-v22.11.0-linux-x64.tar.gz"}, true},
|
|
|
|
|
{"node", "dist/v22.11.0/SHASUMS256.txt",
|
|
|
|
|
genericArtifact{name: "node", version: "v22.11.0", filename: "SHASUMS256.txt"}, true},
|
|
|
|
|
{"node", "dist/index.json", genericArtifact{}, false},
|
|
|
|
|
{"golang", "go1.23.2.linux-amd64.tar.gz",
|
|
|
|
|
genericArtifact{name: "golang", version: "1.23.2", filename: "go1.23.2.linux-amd64.tar.gz"}, true},
|
|
|
|
|
{"golang", "go1.23.2.darwin-arm64.tar.gz.sha256",
|
|
|
|
|
genericArtifact{name: "golang", version: "1.23.2", filename: "go1.23.2.darwin-arm64.tar.gz.sha256"}, true},
|
|
|
|
|
{"golang", "go1.24rc1.windows-amd64.zip",
|
|
|
|
|
genericArtifact{name: "golang", version: "1.24rc1", filename: "go1.24rc1.windows-amd64.zip"}, true},
|
|
|
|
|
{"mirror", "dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz", genericArtifact{}, false},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.repository+"/"+tt.path, func(t *testing.T) {
|
|
|
|
|
got, ok := h.parseArtifact(tt.repository, tt.path)
|
|
|
|
|
if ok != tt.ok || got != tt.want {
|
|
|
|
|
t.Errorf("parseArtifact(%q, %q) = (%+v, %v), want (%+v, %v)", tt.repository, tt.path, got, ok, tt.want, tt.ok)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 09:12:16 +01:00
|
|
|
func TestGenericHandler_ArtifactPatternIsCachedAndOtherPathsAreNot(t *testing.T) {
|
|
|
|
|
const (
|
|
|
|
|
artifactPath = "/dist/maven/maven-3/3.9.9/binaries/apache-maven-3.9.9-bin.tar.gz"
|
|
|
|
|
listingPath = "/dist/maven/maven-3/3.9.9/binaries/"
|
|
|
|
|
)
|
|
|
|
|
tarball := []byte("maven tarball bytes")
|
|
|
|
|
var available atomic.Bool
|
|
|
|
|
available.Store(true)
|
|
|
|
|
var artifactRequests, listingRequests atomic.Int32
|
|
|
|
|
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !available.Load() {
|
|
|
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch r.URL.Path {
|
|
|
|
|
case artifactPath:
|
|
|
|
|
artifactRequests.Add(1)
|
|
|
|
|
_, _ = w.Write(tarball)
|
|
|
|
|
case listingPath:
|
|
|
|
|
listingRequests.Add(1)
|
|
|
|
|
_, _ = w.Write([]byte("<html>listing</html>"))
|
|
|
|
|
default:
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, db, store, _ := setupTestProxy(t)
|
|
|
|
|
store.seekable = true
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy,
|
|
|
|
|
map[string]string{"apache": upstream.URL},
|
|
|
|
|
map[string][]string{"apache": {testMavenPattern}})
|
|
|
|
|
|
|
|
|
|
w := serveGenericRequest(h, "/apache"+artifactPath)
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != string(tarball) {
|
|
|
|
|
t.Fatalf("status = %d, body = %q, want 200 %q", w.Code, w.Body.String(), tarball)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pkg, err := db.GetPackageByEcosystemName(genericEcosystem, "maven")
|
|
|
|
|
if err != nil || pkg == nil {
|
|
|
|
|
t.Fatalf("package generic/maven = %v, %v; want a cached package", pkg, err)
|
|
|
|
|
}
|
|
|
|
|
versions, err := db.GetVersionsByPackagePURL(pkg.PURL)
|
|
|
|
|
if err != nil || len(versions) != 1 || !strings.HasSuffix(versions[0].PURL, "@3.9.9") {
|
|
|
|
|
t.Fatalf("versions = %+v, %v; want one @3.9.9", versions, err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
available.Store(false)
|
|
|
|
|
w = serveGenericRequest(h, "/apache"+artifactPath)
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != string(tarball) {
|
|
|
|
|
t.Fatalf("cached: status = %d, body = %q, want 200 %q", w.Code, w.Body.String(), tarball)
|
|
|
|
|
}
|
|
|
|
|
if got := artifactRequests.Load(); got != 1 {
|
|
|
|
|
t.Errorf("artifact upstream requests = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Paths the pattern does not match keep going through the metadata route.
|
|
|
|
|
available.Store(true)
|
|
|
|
|
for range 2 {
|
|
|
|
|
serveGenericRequest(h, "/apache"+listingPath)
|
|
|
|
|
}
|
|
|
|
|
if got := listingRequests.Load(); got != 2 {
|
|
|
|
|
t.Errorf("listing upstream requests = %d, want 2 with metadata caching off", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenericHandler_ConfiguredArtifactsKeepDistinctPathsAndQueries(t *testing.T) {
|
|
|
|
|
for _, tt := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
targets []string
|
|
|
|
|
}{
|
|
|
|
|
{"platform paths", []string{
|
|
|
|
|
"/releases/tool/1.0.0/linux/tool.tar.gz",
|
|
|
|
|
"/releases/tool/1.0.0/darwin/tool.tar.gz",
|
|
|
|
|
}},
|
|
|
|
|
{"query parameters", []string{
|
|
|
|
|
"/releases/tool/1.0.0/linux/tool.tar.gz?token=example&variant=a%2Bb",
|
|
|
|
|
"/releases/tool/1.0.0/linux/tool.tar.gz?token=other&variant=a+b",
|
|
|
|
|
"/releases/tool/1.0.0/linux/tool.tar.gz",
|
|
|
|
|
}},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
var requests atomic.Int32
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
requests.Add(1)
|
|
|
|
|
// Echo the exact target so dropped or changed queries fail too.
|
|
|
|
|
_, _ = w.Write([]byte(r.URL.RequestURI()))
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, store, _ := setupTestProxy(t)
|
|
|
|
|
store.seekable = true
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
h := NewGenericHandler(proxy,
|
|
|
|
|
map[string]string{"downloads": upstream.URL},
|
|
|
|
|
map[string][]string{"downloads": {`releases/(?P<name>tool)/(?P<version>[^/]+)/(linux|darwin)/(?P<file>[^/]+)`}})
|
|
|
|
|
|
|
|
|
|
for pass := range 2 {
|
|
|
|
|
for _, target := range tt.targets {
|
|
|
|
|
w := serveGenericRequest(h, "/downloads"+target)
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != target {
|
|
|
|
|
t.Fatalf("pass %d, target %q: status = %d, body = %q; want 200 %q",
|
|
|
|
|
pass, target, w.Code, w.Body.String(), target)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
upstream.Close()
|
|
|
|
|
}
|
|
|
|
|
if got := requests.Load(); got != int32(len(tt.targets)) {
|
|
|
|
|
t.Errorf("upstream requests = %d, want %d", got, len(tt.targets))
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
func TestGenericHandler_RejectsUnknownUpstreamAndBadPaths(t *testing.T) {
|
|
|
|
|
h := NewGenericHandler(testProxy(), map[string]string{"github": "https://github.com"})
|
|
|
|
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
name string
|
|
|
|
|
method string
|
|
|
|
|
target string
|
|
|
|
|
want int
|
|
|
|
|
}{
|
|
|
|
|
{"unknown upstream", http.MethodGet, "/gitlab/owner/repo/releases/download/v1/asset", http.StatusNotFound},
|
|
|
|
|
{"missing path", http.MethodGet, "/github", http.StatusNotFound},
|
|
|
|
|
{"missing path with slash", http.MethodGet, "/github/", http.StatusNotFound},
|
|
|
|
|
{"traversal", http.MethodGet, "/github/../etc/passwd", http.StatusBadRequest},
|
|
|
|
|
{"encoded traversal", http.MethodGet, "/github/%2e%2e/etc/passwd", http.StatusBadRequest},
|
|
|
|
|
{"post", http.MethodPost, "/github/owner/repo/releases/download/v1/asset", http.StatusMethodNotAllowed},
|
Host generic files and share one push path across hosted ecosystems
Hosted ecosystems are now entries in one table: each defines its package
names, how the stored filename is chosen, how the manifest is read and which
namespaces are served from pushes alone. npm and Composer move there
unchanged, and generic is added as a third entry.
Generic files are pushed to /api/hosted/generic/{name}/{version}/{file}
and served without a token from /generic/hosted/{name}/{version}/{file},
with the sha256 as ETag. They have no manifest, a version can hold several
files, and they never come from an upstream. "hosted" is reserved as an
upstream.generic name. DELETE with a file removes only that file.
For all hosted ecosystems:
- A second push of the same file is refused with 409. Clients pin pushed
files by version and often by hash, so a published file is no longer
silently replaced. Delete it first to push it again.
- PUT takes an optional ?sha256= and refuses bytes that don't match.
proxy hosted push always sends it.
Versions of generic files carry no version-level integrity, since it could
only describe one of their files; each file is checked against its own
sha256.
Refs #133, #134, #535
2026-10-20 19:39:14 +02:00
|
|
|
{"hosted files with hosting off", http.MethodGet, "/hosted/tools/1.2.0/tool.tar.gz", http.StatusNotFound},
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(tt.method, tt.target, nil))
|
|
|
|
|
if w.Code != tt.want {
|
|
|
|
|
t.Errorf("status = %d, want %d", w.Code, tt.want)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenericHandler_ReleaseAssetIsCachedAndServedWhenUpstreamDown(t *testing.T) {
|
|
|
|
|
asset := []byte("jq binary bytes")
|
|
|
|
|
var available atomic.Bool
|
|
|
|
|
available.Store(true)
|
|
|
|
|
var upstreamRequests atomic.Int32
|
|
|
|
|
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !available.Load() {
|
|
|
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if r.URL.Path != testReleaseAssetPath {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
upstreamRequests.Add(1)
|
|
|
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
|
|
|
_, _ = w.Write(asset)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
2026-10-19 15:43:57 +08:00
|
|
|
proxy, db, store, _ := setupTestProxy(t)
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
store.seekable = true
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": upstream.URL})
|
|
|
|
|
|
|
|
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != string(asset) {
|
|
|
|
|
t.Errorf("body = %q, want %q", got, asset)
|
|
|
|
|
}
|
|
|
|
|
if got := upstreamRequests.Load(); got != 1 {
|
|
|
|
|
t.Fatalf("upstream requests = %d, want 1", got)
|
|
|
|
|
}
|
2026-10-19 15:43:57 +08:00
|
|
|
pkg, err := db.GetPackageByEcosystemName(genericEcosystem, "jqlang/jq")
|
|
|
|
|
if err != nil || pkg == nil {
|
|
|
|
|
t.Fatalf("release package = %v, %v; want a cached package", pkg, err)
|
|
|
|
|
}
|
|
|
|
|
if pkg.PURL != "pkg:generic/jqlang%2Fjq" {
|
|
|
|
|
t.Errorf("release package PURL = %q, want pkg:generic/jqlang%%2Fjq", pkg.PURL)
|
|
|
|
|
}
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
|
|
|
|
|
// Second request must be served from cache, even with the upstream down.
|
|
|
|
|
available.Store(false)
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
rangeRequest := httptest.NewRequest(http.MethodGet, "/github"+testReleaseAssetPath, nil)
|
|
|
|
|
rangeRequest.Header.Set("Range", "bytes=0-2")
|
|
|
|
|
rangeResponse := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(rangeResponse, rangeRequest)
|
|
|
|
|
if rangeResponse.Code != http.StatusPartialContent {
|
|
|
|
|
t.Fatalf("range: status = %d, want 206: %s", rangeResponse.Code, rangeResponse.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := rangeResponse.Body.String(); got != string(asset[:3]) {
|
|
|
|
|
t.Errorf("range: body = %q, want %q", got, asset[:3])
|
|
|
|
|
}
|
|
|
|
|
if got := rangeResponse.Header().Get("Content-Range"); got != "bytes 0-2/15" {
|
|
|
|
|
t.Errorf("range: Content-Range = %q, want %q", got, "bytes 0-2/15")
|
|
|
|
|
}
|
|
|
|
|
if got := upstreamRequests.Load(); got != 1 {
|
|
|
|
|
t.Errorf("upstream requests after range cache hit = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
w = serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("cached: status = %d, want 200: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != string(asset) {
|
|
|
|
|
t.Errorf("cached: body = %q, want %q", got, asset)
|
|
|
|
|
}
|
|
|
|
|
if got := upstreamRequests.Load(); got != 1 {
|
|
|
|
|
t.Errorf("upstream requests after cache hit = %d, want 1", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// HEAD is answered from the same cache entry without a body.
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodHead, "/github"+testReleaseAssetPath, nil))
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("HEAD: status = %d, want 200", w.Code)
|
|
|
|
|
}
|
|
|
|
|
if w.Body.Len() != 0 {
|
|
|
|
|
t.Errorf("HEAD: body length = %d, want 0", w.Body.Len())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 09:12:16 +01:00
|
|
|
func TestGenericHandler_ServesReleaseAssetCachedUnderUnhashedKey(t *testing.T) {
|
|
|
|
|
asset := []byte("jq binary bytes")
|
|
|
|
|
var available atomic.Bool
|
|
|
|
|
available.Store(true)
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !available.Load() {
|
|
|
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, _ = w.Write(asset)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, store, _ := setupTestProxy(t)
|
|
|
|
|
store.seekable = true
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
// Cache the asset the way releases before hashed keys did.
|
|
|
|
|
result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), genericEcosystem,
|
|
|
|
|
"jqlang/jq", "jq-1.7.1", "github/jq-linux-amd64", upstream.URL+testReleaseAssetPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("priming legacy entry: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if result.Reader != nil {
|
|
|
|
|
_ = result.Reader.Close()
|
|
|
|
|
}
|
|
|
|
|
available.Store(false)
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": upstream.URL})
|
|
|
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != string(asset) {
|
|
|
|
|
t.Fatalf("legacy entry: status = %d, body = %q; want 200 %q", w.Code, w.Body.String(), asset)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
w = serveGenericRequest(h, "/github"+testReleaseAssetPath+"?x=1")
|
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
|
|
|
t.Errorf("with query: status = %d, want 502", w.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
repointed := NewGenericHandler(proxy, map[string]string{"github": upstream.URL + "/mirror"})
|
|
|
|
|
w = serveGenericRequest(repointed, "/github"+testReleaseAssetPath)
|
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
|
|
|
t.Errorf("repointed upstream: status = %d, want 502", w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Add generic HTTP download proxy for GitHub release assets (mise/aqua) (#302)
* Add generic HTTP download proxy for GitHub release assets
Adds a /generic/{name}/ route backed by a new upstream.generic named-upstream
map, so tools that download from fixed URL shapes (mise's aqua backend
fetching GitHub release assets, and its tag lookups on api.github.com) can be
pointed at the proxy with client-side URL rewriting. Only configured
upstreams are reachable, so this is not an open HTTP proxy.
Paths shaped like {owner}/{repo}/releases/download/{tag}/{asset} are
version-pinned and go through the artifact cache: fetched once, hashed,
served without revalidation, and still served when the upstream is down.
Every other path goes through the metadata cache with the client's Accept
header and query string replayed, so API responses are fresh within
metadata_ttl, revalidated after that, and served stale when the upstream
fails or rate-limits the request.
Tests cover path classification, unknown upstreams and traversal, cache
hits with the upstream down, HEAD, 404 pass-through, Accept and query
forwarding, stale-on-429, cache isolation between upstreams, and that an
upstream token scoped to the release host is not sent to the object store
it redirects to.
Closes #183.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RDjDeq27CKzEP2o3GWBY7F
* Use fixed Accept header for generic metadata
---------
Co-authored-by: Giles Westwood <giles@gileswestwood.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 16:44:54 +01:00
|
|
|
func TestGenericHandler_ReleaseAssetNotFoundIsNotCached(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": upstream.URL})
|
|
|
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("status = %d, want 404: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenericHandler_MetadataForwardsQueryAndServesStaleOnThrottle(t *testing.T) {
|
|
|
|
|
const apiPath = "/repos/jqlang/jq/releases/tags/jq-1.7.1"
|
|
|
|
|
body := `{"tag_name":"jq-1.7.1"}`
|
|
|
|
|
var throttled atomic.Bool
|
|
|
|
|
var gotQuery string
|
|
|
|
|
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if r.URL.Path != apiPath {
|
|
|
|
|
http.NotFound(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
gotQuery = r.URL.RawQuery
|
|
|
|
|
if throttled.Load() {
|
|
|
|
|
w.Header().Set("Retry-After", "60")
|
|
|
|
|
http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusTooManyRequests)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
w.Header().Set("Content-Type", "application/vnd.github+json")
|
|
|
|
|
w.Header().Set("ETag", `"v1"`)
|
|
|
|
|
_, _ = w.Write([]byte(body))
|
|
|
|
|
}))
|
|
|
|
|
defer upstream.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
proxy.CacheMetadata = true
|
|
|
|
|
// A tiny TTL so the second request is past freshness and has to consult
|
|
|
|
|
// the upstream, and the served copy is marked stale.
|
|
|
|
|
proxy.MetadataTTL = time.Millisecond
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github-api": upstream.URL})
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/github-api"+apiPath+"?per_page=1", nil)
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != body {
|
|
|
|
|
t.Errorf("body = %q, want %q", got, body)
|
|
|
|
|
}
|
|
|
|
|
if gotQuery != "per_page=1" {
|
|
|
|
|
t.Errorf("upstream query = %q, want %q", gotQuery, "per_page=1")
|
|
|
|
|
}
|
|
|
|
|
if ct := w.Header().Get("Content-Type"); ct != "application/vnd.github+json" {
|
|
|
|
|
t.Errorf("Content-Type = %q, want upstream's", ct)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The upstream now throttles us: the cached body must be served stale
|
|
|
|
|
// rather than the 429 being passed through.
|
|
|
|
|
throttled.Store(true)
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
w = httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, req)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("throttled: status = %d, want 200 stale: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != body {
|
|
|
|
|
t.Errorf("throttled: body = %q, want cached %q", got, body)
|
|
|
|
|
}
|
|
|
|
|
if warning := w.Header().Get("Warning"); !strings.Contains(warning, "110") {
|
|
|
|
|
t.Errorf("throttled: Warning = %q, want a 110 stale warning", warning)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenericHandler_DistinctUpstreamsDoNotShareCache(t *testing.T) {
|
|
|
|
|
first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_, _ = w.Write([]byte("from first"))
|
|
|
|
|
}))
|
|
|
|
|
defer first.Close()
|
|
|
|
|
second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_, _ = w.Write([]byte("from second"))
|
|
|
|
|
}))
|
|
|
|
|
defer second.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(first.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"one": first.URL, "two": second.URL})
|
|
|
|
|
|
|
|
|
|
w := serveGenericRequest(h, "/one"+testReleaseAssetPath)
|
|
|
|
|
if got := w.Body.String(); got != "from first" {
|
|
|
|
|
t.Fatalf("one: body = %q, want %q", got, "from first")
|
|
|
|
|
}
|
|
|
|
|
w = serveGenericRequest(h, "/two"+testReleaseAssetPath)
|
|
|
|
|
if got := w.Body.String(); got != "from second" {
|
|
|
|
|
t.Fatalf("two: body = %q, want %q (must not reuse the first upstream's cache entry)", got, "from second")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGenericHandler_UpstreamAuthIsScopedToTheConfiguredHost(t *testing.T) {
|
|
|
|
|
asset := []byte("private asset")
|
|
|
|
|
var storageAuth atomic.Value
|
|
|
|
|
storageAuth.Store("unset")
|
|
|
|
|
|
|
|
|
|
// The object store the release host redirects to must never see the
|
|
|
|
|
// token configured for the release host.
|
|
|
|
|
objectStore := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
storageAuth.Store(r.Header.Get("Authorization"))
|
|
|
|
|
_, _ = w.Write(asset)
|
|
|
|
|
}))
|
|
|
|
|
defer objectStore.Close()
|
|
|
|
|
|
|
|
|
|
var releaseAuth string
|
|
|
|
|
releaseHost := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
releaseAuth = r.Header.Get("Authorization")
|
|
|
|
|
if releaseAuth != "Bearer github-token" {
|
|
|
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
http.Redirect(w, r, objectStore.URL+"/signed"+r.URL.Path, http.StatusFound)
|
|
|
|
|
}))
|
|
|
|
|
defer releaseHost.Close()
|
|
|
|
|
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
authClient := &http.Client{Transport: upstreamhttp.NewTransport(http.DefaultTransport,
|
|
|
|
|
upstreamhttp.AuthFunc(func(url string) (string, string) {
|
|
|
|
|
if strings.HasPrefix(url, releaseHost.URL) {
|
|
|
|
|
return "Authorization", "Bearer github-token"
|
|
|
|
|
}
|
|
|
|
|
return "", ""
|
|
|
|
|
}))}
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(authClient), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github": releaseHost.URL})
|
|
|
|
|
w := serveGenericRequest(h, "/github"+testReleaseAssetPath)
|
|
|
|
|
if w.Code != http.StatusOK {
|
|
|
|
|
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
if got := w.Body.String(); got != string(asset) {
|
|
|
|
|
t.Errorf("body = %q, want %q", got, asset)
|
|
|
|
|
}
|
|
|
|
|
if releaseAuth != "Bearer github-token" {
|
|
|
|
|
t.Errorf("release host Authorization = %q, want the configured token", releaseAuth)
|
|
|
|
|
}
|
|
|
|
|
if got := storageAuth.Load(); got != "" {
|
|
|
|
|
t.Errorf("object store Authorization = %q, want none after the cross-host redirect", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func serveGenericRequest(h *GenericHandler, target string) *httptest.ResponseRecorder {
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
|
|
|
return w
|
|
|
|
|
}
|
2026-10-20 20:03:06 +02:00
|
|
|
|
|
|
|
|
func TestGenericHandler_UpstreamNamedHostedWhileHostingIsOff(t *testing.T) {
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
_, _ = io.WriteString(w, "from upstream "+r.URL.Path)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(upstream.Close)
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"hosted": upstream.URL})
|
|
|
|
|
|
|
|
|
|
w := httptest.NewRecorder()
|
|
|
|
|
h.Routes().ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/hosted/index.json", nil))
|
|
|
|
|
if w.Code != http.StatusOK || w.Body.String() != "from upstream /index.json" {
|
|
|
|
|
t.Errorf("status = %d, body %q, want the upstream response", w.Code, w.Body)
|
|
|
|
|
}
|
|
|
|
|
}
|
Require generic_paths for generic upstreams with credentials
A generic upstream forwards any path below /generic/{name}/. The
documented mise setup puts a GITHUB_TOKEN on api.github.com to raise
the rate limit, so anyone who could reach the proxy could make any GET
request with that token, including for private repositories it can
read, and the answers went into the shared metadata cache. upstream.git
already refuses an upstream with credentials unless it lists its repos.
upstream.generic_paths now lists, per generic upstream, the paths it
may serve, with the same pattern syntax as git repos. Other paths
answer 404 without reaching the upstream, and so does a path holding
'%', '?', '#' or a backslash, which the upstream could read as a
different path than the one that matched. Config validation refuses a
generic upstream that upstream.auth has credentials for unless it has
generic_paths, which also covers a github.com token reaching the
generic github upstream. Upstreams without credentials keep serving
every path.
The docs and example config show the paths mise needs and recommend a
fine-grained token limited to public repositories.
2026-10-24 08:02:22 +02:00
|
|
|
|
|
|
|
|
func TestGenericHandler_ServesOnlyConfiguredPaths(t *testing.T) {
|
|
|
|
|
var requested []string
|
|
|
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
requested = append(requested, r.URL.RequestURI())
|
|
|
|
|
_, _ = io.WriteString(w, "from upstream "+r.URL.Path)
|
|
|
|
|
}))
|
|
|
|
|
t.Cleanup(upstream.Close)
|
|
|
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
|
|
|
proxy.HTTPClient = upstream.Client()
|
|
|
|
|
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(upstream.Client()), fetch.WithMaxRetries(0))
|
|
|
|
|
proxy.Fetcher = fetcher
|
|
|
|
|
t.Cleanup(func() { _ = fetcher.Close() })
|
|
|
|
|
|
|
|
|
|
h := NewGenericHandler(proxy, map[string]string{"github-api": upstream.URL, "open": upstream.URL}).
|
|
|
|
|
WithPaths(map[string][]string{"github-api": {"repos/*/*/releases/**", "rate_limit"}})
|
|
|
|
|
|
|
|
|
|
for _, target := range []string{
|
|
|
|
|
"/github-api/repos/jqlang/jq/releases/tags/jq-1.7.1",
|
|
|
|
|
"/github-api/rate_limit",
|
|
|
|
|
"/open/user/repos",
|
|
|
|
|
} {
|
|
|
|
|
if w := serveGenericRequest(h, target); w.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("GET %s: status = %d, want 200: %s", target, w.Code, w.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
served := len(requested)
|
|
|
|
|
for _, target := range []string{
|
|
|
|
|
"/github-api/user/repos",
|
|
|
|
|
"/github-api/repos/acme/private/contents/secret.txt",
|
|
|
|
|
"/github-api/repos/jqlang/jq/releases",
|
|
|
|
|
"/github-api/repos/jqlang/jq%3F/releases/x",
|
|
|
|
|
"/github-api/repos/jqlang/jq/releases/%2575ser",
|
|
|
|
|
"/github-api/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64",
|
|
|
|
|
} {
|
|
|
|
|
if w := serveGenericRequest(h, target); w.Code != http.StatusNotFound {
|
|
|
|
|
t.Errorf("GET %s: status = %d, want 404", target, w.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(requested) != served {
|
|
|
|
|
t.Errorf("upstream saw %v for paths outside generic_paths", requested[served:])
|
|
|
|
|
}
|
|
|
|
|
}
|