From ca269c50468382499efc92ca560dab978561f1be Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Tue, 20 Oct 2026 22:19:23 +1100 Subject: [PATCH] Forward module and pagination params on puppet release listings (#161) A puppet remote drops the query string on `/v3/releases`, so `?module=` reaches the Forge unfiltered and clients get releases of every module instead of the one they asked for. - forward validated `module`, `limit` (capped at 100) and `offset` on `v3/releases` via `QueryForwarder` - document the forwarded params and the Forge API `base_url` in `docs/puppet/remote.md` Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/161 Co-authored-by: unkin-agent Co-committed-by: unkin-agent --- docs/puppet/remote.md | 3 +++ internal/provider/puppet/puppet.go | 27 +++++++++++++++++++++++++ internal/provider/puppet/puppet_test.go | 25 +++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/docs/puppet/remote.md b/docs/puppet/remote.md index 8c7435e..c3a877c 100644 --- a/docs/puppet/remote.md +++ b/docs/puppet/remote.md @@ -5,3 +5,6 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md). | Type | Mutable (auto-detected) | Immutable (auto-detected) | |---|---|---| | `puppet` | `v3/modules/*`, `v3/releases*` | `.tar.gz` | + +`/v3/releases` forwards the `module`, `limit` and `offset` query params +upstream; set `base_url` to the Forge API root, e.g. `https://forgeapi.puppet.com`. diff --git a/internal/provider/puppet/puppet.go b/internal/provider/puppet/puppet.go index 03bf1d8..c2396d8 100644 --- a/internal/provider/puppet/puppet.go +++ b/internal/provider/puppet/puppet.go @@ -3,6 +3,7 @@ package puppet import ( "context" "net/http" + "strconv" "strings" "git.unkin.net/unkin/artifactapi/internal/auth" @@ -39,6 +40,32 @@ func (p *Provider) UpstreamURL(remote models.Remote, path string) string { return strings.TrimRight(remote.BaseURL, "/") + "/" + strings.TrimLeft(path, "/") } +// ForwardedQuery keeps the module filter and pagination on release listings. +func (p *Provider) ForwardedQuery(path string) map[string]provider.QueryValidator { + if path != "v3/releases" { + return nil + } + return map[string]provider.QueryValidator{"module": moduleParam, "limit": pageLimit, "offset": pageOffset} +} + +func moduleParam(v string) (string, bool) { + v = NormalizeSlug(v) + return v, ValidSlug(v) +} + +func pageLimit(v string) (string, bool) { + n, err := strconv.ParseUint(v, 10, 32) + if err != nil || n == 0 { + return "", false + } + return strconv.FormatUint(min(n, 100), 10), true +} + +func pageOffset(v string) (string, bool) { + n, err := strconv.ParseUint(v, 10, 32) + return strconv.FormatUint(n, 10), err == nil +} + func (p *Provider) RewritesResponse() {} func (p *Provider) RewriteResponse(body []byte, remote models.Remote, proxyBaseURL string) ([]byte, error) { diff --git a/internal/provider/puppet/puppet_test.go b/internal/provider/puppet/puppet_test.go index 33b76a3..1209e82 100644 --- a/internal/provider/puppet/puppet_test.go +++ b/internal/provider/puppet/puppet_test.go @@ -76,3 +76,28 @@ func TestAuthHeaders(t *testing.T) { t.Error("no credentials, no header") } } + +func TestForwardedQuery(t *testing.T) { + p := &Provider{} + if p.ForwardedQuery("v3/files/a-b-1.0.0.tar.gz") != nil || p.ForwardedQuery("v3/modules/a-b") != nil { + t.Fatal("only release listings forward a query") + } + f := p.ForwardedQuery("v3/releases") + for name, c := range map[string]struct { + in, want string + ok bool + }{ + "module": {"Puppetlabs/stdlib", "puppetlabs-stdlib", true}, + "limit": {"500", "100", true}, + "offset": {"40", "40", true}, + } { + if got, ok := f[name](c.in); got != c.want || ok != c.ok { + t.Errorf("%s(%q) = %q %v, want %q %v", name, c.in, got, ok, c.want, c.ok) + } + } + for name, bad := range map[string]string{"module": "../x", "limit": "0", "offset": "-1"} { + if _, ok := f[name](bad); ok { + t.Errorf("%s(%q) accepted", name, bad) + } + } +}