diff --git a/docs/content/about/configuration.md b/docs/content/about/configuration.md index 42e919c9e..604445e6f 100644 --- a/docs/content/about/configuration.md +++ b/docs/content/about/configuration.md @@ -805,8 +805,10 @@ location of a proxy for the layer stored by the S3 storage driver. ## `tags` The `tags` subsection provides configuration to limit the maximum number of tags -returned by the tags API endpoint. When a client requests the list of tags for -a repository, the registry will return at most `maxtags` tags. +returned in a single response from the tags API endpoint. When a client requests +more than `maxtags` via the `n` query parameter, the server returns `maxtags` +results along with a `Link` header so the client can paginate to retrieve the +rest. ```yaml tags: diff --git a/registry/handlers/api_test.go b/registry/handlers/api_test.go index 76a5c3519..0d553506d 100644 --- a/registry/handlers/api_test.go +++ b/registry/handlers/api_test.go @@ -612,6 +612,63 @@ func TestTagsAPI(t *testing.T) { } } +// TestTagsAPIMaxTagsClamp verifies that the tags/list endpoint clamps an n +// query parameter greater than the server's configured MaxTags down to MaxTags +// rather than returning 400 — the OCI distribution-spec permits a server to +// return fewer than n results when a Link header is provided. +func TestTagsAPIMaxTagsClamp(t *testing.T) { + config := configuration.Configuration{ + Storage: configuration.Storage{ + "inmemory": configuration.Parameters{}, + "maintenance": configuration.Parameters{"uploadpurging": map[any]any{"enabled": false}}, + }, + Catalog: configuration.Catalog{MaxEntries: 5}, + Tags: configuration.Tags{MaxTags: 2}, + } + config.HTTP.Headers = headerConfig + env := newTestEnvWithConfig(t, &config) + defer env.Shutdown() + + imageName, err := reference.WithName("test") + if err != nil { + t.Fatalf("unable to parse reference: %v", err) + } + + tags := []string{"2j2ar", "asj9e", "jyi7b", "kb0j5", "sb71y"} + for _, tag := range tags { + createRepository(env, t, imageName.Name(), tag) + } + + tagsURL, err := env.builder.BuildTagsURL(imageName, url.Values{"n": []string{"100"}}) + if err != nil { + t.Fatalf("unexpected error building tags URL: %v", err) + } + + resp, err := http.Get(tagsURL) + if err != nil { + t.Fatalf("unexpected error issuing request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected response status code to be %d, got %d", http.StatusOK, resp.StatusCode) + } + + var body tagsAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("unexpected error decoding response body: %v", err) + } + expected := tagsAPIResponse{Name: imageName.Name(), Tags: []string{"2j2ar", "asj9e"}} + if !reflect.DeepEqual(body, expected) { + t.Fatalf("expected response body to be:\n%+v\ngot:\n%+v", expected, body) + } + + wantLink := `; rel="next"` + if got := resp.Header.Get("Link"); got != wantLink { + t.Fatalf("expected response Link header to be %q, got %q", wantLink, got) + } +} + func checkLink(t *testing.T, urlStr string, numEntries int, last string) url.Values { re := regexp.MustCompile("<(/v2/_catalog.*)>; rel=\"next\"") matches := re.FindStringSubmatch(urlStr) diff --git a/registry/handlers/tags.go b/registry/handlers/tags.go index 4d88a74ee..0e30e1070 100644 --- a/registry/handlers/tags.go +++ b/registry/handlers/tags.go @@ -41,17 +41,19 @@ func (th *tagsHandler) GetTags(w http.ResponseWriter, r *http.Request) { limit := -1 - // parse n, if n unparseable, or negative assign it to defaultReturnedEntries if n := q.Get("n"); n != "" { - if th.App.Config.Tags.MaxTags > 0 { - limit = th.App.Config.Tags.MaxTags - } parsedMax, err := strconv.Atoi(n) - if err != nil || (limit > 0 && parsedMax > limit) || parsedMax < 0 { + if err != nil || parsedMax < 0 { th.Errors = append(th.Errors, errcode.ErrorCodePaginationNumberInvalid.WithDetail(map[string]int{"n": parsedMax})) return } limit = parsedMax + // Per the OCI distribution-spec, a server MAY return fewer than n + // results when a Link header is provided for continuation. Clamp to + // MaxTags instead of rejecting oversized requests. + if maxTags := th.App.Config.Tags.MaxTags; maxTags > 0 && limit > maxTags { + limit = maxTags + } } filled := make([]string, 0)