Skip to content

Commit fbfe57b

Browse files
committed
ociregistry/{ociclient,ociserver}: better Referrers support
- support artifact type filtering - include (and recognize) `OCI-Filters-Applied` header according to the spec - implement paging when the server only returns a limited set of referrers results - implement referrers-tag fallback behavior when the Referrers API is not supported by a server. - add a server option to disable artifactType filtering Updates #6 Fixes #35 Fixes #40 Fixes #41 Fixes #42 Signed-off-by: Roger Peppe <[email protected]> Change-Id: Icd040930924b99f916e07d7848e3a5cae7b63b1f Reviewed-on: https://review.gerrithub.io/c/cue-labs/oci/+/1218339 TryBot-Result: CUE porcuepine <[email protected]> Reviewed-by: Daniel Martí <[email protected]>
1 parent df606ef commit fbfe57b

File tree

8 files changed

+311
-49
lines changed

8 files changed

+311
-49
lines changed

ociregistry/internal/conformance/conformance_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,20 @@ func TestMem(t *testing.T) {
6060
})
6161
}
6262

63+
func TestServerWithReferrersFilteringDisabled(t *testing.T) {
64+
// Just run the distribution tests because the extra tests expect
65+
// the referrers API to be enabled and fully working.
66+
t.Run("distribution", func(t *testing.T) {
67+
testDistribution(t, func(t *testing.T) string {
68+
srv := httptest.NewServer(ociserver.New(ocidebug.New(ocimem.New(), t.Logf), &ociserver.Options{
69+
DisableReferrersFiltering: true,
70+
}))
71+
t.Cleanup(srv.Close)
72+
return srv.URL
73+
})
74+
})
75+
}
76+
6377
func TestClientAsProxy(t *testing.T) {
6478
runTests(t, func(t *testing.T) string {
6579
direct := httptest.NewServer(ociserver.New(ocimem.New(), &ociserver.Options{

ociregistry/internal/ocirequest/create.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func (req *Request) MustConstruct() (method string, ustr string) {
4040
return method, ustr
4141
}
4242

43-
func (req *Request) construct() (method string, url string) {
43+
func (req *Request) construct() (method string, urlStr string) {
4444
switch req.Kind {
4545
case ReqPing:
4646
return "GET", "/v2/"
@@ -77,7 +77,11 @@ func (req *Request) construct() (method string, url string) {
7777
case ReqTagsList:
7878
return "GET", "/v2/" + req.Repo + "/tags/list" + req.listParams()
7979
case ReqReferrersList:
80-
return "GET", "/v2/" + req.Repo + "/referrers/" + req.Digest
80+
p := "/v2/" + req.Repo + "/referrers/" + req.Digest
81+
if req.ArtifactType != "" {
82+
p += "?" + url.Values{"artifactType": {req.ArtifactType}}.Encode()
83+
}
84+
return "GET", p
8185
case ReqCatalogList:
8286
return "GET", "/v2/_catalog" + req.listParams()
8387
default:

ociregistry/internal/ocirequest/request.go

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,17 +91,22 @@ type Request struct {
9191
// Valid for:
9292
// ReqTagsList
9393
// ReqCatalog
94-
// ReqReferrers
9594
ListN int
9695

97-
// listLast holds the item to start just after
96+
// ListLast holds the item to start just after
9897
// when listing.
9998
//
10099
// Valid for:
101100
// ReqTagsList
102101
// ReqCatalog
103-
// ReqReferrers
104102
ListLast string
103+
104+
// ArtifactType holds the artifact type to filter by when
105+
// listing.
106+
//
107+
// Valid for:
108+
// ReqReferrersList
109+
ArtifactType string
105110
}
106111

107112
type Kind int
@@ -373,10 +378,11 @@ func Parse(method string, u *url.URL) (*Request, error) {
373378
if !ociref.IsValidRepository(rreq.Repo) {
374379
return nil, ociregistry.ErrNameInvalid
375380
}
376-
// TODO is there any kind of pagination for referrers?
377-
// We'll set ListN to be future-proof.
381+
// Unlike other list-oriented endpoints, there appears to be no defined way for the client
382+
// to indicate the desired number of results, but set ListN anyway to be future-proof.
378383
rreq.ListN = -1
379384
rreq.Digest = last
385+
rreq.ArtifactType = urlq.Get("artifactType")
380386
rreq.Kind = ReqReferrersList
381387
return &rreq, nil
382388
}

ociregistry/ociclient/lister.go

Lines changed: 109 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,11 @@ package ociclient
1717
import (
1818
"context"
1919
"encoding/json"
20+
"errors"
2021
"fmt"
2122
"io"
2223
"net/http"
24+
"slices"
2325
"strings"
2426

2527
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -29,11 +31,11 @@ import (
2931
)
3032

3133
func (c *client) Repositories(ctx context.Context, startAfter string) ociregistry.Seq[string] {
32-
return c.pager(ctx, &ocirequest.Request{
34+
return pager(ctx, c, &ocirequest.Request{
3335
Kind: ocirequest.ReqCatalogList,
3436
ListN: c.listPageSize,
3537
ListLast: startAfter,
36-
}, func(resp *http.Response) ([]string, error) {
38+
}, true, func(resp *http.Response) ([]string, error) {
3739
data, err := io.ReadAll(resp.Body)
3840
if err != nil {
3941
return nil, err
@@ -49,12 +51,12 @@ func (c *client) Repositories(ctx context.Context, startAfter string) ociregistr
4951
}
5052

5153
func (c *client) Tags(ctx context.Context, repoName, startAfter string) ociregistry.Seq[string] {
52-
return c.pager(ctx, &ocirequest.Request{
54+
return pager(ctx, c, &ocirequest.Request{
5355
Kind: ocirequest.ReqTagsList,
5456
Repo: repoName,
5557
ListN: c.listPageSize,
5658
ListLast: startAfter,
57-
}, func(resp *http.Response) ([]string, error) {
59+
}, true, func(resp *http.Response) ([]string, error) {
5860
data, err := io.ReadAll(resp.Body)
5961
if err != nil {
6062
return nil, err
@@ -71,54 +73,80 @@ func (c *client) Tags(ctx context.Context, repoName, startAfter string) ociregis
7173
}
7274

7375
func (c *client) Referrers(ctx context.Context, repoName string, digest ociregistry.Digest, artifactType string) ociregistry.Seq[ociregistry.Descriptor] {
74-
// TODO paging
75-
resp, err := c.doRequest(ctx, &ocirequest.Request{
76-
Kind: ocirequest.ReqReferrersList,
77-
Repo: repoName,
78-
Digest: string(digest),
79-
ListN: c.listPageSize,
80-
})
81-
if err != nil {
82-
return ociregistry.ErrorSeq[ociregistry.Descriptor](err)
83-
}
84-
85-
data, err := io.ReadAll(resp.Body)
86-
resp.Body.Close()
87-
if err != nil {
88-
return ociregistry.ErrorSeq[ociregistry.Descriptor](err)
89-
}
90-
var referrersResponse ocispec.Index
91-
if err := json.Unmarshal(data, &referrersResponse); err != nil {
92-
return ociregistry.ErrorSeq[ociregistry.Descriptor](fmt.Errorf("cannot unmarshal referrers response: %v", err))
93-
}
94-
return ociregistry.SliceSeq(referrersResponse.Manifests)
76+
return pager(ctx, c, &ocirequest.Request{
77+
Kind: ocirequest.ReqReferrersList,
78+
Repo: repoName,
79+
Digest: string(digest),
80+
ListN: c.listPageSize,
81+
ArtifactType: artifactType,
82+
}, false, func(resp *http.Response) ([]ociregistry.Descriptor, error) {
83+
body := resp.Body
84+
if resp.StatusCode == http.StatusNotFound {
85+
body.Close()
86+
body = nil
87+
// Fall back to the referrers tag API.
88+
// From https://github.com/opencontainers/distribution-spec/blob/main/spec.md#unavailable-referrers-api :
89+
// A client querying the referrers API and receiving a
90+
// 404 Not Found MUST fallback to using an image index
91+
// pushed to a tag described by the referrers tag
92+
// schema.
93+
r, err := c.GetTag(ctx, repoName, referrersTag(digest))
94+
if err != nil {
95+
if errors.Is(err, ociregistry.ErrManifestUnknown) {
96+
return nil, nil
97+
}
98+
return nil, err
99+
}
100+
body = r
101+
}
102+
data, err := io.ReadAll(body)
103+
body.Close()
104+
if err != nil {
105+
return nil, err
106+
}
107+
var referrersResponse ocispec.Index
108+
if err := json.Unmarshal(data, &referrersResponse); err != nil {
109+
return nil, fmt.Errorf("cannot unmarshal referrers response: %v", err)
110+
}
111+
if artifactType == "" || resp.Header.Get("OCI-Filters-Applied") == "artifactType" {
112+
return referrersResponse.Manifests, nil
113+
}
114+
// The server hasn't filtered the responses, so we must.
115+
// TODO is it OK to assume that the index contains correctly populated
116+
// artifact type and attributes fields when we've fallen back to the referrer tags API?
117+
// If not, we might have to retrieve all the individual manifests to check that info.
118+
manifests := slices.DeleteFunc(referrersResponse.Manifests, func(desc ociregistry.Descriptor) bool {
119+
return desc.ArtifactType != artifactType
120+
})
121+
return manifests, nil
122+
}, http.StatusOK, http.StatusNotFound)
95123
}
96124

97125
// pager returns an iterator for a list entry point. It starts by sending the given
98126
// initial request and parses each response into its component items using
99127
// parseResponse. It tries to use the Link header in each response to continue
100-
// the iteration, falling back to using the "last" query parameter.
101-
func (c *client) pager(ctx context.Context, initialReq *ocirequest.Request, parseResponse func(*http.Response) ([]string, error)) ociregistry.Seq[string] {
102-
return func(yield func(string, error) bool) {
103-
// We assume that the same scope is applicable to all page requests.
128+
// the iteration, falling back to using the "last" query parameter if
129+
// canUseLast is true.
130+
func pager[T any](ctx context.Context, c *client, initialReq *ocirequest.Request, canUseLast bool, parseResponse func(*http.Response) ([]T, error), okStatuses ...int) ociregistry.Seq[T] {
131+
return func(yield func(T, error) bool) {
132+
// We assume that the same auth scope is applicable to all page requests.
104133
req, err := newRequest(ctx, initialReq, nil)
105134
if err != nil {
106-
yield("", err)
135+
yield(*new(T), err)
107136
return
108137
}
109138
for {
110-
resp, err := c.do(req)
139+
resp, err := c.do(req, okStatuses...)
111140
if err != nil {
112-
yield("", err)
141+
yield(*new(T), err)
113142
return
114143
}
115144
items, err := parseResponse(resp)
116145
resp.Body.Close()
117146
if err != nil {
118-
yield("", err)
147+
yield(*new(T), err)
119148
return
120149
}
121-
// TODO sanity check that items are in lexical order?
122150
for _, item := range items {
123151
if !yield(item, nil) {
124152
return
@@ -131,28 +159,35 @@ func (c *client) pager(ctx context.Context, initialReq *ocirequest.Request, pars
131159
// is less than <int>.
132160
return
133161
}
134-
req, err = nextLink(ctx, resp, initialReq, items[len(items)-1])
162+
req, err = nextLink(ctx, resp, initialReq, canUseLast, items[len(items)-1])
135163
if err != nil {
136-
yield("", fmt.Errorf("invalid Link header in response: %v", err))
164+
yield(*new(T), fmt.Errorf("invalid Link header in response: %v", err))
165+
return
166+
}
167+
if req == nil {
168+
// No link found; assume there are no more items.
137169
return
138170
}
139171
}
140172
}
141173
}
142174

143-
// nextLink tries to form a request that can be sent to obtain the next page
175+
// nextLink ttries to form a request that can be sent to obtain the next page
144176
// in a set of list results.
145177
// The given response holds the response received from the previous
146178
// list request; initialReq holds the request that initiated the listing,
147179
// and last holds the final item returned in the previous response.
148-
func nextLink(ctx context.Context, resp *http.Response, initialReq *ocirequest.Request, last string) (*http.Request, error) {
180+
func nextLink[T any](ctx context.Context, resp *http.Response, initialReq *ocirequest.Request, canUseLast bool, last T) (*http.Request, error) {
149181
link0 := resp.Header.Get("Link")
150182
if link0 == "" {
183+
if !canUseLast {
184+
return nil, nil
185+
}
151186
// This is beyond the first page and there was no Link
152187
// in the previous response (the standard doesn't mandate
153188
// one), so add a "last" parameter to the initial request.
154189
rreq := *initialReq
155-
rreq.ListLast = last
190+
rreq.ListLast = fmt.Sprint(last)
156191
req, err := newRequest(ctx, &rreq, nil)
157192
if err != nil {
158193
// Given that we could form the initial request, this should
@@ -178,3 +213,38 @@ func nextLink(ctx context.Context, resp *http.Response, initialReq *ocirequest.R
178213
}
179214
return http.NewRequestWithContext(ctx, "GET", linkURL.String(), nil)
180215
}
216+
217+
// referrersTag returns the referrers tag for the given digest, as described
218+
// in https://github.com/opencontainers/distribution-spec/blob/main/spec.md#referrers-tag-schema
219+
func referrersTag(digest ociregistry.Digest) string {
220+
// It's hard to know what the spec means by "with any characters not allowed by <reference> tags replaced with -",
221+
// because different characters are allowed in different contexts (for example, a dot character
222+
// is allowed except when it's at the start.
223+
// In practice, however, the set of characters is very limited, and the only
224+
// disallowed character in common use is :, so just use a naive algorithm.
225+
return truncateAndMap(digest.Algorithm().String(), 32) + "-" + truncateAndMap(digest.Encoded(), 64)
226+
}
227+
228+
func truncateAndMap(s string, n int) string {
229+
// regexp: [a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}
230+
231+
s = strings.Map(func(r rune) rune {
232+
switch {
233+
case 'a' <= r && r <= 'z':
234+
return r
235+
case 'A' <= r && r <= 'Z':
236+
return r
237+
case '0' <= r && r <= '9':
238+
return r
239+
case r == '.' || r == '_' || r == '-':
240+
return r
241+
}
242+
return '-'
243+
}, s)
244+
// Note: it's OK to use n as a byte index because the
245+
// above Map has eliminated all non-ASCII characters.
246+
if len(s) <= n {
247+
return s
248+
}
249+
return s[:n]
250+
}

0 commit comments

Comments
 (0)