From 2e80430d36d79a215a04b03bb993e745b5dffbab Mon Sep 17 00:00:00 2001 From: Jeff Carter Date: Fri, 22 May 2026 17:10:00 -0400 Subject: [PATCH] update ociclient to stop relying on internal/ocirequest, instead build requests in a way that is easier to extend --- ociauth/auth.go | 46 +++--------- ociauth/auth_test.go | 83 ++++++++++++++++++---- ociauth/context.go | 18 +++-- ociauth/scope.go | 9 ++- ociclient/auth_test.go | 14 ++-- ociclient/badname_test.go | 4 +- ociclient/client.go | 123 ++++++++++++++++---------------- ociclient/deleter.go | 35 +++++---- ociclient/extension.go | 51 ++++++++++++++ ociclient/lister.go | 94 ++++++++++++++----------- ociclient/reader.go | 144 ++++++++++++++++---------------------- ociclient/writer.go | 135 ++++++++++++++++++----------------- ociregistry/README.md | 18 ----- 13 files changed, 414 insertions(+), 360 deletions(-) create mode 100644 ociclient/extension.go delete mode 100644 ociregistry/README.md diff --git a/ociauth/auth.go b/ociauth/auth.go index e83e9f6..4c7d140 100644 --- a/ociauth/auth.go +++ b/ociauth/auth.go @@ -151,9 +151,8 @@ func (a *stdTransport) RoundTrip(req *http.Request) (*http.Response, error) { ctx := req.Context() requiredScope := RequestInfoFromContext(ctx).RequiredScope - wantScope := ScopeFromContext(ctx) - if err := r.setAuthorization(ctx, req, requiredScope, wantScope); err != nil { + if err := r.setAuthorization(ctx, req, requiredScope); err != nil { return nil, err } resp, err := r.transport.RoundTrip(req) @@ -171,7 +170,7 @@ func (a *stdTransport) RoundTrip(req *http.Request) (*http.Response, error) { if challenge == nil { return resp, nil } - authAdded, tokenAcquired, err := r.setAuthorizationFromChallenge(ctx, req, challenge, requiredScope, wantScope) + authAdded, tokenAcquired, err := r.setAuthorizationFromChallenge(ctx, req, challenge) if err != nil { resp.Body.Close() return nil, err @@ -218,7 +217,7 @@ func (a *stdTransport) RoundTrip(req *http.Request) (*http.Response, error) { // setAuthorization sets up authorization on the given request using any // auth information currently available. -func (r *registry) setAuthorization(ctx context.Context, req *http.Request, requiredScope, wantScope Scope) error { +func (r *registry) setAuthorization(ctx context.Context, req *http.Request, requiredScope Scope) error { r.mu.Lock() defer r.mu.Unlock() // Remove tokens that have expired or will expire soon so that @@ -247,7 +246,7 @@ func (r *registry) setAuthorization(ctx context.Context, req *http.Request, requ // acquiring several tokens concurrently. We should relax the lock // to allow that. - accessToken, err := r.acquireAccessToken(ctx, requiredScope, wantScope) + accessToken, err := r.acquireAccessToken(ctx, requiredScope) if err != nil { // Avoid using %w to wrap the error because we don't want the // caller of RoundTrip (usually ociclient) to assume that the @@ -264,7 +263,7 @@ func (r *registry) setAuthorization(ctx context.Context, req *http.Request, requ return nil } -func (r *registry) setAuthorizationFromChallenge(ctx context.Context, req *http.Request, challenge *authHeader, requiredScope, wantScope Scope) (authAdded, tokenAcquired bool, _ error) { +func (r *registry) setAuthorizationFromChallenge(ctx context.Context, req *http.Request, challenge *authHeader) (authAdded, tokenAcquired bool, _ error) { r.mu.Lock() defer r.mu.Unlock() r.wwwAuthenticate = challenge @@ -272,7 +271,7 @@ func (r *registry) setAuthorizationFromChallenge(ctx context.Context, req *http. switch { case r.wwwAuthenticate.scheme == "bearer": scope := ParseScope(r.wwwAuthenticate.params["scope"]) - accessToken, err := r.acquireAccessToken(ctx, scope, wantScope.Union(requiredScope)) + accessToken, err := r.acquireAccessToken(ctx, scope) if err != nil { return false, false, err } @@ -320,41 +319,14 @@ func (r *registry) init() error { } // acquireAccessToken tries to acquire an access token for authorizing a request. -// The requiredScopeStr parameter indicates the scope that's definitely -// required. This is a string because apparently some servers are picky -// about getting exactly the same scope in the auth request that was -// returned in the challenge. The wantScope parameter indicates -// what scope might be required in the future. +// The scope comes from the registry's Www-Authenticate challenge. // // This method assumes that there has been a previous 401 response with // a Www-Authenticate: Bearer... header. -func (r *registry) acquireAccessToken(ctx context.Context, requiredScope, wantScope Scope) (string, error) { - scope := requiredScope.Union(wantScope) +func (r *registry) acquireAccessToken(ctx context.Context, scope Scope) (string, error) { tok, err := r.acquireToken(ctx, scope) if err != nil { - var herr oci.HTTPError - if !errors.As(err, &herr) || herr.StatusCode() != http.StatusUnauthorized { - return "", err - } - // The documentation says this: - // - // If the client only has a subset of the requested - // access it _must not be considered an error_ as it is - // not the responsibility of the token server to - // indicate authorization errors as part of this - // workflow. - // - // However it's apparently not uncommon for servers to reject - // such requests anyway, so if we've got an unauthorized error - // and wantScope goes beyond requiredScope, it may be because - // the server is rejecting the request. - scope = requiredScope - tok, err = r.acquireToken(ctx, scope) - if err != nil { - return "", err - } - // TODO mark the registry as picky about tokens so we don't - // attempt twice every time? + return "", err } if tok.RefreshToken != "" { r.refreshToken = tok.RefreshToken diff --git a/ociauth/auth_test.go b/ociauth/auth_test.go index b223e61..29c3d92 100644 --- a/ociauth/auth_test.go +++ b/ociauth/auth_test.go @@ -100,9 +100,9 @@ func TestBearerAuth(t *testing.T) { assertRequest(context.Background(), t, ts, "/test", client, Scope{}) } -func TestBearerAuthAdditionalScope(t *testing.T) { - // This tests the scenario where there's a larger scope in the context - // than the required scope. +func TestBearerAuthAdditionalScopeDoesNotOverrideChallenge(t *testing.T) { + // This tests that additional context scope is not unioned into the + // registry-provided challenge scope. requiredScope := ParseScope("repository:foo:push,pull") additionalScope := ParseScope("repository:bar:pull somethingElse") authSrv := newAuthServer(t, func(req *http.Request) (any, *httpError) { @@ -114,8 +114,7 @@ func TestBearerAuthAdditionalScope(t *testing.T) { } requestedScope := ParseScope(strings.Join(req.Form["scope"], " ")) runNonFatal(t, func(t testing.TB) { - wantScope := requiredScope.Union(additionalScope) - require.True(t, wantScope.Equal(requestedScope), "scope mismatch: got %v, want %v", requestedScope, wantScope) + require.True(t, requiredScope.Equal(requestedScope), "scope mismatch: got %v, want %v", requestedScope, requiredScope) require.Equal(t, []string{"someService"}, req.Form["service"]) }) return &wireToken{ @@ -132,8 +131,7 @@ func TestBearerAuthAdditionalScope(t *testing.T) { } } runNonFatal(t, func(t testing.TB) { - wantScope := requiredScope.Union(additionalScope) - require.True(t, wantScope.Equal(authScopeFromRequest(t, req)), "scope mismatch") + require.True(t, requiredScope.Equal(authScopeFromRequest(t, req)), "scope mismatch") }) return nil }) @@ -418,10 +416,14 @@ func TestLaterRequestCanUseEarlierTokenWithLargerScope(t *testing.T) { Action: ActionPull, }) if req.Header.Get("Authorization") == "" { + challengeScope := requiredScope + if resource == "foo1" { + challengeScope = ParseScope("repository:foo1:pull repository:foo2:pull") + } return &httpError{ statusCode: http.StatusUnauthorized, header: http.Header{ - "Www-Authenticate": []string{fmt.Sprintf("Bearer realm=%q,service=someService,scope=%q", authSrv, requiredScope)}, + "Www-Authenticate": []string{fmt.Sprintf("Bearer realm=%q,service=someService,scope=%q", authSrv, challengeScope)}, }, } } @@ -438,15 +440,72 @@ func TestLaterRequestCanUseEarlierTokenWithLargerScope(t *testing.T) { }), }), } - ctx := ContextWithScope(context.Background(), ParseScope("repository:foo1:pull repository:foo2:pull")) - assertRequest(ctx, t, ts, "/test/foo1", client, Scope{}) - assertRequest(ctx, t, ts, "/test/foo2", client, Scope{}) + assertRequest(context.Background(), t, ts, "/test/foo1", client, Scope{}) + assertRequest(context.Background(), t, ts, "/test/foo2", client, Scope{}) // One token fetch should have been sufficient for both requests. require.Equal(t, 1, authCount) } +func TestLaterRequestCanAcquireTokenProactively(t *testing.T) { + authCount := 0 + authSrv := newAuthServer(t, func(req *http.Request) (any, *httpError) { + authCount++ + requestedScope := ParseScope(strings.Join(req.Form["scope"], " ")) + return &wireToken{ + Token: token{requestedScope}.String(), + }, nil + }) + targetCount := 0 + ts := newTargetServer(t, func(req *http.Request) *httpError { + targetCount++ + resource := strings.TrimPrefix(req.URL.Path, "/test/") + requiredScope := NewScope(ResourceScope{ + ResourceType: TypeRepository, + Resource: resource, + Action: ActionPull, + }) + if req.Header.Get("Authorization") == "" { + return &httpError{ + statusCode: http.StatusUnauthorized, + header: http.Header{ + "Www-Authenticate": []string{fmt.Sprintf("Bearer realm=%q,service=someService,scope=%q", authSrv, requiredScope)}, + }, + } + } + runNonFatal(t, func(t testing.TB) { + requestScope := authScopeFromRequest(t, req) + require.True(t, requestScope.Contains(requiredScope), "request scope: %q; required scope: %q", requestScope, requiredScope) + }) + return nil + }) + client := &http.Client{ + Transport: NewStdTransport(StdTransportParams{ + Config: configFunc(func(host string) (ConfigEntry, error) { + if host == ts.Host { + return ConfigEntry{ + RefreshToken: "someRefreshToken", + }, nil + } + return ConfigEntry{}, nil + }), + }), + } + assertRequest1(ContextWithRequestInfo(context.Background(), RequestInfo{ + RequiredScope: ParseScope("repository:foo1:pull"), + }), t, ts, "/test/foo1", client) + require.Equal(t, 2, targetCount) + require.Equal(t, 1, authCount) + + assertRequest1(ContextWithRequestInfo(context.Background(), RequestInfo{ + RequiredScope: ParseScope("repository:foo2:pull"), + }), t, ts, "/test/foo2", client) + require.Equal(t, 3, targetCount) + require.Equal(t, 2, authCount) +} + func TestAuthServerRejectsRequestsWithTooMuchScope(t *testing.T) { - // This tests the scenario described in the comment in registry.acquireAccessToken. + // This verifies that caller-provided desired scope is not added to the + // registry-provided challenge scope. userHasScope := ParseScope("repository:foo:pull") authSrv := newAuthServer(t, func(req *http.Request) (any, *httpError) { diff --git a/ociauth/context.go b/ociauth/context.go index cd691d6..218aa5b 100644 --- a/ociauth/context.go +++ b/ociauth/context.go @@ -7,10 +7,8 @@ import ( type scopeKey struct{} // ContextWithScope returns ctx annotated with the given -// scope. When the ociauth transport receives a request with a scope in the context, -// it will treat it as "desired authorization scope"; new authorization tokens -// will be acquired with that scope as well as any scope required by -// the operation. +// scope. The ociauth transport does not add this scope to a registry +// challenge; challenges remain the source of truth for new token acquisition. func ContextWithScope(ctx context.Context, s Scope) context.Context { return context.WithValue(ctx, scopeKey{}, s) } @@ -29,18 +27,18 @@ type requestInfoKey struct{} // request context. The [ociclient] package will add this to all // requests that is makes. type RequestInfo struct { - // RequiredScope holds the authorization scope that's required - // by the request. The ociauth logic will reuse any available - // auth token that has this scope. When acquiring a new token, - // it will add any scope found in [ScopeFromContext] too. + // RequiredScope holds the authorization scope that can satisfy + // the request for cached-token reuse. When the transport already + // knows the registry's bearer token realm and has a refresh token, + // it may use this scope to acquire a token proactively. A + // Www-Authenticate challenge remains authoritative when present. RequiredScope Scope } // ContextWithRequestInfo returns ctx annotated with the given // request informaton. When ociclient receives a request with // this attached, it will respect info.RequiredScope to determine -// what auth tokens to reuse. When it acquires a new token, -// it will ask for the union of info.RequiredScope [ScopeFromContext]. +// what auth tokens to reuse or proactively acquire. func ContextWithRequestInfo(ctx context.Context, info RequestInfo) context.Context { return context.WithValue(ctx, requestInfoKey{}, info) } diff --git a/ociauth/scope.go b/ociauth/scope.go index 1dd65bf..f11cab2 100644 --- a/ociauth/scope.go +++ b/ociauth/scope.go @@ -13,6 +13,7 @@ type knownAction byte const ( unknownAction knownAction = iota // Note: ordered by lexical string representation. + deleteAction pullAction pushAction numActions @@ -24,6 +25,8 @@ const ( // TypeRegistry is the resource type for registry-wide operations. TypeRegistry = "registry" + // ActionDelete is the action for deleting content from a repository. + ActionDelete = "delete" // ActionPull is the action for pulling content from a repository. ActionPull = "pull" // ActionPush is the action for pushing content to a repository. @@ -32,6 +35,8 @@ const ( func (a knownAction) String() string { switch a { + case deleteAction: + return ActionDelete case pullAction: return ActionPull case pushAction: @@ -65,7 +70,7 @@ type ResourceScope struct { Resource string // Action names an action that can be performed on the resource. - // This is usually ActionPush or ActionPull. + // This is usually ActionPull, ActionPush or ActionDelete. Action string } @@ -487,6 +492,8 @@ func (s Scope) String() string { func parseKnownAction(s string) knownAction { switch s { + case ActionDelete: + return deleteAction case ActionPull: return pullAction case ActionPush: diff --git a/ociclient/auth_test.go b/ociclient/auth_test.go index 21e636d..a225684 100644 --- a/ociclient/auth_test.go +++ b/ociclient/auth_test.go @@ -54,14 +54,14 @@ func TestAuthScopes(t *testing.T) { assertScope("repository:foo/bar:pull", func(ctx context.Context, r oci.Interface) { r.ResolveTag(ctx, "foo/bar", "sometag") }) - assertScope("repository:foo/bar:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:foo/bar:pull,push", func(ctx context.Context, r oci.Interface) { r.PushBlob(ctx, "foo/bar", oci.Descriptor{ MediaType: "application/json", Digest: testDigest, Size: 3, }, strings.NewReader("foo")) }) - assertScope("repository:foo/bar:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:foo/bar:pull,push", func(ctx context.Context, r oci.Interface) { w, err := r.PushBlobChunked(ctx, "foo/bar", 0) require.NoError(t, err) w.Write([]byte("foo")) @@ -74,21 +74,21 @@ func TestAuthScopes(t *testing.T) { _, err = w.Commit(ocidigest.FromBytes([]byte("foobar"))) require.NoError(t, err) }) - assertScope("repository:x/y:pull repository:z/w:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:x/y:pull repository:z/w:pull,push", func(ctx context.Context, r oci.Interface) { r.MountBlob(ctx, "x/y", "z/w", testDigest) }) - assertScope("repository:foo/bar:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:foo/bar:pull,push", func(ctx context.Context, r oci.Interface) { r.PushManifest(ctx, "foo/bar", []byte("something"), "application/json", &oci.PushManifestParameters{ Tags: []string{"sometag"}, }) }) - assertScope("repository:foo/bar:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:foo/bar:delete", func(ctx context.Context, r oci.Interface) { r.DeleteBlob(ctx, "foo/bar", testDigest) }) - assertScope("repository:foo/bar:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:foo/bar:delete", func(ctx context.Context, r oci.Interface) { r.DeleteManifest(ctx, "foo/bar", testDigest) }) - assertScope("repository:foo/bar:push", func(ctx context.Context, r oci.Interface) { + assertScope("repository:foo/bar:delete", func(ctx context.Context, r oci.Interface) { r.DeleteTag(ctx, "foo/bar", "sometag") }) assertScope("registry:catalog:*", func(ctx context.Context, r oci.Interface) { diff --git a/ociclient/badname_test.go b/ociclient/badname_test.go index 77f228b..e2a31b1 100644 --- a/ociclient/badname_test.go +++ b/ociclient/badname_test.go @@ -19,9 +19,9 @@ func TestBadRepoName(t *testing.T) { }) require.NoError(t, err) _, err = r.GetBlob(ctx, "Invalid--Repo", ocidigest.FromBytes(nil)) - assert.Regexp(t, "invalid OCI request: name invalid: invalid repository name", err.Error()) + assert.Regexp(t, "no can do", err.Error()) _, err = r.ResolveTag(ctx, "okrepo", "bad-Tag!") - assert.Regexp(t, "invalid OCI request: 404 Not Found: page not found", err.Error()) + assert.Regexp(t, "no can do", err.Error()) } type noTransport struct{} diff --git a/ociclient/client.go b/ociclient/client.go index d0070a7..38b4444 100644 --- a/ociclient/client.go +++ b/ociclient/client.go @@ -31,7 +31,6 @@ import ( "github.com/docker/oci" - "github.com/docker/oci/internal/ocirequest" "github.com/docker/oci/ociauth" "github.com/docker/oci/ocidigest" "github.com/docker/oci/ociref" @@ -262,18 +261,8 @@ var knownManifestMediaTypes = []string{ "*/*", } -// doRequest performs the given OCI request, sending it with the given body (which may be nil). -func (c *client) doRequest(ctx context.Context, rreq *ocirequest.Request, okStatuses ...int) (*http.Response, error) { - req, err := newRequest(ctx, rreq, nil) - if err != nil { - return nil, err - } - if rreq.Kind == ocirequest.ReqManifestGet || rreq.Kind == ocirequest.ReqManifestHead { - // When getting manifests, some servers won't return - // the content unless there's an Accept header, so - // add all the manifest kinds that we know about. - req.Header["Accept"] = knownManifestMediaTypes - } +// doRequest performs an OCI HTTP request and turns non-2xx responses into OCI errors. +func (c *client) doRequest(req *http.Request, okStatuses ...int) (*http.Response, error) { resp, err := c.do(req, okStatuses...) if err != nil { return nil, err @@ -373,58 +362,70 @@ func unexpectedStatusError(code int) error { return fmt.Errorf("unexpected HTTP response code %d", code) } -func scopeForRequest(r *ocirequest.Request) ociauth.Scope { - switch r.Kind { - case ocirequest.ReqPing: - return ociauth.Scope{} - case ocirequest.ReqBlobGet, - ocirequest.ReqBlobHead, - ocirequest.ReqManifestGet, - ocirequest.ReqManifestHead, - ocirequest.ReqTagsList, - ocirequest.ReqReferrersList: - return ociauth.NewScope(ociauth.ResourceScope{ - ResourceType: ociauth.TypeRepository, - Resource: r.Repo, - Action: ociauth.ActionPull, - }) - case ocirequest.ReqBlobDelete, - ocirequest.ReqBlobStartUpload, - ocirequest.ReqBlobUploadBlob, - ocirequest.ReqBlobUploadInfo, - ocirequest.ReqBlobUploadChunk, - ocirequest.ReqBlobCompleteUpload, - ocirequest.ReqManifestPut, - ocirequest.ReqManifestDelete: - return ociauth.NewScope(ociauth.ResourceScope{ - ResourceType: ociauth.TypeRepository, - Resource: r.Repo, - Action: ociauth.ActionPush, - }) - case ocirequest.ReqBlobMount: - return ociauth.NewScope(ociauth.ResourceScope{ - ResourceType: ociauth.TypeRepository, - Resource: r.Repo, - Action: ociauth.ActionPush, - }, ociauth.ResourceScope{ - ResourceType: ociauth.TypeRepository, - Resource: r.FromRepo, - Action: ociauth.ActionPull, - }) - case ocirequest.ReqCatalogList: - return ociauth.NewScope(ociauth.CatalogScope) - default: - panic(fmt.Errorf("unexpected request kind %v", r.Kind)) - } +func newRequest(ctx context.Context, method string, u string, body io.Reader, scope ociauth.Scope) (*http.Request, error) { + ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ + RequiredScope: scope, + }) + return http.NewRequestWithContext(ctx, method, u, body) } -func newRequest(ctx context.Context, rreq *ocirequest.Request, body io.Reader) (*http.Request, error) { - method, u, err := rreq.Construct() +func newManifestRequest(ctx context.Context, method string, repo string, tagOrDigest string, scope ociauth.Scope) (*http.Request, error) { + req, err := newRequest(ctx, method, manifestURL(repo, tagOrDigest), nil, scope) if err != nil { return nil, err } - ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ - RequiredScope: scopeForRequest(rreq), + // When getting manifests, some servers won't return the content unless + // there's an Accept header, so add all manifest kinds that we know about. + if method == http.MethodGet || method == http.MethodHead { + req.Header["Accept"] = knownManifestMediaTypes + } + return req, nil +} + +func pullScope(repo string) ociauth.Scope { + return ociauth.NewScope(ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: repo, + Action: ociauth.ActionPull, }) - return http.NewRequestWithContext(ctx, method, u, body) +} + +func pushScope(repo string) ociauth.Scope { + return ociauth.NewScope(ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: repo, + Action: ociauth.ActionPull, + }, ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: repo, + Action: ociauth.ActionPush, + }) +} + +func deleteScope(repo string) ociauth.Scope { + return ociauth.NewScope(ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: repo, + Action: ociauth.ActionDelete, + }) +} + +func mountScope(fromRepo, toRepo string) ociauth.Scope { + return ociauth.NewScope(ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: toRepo, + Action: ociauth.ActionPull, + }, ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: toRepo, + Action: ociauth.ActionPush, + }, ociauth.ResourceScope{ + ResourceType: ociauth.TypeRepository, + Resource: fromRepo, + Action: ociauth.ActionPull, + }) +} + +func catalogScope() ociauth.Scope { + return ociauth.NewScope(ociauth.CatalogScope) } diff --git a/ociclient/deleter.go b/ociclient/deleter.go index 2dc7a37..b3aaa4c 100644 --- a/ociclient/deleter.go +++ b/ociclient/deleter.go @@ -19,35 +19,34 @@ import ( "net/http" "github.com/docker/oci" - "github.com/docker/oci/internal/ocirequest" ) func (c *client) DeleteBlob(ctx context.Context, repoName string, digest oci.Digest) error { - return c.delete(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqBlobDelete, - Repo: repoName, - Digest: digest.String(), - }) + req, err := newRequest(ctx, http.MethodDelete, blobURL(repoName, digest), nil, deleteScope(repoName)) + if err != nil { + return err + } + return c.delete(req) } func (c *client) DeleteManifest(ctx context.Context, repoName string, digest oci.Digest) error { - return c.delete(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqManifestDelete, - Repo: repoName, - Digest: digest.String(), - }) + req, err := newRequest(ctx, http.MethodDelete, manifestURL(repoName, digest.String()), nil, deleteScope(repoName)) + if err != nil { + return err + } + return c.delete(req) } func (c *client) DeleteTag(ctx context.Context, repoName string, tagName string) error { - return c.delete(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqManifestDelete, - Repo: repoName, - Tag: tagName, - }) + req, err := newRequest(ctx, http.MethodDelete, manifestURL(repoName, tagName), nil, deleteScope(repoName)) + if err != nil { + return err + } + return c.delete(req) } -func (c *client) delete(ctx context.Context, rreq *ocirequest.Request) error { - resp, err := c.doRequest(ctx, rreq, http.StatusAccepted) +func (c *client) delete(req *http.Request) error { + resp, err := c.doRequest(req, http.StatusAccepted) if err != nil { return err } diff --git a/ociclient/extension.go b/ociclient/extension.go new file mode 100644 index 0000000..9b5fc40 --- /dev/null +++ b/ociclient/extension.go @@ -0,0 +1,51 @@ +// Copyright 2023 CUE Labs AG +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package ociclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "iter" + "net/http" +) + +func (c *client) Repositories(ctx context.Context, startAfter string) iter.Seq2[string, error] { + return pager(ctx, c, pageRequest{ + URL: catalogURL(startAfter), + Scope: catalogScope(), + Limit: -1, + Next: func(last any) string { + return catalogURL(fmt.Sprint(last)) + }, + }, true, func(resp *http.Response) ([]string, error) { + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var catalog struct { + Repos []string `json:"repositories"` + } + if err := json.Unmarshal(data, &catalog); err != nil { + return nil, fmt.Errorf("cannot unmarshal catalog response: %v", err) + } + return catalog.Repos, nil + }) +} + +func catalogURL(last string) string { + return "/v2/_catalog" + listQuery(-1, last) +} diff --git a/ociclient/lister.go b/ociclient/lister.go index b18b6d0..fd3fe4b 100644 --- a/ociclient/lister.go +++ b/ociclient/lister.go @@ -22,33 +22,14 @@ import ( "io" "iter" "net/http" + "net/url" "slices" "strings" "github.com/docker/oci" - - "github.com/docker/oci/internal/ocirequest" + "github.com/docker/oci/ociauth" ) -func (c *client) Repositories(ctx context.Context, startAfter string) iter.Seq2[string, error] { - return pager(ctx, c, &ocirequest.Request{ - Kind: ocirequest.ReqCatalogList, - ListLast: startAfter, - }, true, func(resp *http.Response) ([]string, error) { - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - var catalog struct { - Repos []string `json:"repositories"` - } - if err := json.Unmarshal(data, &catalog); err != nil { - return nil, fmt.Errorf("cannot unmarshal catalog response: %v", err) - } - return catalog.Repos, nil - }) -} - func (c *client) Tags(ctx context.Context, repoName string, params *oci.TagsParameters) iter.Seq2[string, error] { var startAfter string limit := -1 @@ -56,11 +37,13 @@ func (c *client) Tags(ctx context.Context, repoName string, params *oci.TagsPara startAfter = params.StartAfter limit = params.Limit } - return pager(ctx, c, &ocirequest.Request{ - Kind: ocirequest.ReqTagsList, - Repo: repoName, - ListN: limit, - ListLast: startAfter, + return pager(ctx, c, pageRequest{ + URL: tagsURL(repoName, limit, startAfter), + Scope: pullScope(repoName), + Limit: limit, + Next: func(last any) string { + return tagsURL(repoName, limit, fmt.Sprint(last)) + }, }, true, func(resp *http.Response) ([]string, error) { data, err := io.ReadAll(resp.Body) if err != nil { @@ -82,12 +65,10 @@ func (c *client) Referrers(ctx context.Context, repoName string, digest oci.Dige if params != nil { artifactType = params.ArtifactType } - return pager(ctx, c, &ocirequest.Request{ - Kind: ocirequest.ReqReferrersList, - Repo: repoName, - Digest: digest.String(), - ListN: -1, - ArtifactType: artifactType, + return pager(ctx, c, pageRequest{ + URL: referrersURL(repoName, digest, artifactType), + Scope: pullScope(repoName), + Limit: -1, }, false, func(resp *http.Response) ([]oci.Descriptor, error) { body := resp.Body if resp.StatusCode == http.StatusNotFound { @@ -137,15 +118,22 @@ func (c *client) Referrers(ctx context.Context, repoName string, digest oci.Dige }, http.StatusOK, http.StatusNotFound) } +type pageRequest struct { + URL string + Scope ociauth.Scope + Limit int + Next func(last any) string +} + // pager returns an iterator for a list entry point. It starts by sending the given // initial request and parses each response into its component items using // parseResponse. It tries to use the Link header in each response to continue // the iteration, falling back to using the "last" query parameter if // canUseLast is true. -func pager[T any](ctx context.Context, c *client, initialReq *ocirequest.Request, canUseLast bool, parseResponse func(*http.Response) ([]T, error), okStatuses ...int) iter.Seq2[T, error] { +func pager[T any](ctx context.Context, c *client, initialReq pageRequest, canUseLast bool, parseResponse func(*http.Response) ([]T, error), okStatuses ...int) iter.Seq2[T, error] { return func(yield func(T, error) bool) { // We assume that the same auth scope is applicable to all page requests. - req, err := newRequest(ctx, initialReq, nil) + req, err := newRequest(ctx, http.MethodGet, initialReq.URL, nil, initialReq.Scope) if err != nil { yield(*new(T), err) return @@ -167,7 +155,7 @@ func pager[T any](ctx context.Context, c *client, initialReq *ocirequest.Request return } } - if len(items) == 0 || (initialReq.ListN > 0 && len(items) < initialReq.ListN) { + if len(items) == 0 || (initialReq.Limit > 0 && len(items) < initialReq.Limit) { // From the distribution spec: // The response to such a request MAY return fewer than results, // but only when the total number of tags attached to the repository @@ -192,18 +180,16 @@ func pager[T any](ctx context.Context, c *client, initialReq *ocirequest.Request // The given response holds the response received from the previous // list request; initialReq holds the request that initiated the listing, // and last holds the final item returned in the previous response. -func nextLink[T any](ctx context.Context, resp *http.Response, initialReq *ocirequest.Request, canUseLast bool, last T) (*http.Request, error) { +func nextLink[T any](ctx context.Context, resp *http.Response, initialReq pageRequest, canUseLast bool, last T) (*http.Request, error) { link0 := resp.Header.Get("Link") if link0 == "" { - if !canUseLast { + if !canUseLast || initialReq.Next == nil { return nil, nil } // This is beyond the first page and there was no Link // in the previous response (the standard doesn't mandate // one), so add a "last" parameter to the initial request. - rreq := *initialReq - rreq.ListLast = fmt.Sprint(last) - req, err := newRequest(ctx, &rreq, nil) + req, err := newRequest(ctx, http.MethodGet, initialReq.Next(last), nil, initialReq.Scope) if err != nil { // Given that we could form the initial request, this should // never happen. @@ -226,7 +212,7 @@ func nextLink[T any](ctx context.Context, resp *http.Response, initialReq *ocire if err != nil { return nil, fmt.Errorf("invalid URL in Link=%q", link0) } - return http.NewRequestWithContext(ctx, "GET", linkURL.String(), nil) + return newRequest(ctx, http.MethodGet, linkURL.String(), nil, initialReq.Scope) } // referrersTag returns the referrers tag for the given digest, as described @@ -263,3 +249,29 @@ func truncateAndMap(s string, n int) string { } return s[:n] } + +func tagsURL(repo string, limit int, last string) string { + return "/v2/" + repo + "/tags/list" + listQuery(limit, last) +} + +func referrersURL(repo string, digest oci.Digest, artifactType string) string { + u := "/v2/" + repo + "/referrers/" + digest.String() + if artifactType != "" { + u += "?" + url.Values{"artifactType": {artifactType}}.Encode() + } + return u +} + +func listQuery(limit int, last string) string { + q := make(url.Values) + if limit >= 0 { + q.Set("n", fmt.Sprint(limit)) + } + if last != "" { + q.Set("last", last) + } + if len(q) == 0 { + return "" + } + return "?" + q.Encode() +} diff --git a/ociclient/reader.go b/ociclient/reader.go index d530fd3..cb99a84 100644 --- a/ociclient/reader.go +++ b/ociclient/reader.go @@ -22,28 +22,22 @@ import ( "net/http" "github.com/docker/oci" - "github.com/docker/oci/internal/ocirequest" "github.com/docker/oci/ocidigest" ) func (c *client) GetBlob(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { - return c.read(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqBlobGet, - Repo: repo, - Digest: digest.String(), - }) + req, err := newRequest(ctx, http.MethodGet, blobURL(repo, digest), nil, pullScope(repo)) + if err != nil { + return nil, err + } + return c.read(req, digest, false) } func (c *client) GetBlobRange(ctx context.Context, repo string, digest oci.Digest, o0, o1 int64) (_ oci.BlobReader, _err error) { if o0 == 0 && o1 < 0 { return c.GetBlob(ctx, repo, digest) } - rreq := &ocirequest.Request{ - Kind: ocirequest.ReqBlobGet, - Repo: repo, - Digest: digest.String(), - } - req, err := newRequest(ctx, rreq, nil) + req, err := newRequest(ctx, http.MethodGet, blobURL(repo, digest), nil, pullScope(repo)) if err != nil { return nil, err } @@ -60,8 +54,7 @@ func (c *client) GetBlobRange(ctx context.Context, repo string, digest oci.Diges // Fix that either by returning ErrUnsupported or by reading the whole // blob and returning only the required portion. defer closeOnError(&_err, resp.Body) - knownDigest, _ := ocidigest.Parse(rreq.Digest) - desc, err := descriptorFromResponse(resp, knownDigest, requireSize) + desc, err := descriptorFromResponse(resp, digest, requireSize) if err != nil { return nil, fmt.Errorf("invalid descriptor in response: %v", err) } @@ -69,36 +62,35 @@ func (c *client) GetBlobRange(ctx context.Context, repo string, digest oci.Diges } func (c *client) ResolveBlob(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { - return c.resolve(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqBlobHead, - Repo: repo, - Digest: digest.String(), - }) + req, err := newRequest(ctx, http.MethodHead, blobURL(repo, digest), nil, pullScope(repo)) + if err != nil { + return oci.Descriptor{}, err + } + return c.resolve(req, digest) } func (c *client) ResolveManifest(ctx context.Context, repo string, digest oci.Digest) (oci.Descriptor, error) { - return c.resolve(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqManifestHead, - Repo: repo, - Digest: digest.String(), - }) + req, err := newManifestRequest(ctx, http.MethodHead, repo, digest.String(), pullScope(repo)) + if err != nil { + return oci.Descriptor{}, err + } + return c.resolve(req, digest) } func (c *client) ResolveTag(ctx context.Context, repo string, tag string) (oci.Descriptor, error) { - return c.resolve(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqManifestHead, - Repo: repo, - Tag: tag, - }) + req, err := newManifestRequest(ctx, http.MethodHead, repo, tag, pullScope(repo)) + if err != nil { + return oci.Descriptor{}, err + } + return c.resolve(req, "") } -func (c *client) resolve(ctx context.Context, rreq *ocirequest.Request) (oci.Descriptor, error) { - resp, err := c.doRequest(ctx, rreq) +func (c *client) resolve(req *http.Request, knownDigest oci.Digest) (oci.Descriptor, error) { + resp, err := c.doRequest(req) if err != nil { return oci.Descriptor{}, err } resp.Body.Close() - knownDigest, _ := ocidigest.Parse(rreq.Digest) desc, err := descriptorFromResponse(resp, knownDigest, requireSize|requireDigest) if err != nil { return oci.Descriptor{}, fmt.Errorf("invalid descriptor in response: %v", err) @@ -107,40 +99,29 @@ func (c *client) resolve(ctx context.Context, rreq *ocirequest.Request) (oci.Des } func (c *client) GetManifest(ctx context.Context, repo string, digest oci.Digest) (oci.BlobReader, error) { - return c.read(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqManifestGet, - Repo: repo, - Digest: digest.String(), - }) + req, err := newManifestRequest(ctx, http.MethodGet, repo, digest.String(), pullScope(repo)) + if err != nil { + return nil, err + } + return c.read(req, digest, true) } func (c *client) GetTag(ctx context.Context, repo string, tagName string) (oci.BlobReader, error) { - return c.read(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqManifestGet, - Repo: repo, - Tag: tagName, - }) + req, err := newManifestRequest(ctx, http.MethodGet, repo, tagName, pullScope(repo)) + if err != nil { + return nil, err + } + return c.read(req, "", true) } -// inMemThreshold holds the maximum number of bytes of manifest content -// that we'll hold in memory to obtain a digest before falling back do -// doing a HEAD request. -// -// This is hopefully large enough to be considerably larger than most -// manifests but small enough to fit comfortably into RAM on most -// platforms. -// -// Note: this is only used when talking to registries that fail to return -// a digest when doing a GET on a tag. -const inMemThreshold = 128 * 1024 +const maxManifestSize = 4 * 1024 * 1024 -func (c *client) read(ctx context.Context, rreq *ocirequest.Request) (_ oci.BlobReader, _err error) { - resp, err := c.doRequest(ctx, rreq) +func (c *client) read(req *http.Request, knownDigest oci.Digest, isManifest bool) (_ oci.BlobReader, _err error) { + resp, err := c.doRequest(req) if err != nil { return nil, err } defer closeOnError(&_err, resp.Body) - knownDigest, _ := ocidigest.Parse(rreq.Digest) desc, err := descriptorFromResponse(resp, knownDigest, requireSize) if err != nil { return nil, fmt.Errorf("invalid descriptor in response: %v", err) @@ -152,39 +133,32 @@ func (c *client) read(ctx context.Context, rreq *ocirequest.Request) (_ oci.Blob // We know the request must be a tag-getting // request because all other requests take a digest not a tag // but sanity check anyway. - if rreq.Kind != ocirequest.ReqManifestGet { + if !isManifest { return nil, fmt.Errorf("internal error: no digest available for non-tag request") } - // If the manifest is of a reasonable size, just read it into memory - // and calculate the digest that way, otherwise issue a HEAD - // request which should hopefully (and does in the ECR case) - // give us the digest we need. - if desc.Size <= inMemThreshold { - data, err := io.ReadAll(io.LimitReader(resp.Body, desc.Size+1)) - if err != nil { - return nil, fmt.Errorf("failed to read body to determine digest: %v", err) - } - if int64(len(data)) != desc.Size { - return nil, fmt.Errorf("body size mismatch") - } - desc.Digest = ocidigest.FromBytes(data) - resp.Body.Close() - resp.Body = io.NopCloser(bytes.NewReader(data)) - } else { - rreq1 := rreq - rreq1.Kind = ocirequest.ReqManifestHead - resp1, err := c.doRequest(ctx, rreq1) - if err != nil { - return nil, err - } - resp1.Body.Close() - knownDigest, _ := ocidigest.Parse(rreq1.Digest) - desc, err = descriptorFromResponse(resp1, knownDigest, requireSize|requireDigest) - if err != nil { - return nil, err - } + if desc.Size > maxManifestSize { + return nil, fmt.Errorf("manifest size %d exceeds maximum size %d", desc.Size, maxManifestSize) } + + data, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize+1)) + if err != nil { + return nil, fmt.Errorf("failed to read body to determine digest: %v", err) + } + if int64(len(data)) != desc.Size { + return nil, fmt.Errorf("body size mismatch") + } + desc.Digest = ocidigest.FromBytes(data) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(data)) } return newBlobReader(resp.Body, desc), nil } + +func blobURL(repo string, digest oci.Digest) string { + return "/v2/" + repo + "/blobs/" + digest.String() +} + +func manifestURL(repo string, tagOrDigest string) string { + return "/v2/" + repo + "/manifests/" + tagOrDigest +} diff --git a/ociclient/writer.go b/ociclient/writer.go index 4693ec8..53da56e 100644 --- a/ociclient/writer.go +++ b/ociclient/writer.go @@ -27,7 +27,6 @@ import ( "github.com/docker/oci" - "github.com/docker/oci/internal/ocirequest" "github.com/docker/oci/ociauth" "github.com/docker/oci/ocidigest" ) @@ -59,31 +58,14 @@ func (c *client) PushManifest(ctx context.Context, repo string, contents []byte, // If there are no tags, push once by digest. // If there are tags, push once per tag (all referencing the same contents). if len(tags) == 0 { - rreq := &ocirequest.Request{ - Kind: ocirequest.ReqManifestPut, - Repo: repo, - Digest: desc.Digest.String(), - } - _, err := c.putManifest(ctx, rreq, desc) + _, err := c.putManifest(ctx, repo, desc.Digest.String(), nil, desc) return desc, err } else { - rreq := &ocirequest.Request{ - Kind: ocirequest.ReqManifestPut, - Repo: repo, - Tags: tags, - Digest: desc.Digest.String(), - } - createdTags, err := c.putManifest(ctx, rreq, desc) + createdTags, err := c.putManifest(ctx, repo, desc.Digest.String(), tags, desc) if err != nil || len(createdTags) != len(tags) { // bulk send failed, fallback to sending one at a time for _, tag := range tags { - rreq := &ocirequest.Request{ - Kind: ocirequest.ReqManifestPut, - Repo: repo, - Tag: tag, - Digest: desc.Digest.String(), - } - _, err = c.putManifest(ctx, rreq, desc) + _, err = c.putManifest(ctx, repo, tag, nil, desc) if err != nil { return oci.Descriptor{}, fmt.Errorf("creating tag %s failed: %w", tag, err) } @@ -93,8 +75,16 @@ func (c *client) PushManifest(ctx context.Context, repo string, contents []byte, return desc, nil } -func (c *client) putManifest(ctx context.Context, rreq *ocirequest.Request, desc oci.Descriptor) ([]string, error) { - req, err := newRequest(ctx, rreq, bytes.NewReader(desc.Data)) +func (c *client) putManifest(ctx context.Context, repo string, tagOrDigest string, tags []string, desc oci.Descriptor) ([]string, error) { + u := manifestURL(repo, tagOrDigest) + if len(tags) > 0 { + q := make(url.Values) + for _, tag := range tags { + q.Add("tag", tag) + } + u += "?" + q.Encode() + } + req, err := newRequest(ctx, http.MethodPut, u, bytes.NewReader(desc.Data), pushScope(repo)) if err != nil { return nil, err } @@ -106,23 +96,24 @@ func (c *client) putManifest(ctx context.Context, rreq *ocirequest.Request, desc } resp.Body.Close() - var tags []string + var createdTags []string if vs := resp.Header.Values("OCI-Tag"); len(vs) > 0 { for _, v := range vs { - tags = append(tags, strings.Split(v, ",")...) + createdTags = append(createdTags, strings.Split(v, ",")...) } } - return tags, nil + return createdTags, nil } func (c *client) MountBlob(ctx context.Context, fromRepo, toRepo string, dig oci.Digest) (oci.Descriptor, error) { - rreq := &ocirequest.Request{ - Kind: ocirequest.ReqBlobMount, - Repo: toRepo, - FromRepo: fromRepo, - Digest: dig.String(), + q := url.Values{} + q.Set("mount", dig.String()) + q.Set("from", fromRepo) + req, err := newRequest(ctx, http.MethodPost, blobUploadURL(toRepo, q), nil, mountScope(fromRepo, toRepo)) + if err != nil { + return oci.Descriptor{}, err } - resp, err := c.doRequest(ctx, rreq, http.StatusCreated, http.StatusAccepted) + resp, err := c.doRequest(req, http.StatusCreated, http.StatusAccepted) if err != nil { return oci.Descriptor{}, err } @@ -138,15 +129,12 @@ func (c *client) MountBlob(ctx context.Context, fromRepo, toRepo string, dig oci } func (c *client) PushBlob(ctx context.Context, repo string, desc oci.Descriptor, r io.Reader) (_ oci.Descriptor, _err error) { - // TODO use the single-post blob-upload method (ReqBlobUploadBlob) + // TODO use the single-post blob-upload method. // See: // https://github.com/distribution/distribution/issues/4065 // https://github.com/golang/go/issues/63152 - rreq := &ocirequest.Request{ - Kind: ocirequest.ReqBlobStartUpload, - Repo: repo, - } - req, err := newRequest(ctx, rreq, nil) + scope := pushScope(repo) + req, err := newRequest(ctx, http.MethodPost, blobUploadURL(repo, nil), nil, scope) if err != nil { return oci.Descriptor{}, err } @@ -163,10 +151,8 @@ func (c *client) PushBlob(ctx context.Context, repo string, desc oci.Descriptor, // We've got the upload location. Now PUT the content. ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ - RequiredScope: scopeForRequest(rreq), + RequiredScope: scope, }) - // Note: we can't use ocirequest.Request here because that's - // specific to the ociserver implementation in this case. req, err = http.NewRequestWithContext(ctx, "PUT", "", r) if err != nil { return oci.Descriptor{}, err @@ -175,7 +161,7 @@ func (c *client) PushBlob(ctx context.Context, repo string, desc oci.Descriptor, req.ContentLength = desc.Size req.Header.Set("Content-Type", "application/octet-stream") // TODO: per the spec, the content-range header here is unnecessary. - req.Header.Set("Content-Range", ocirequest.RangeString(0, desc.Size)) + req.Header.Set("Content-Range", contentRange(0, desc.Size)) resp, err = c.do(req, http.StatusCreated) if err != nil { return oci.Descriptor{}, err @@ -194,10 +180,11 @@ func (c *client) PushBlobChunked(ctx context.Context, repo string, chunkSize int if chunkSize <= 0 { chunkSize = defaultChunkSize } - resp, err := c.doRequest(ctx, &ocirequest.Request{ - Kind: ocirequest.ReqBlobStartUpload, - Repo: repo, - }, http.StatusAccepted) + req, err := newRequest(ctx, http.MethodPost, blobUploadURL(repo, nil), nil, pushScope(repo)) + if err != nil { + return nil, err + } + resp, err := c.doRequest(req, http.StatusAccepted) if err != nil { return nil, err } @@ -207,11 +194,7 @@ func (c *client) PushBlobChunked(ctx context.Context, repo string, chunkSize int return nil, err } ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ - RequiredScope: ociauth.NewScope(ociauth.ResourceScope{ - ResourceType: "repository", - Resource: repo, - Action: "push", - }), + RequiredScope: pushScope(repo), }) return &blobWriter{ ctx: ctx, @@ -234,17 +217,8 @@ func (c *client) PushBlobChunkedResume(ctx context.Context, repo string, id stri case offset == -1: // Try to find what offset we're meant to be writing at // by doing a GET to the location. - // TODO does resuming an upload require push or pull scope or both? ctx := ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ - RequiredScope: ociauth.NewScope(ociauth.ResourceScope{ - ResourceType: "repository", - Resource: repo, - Action: "push", - }, ociauth.ResourceScope{ - ResourceType: "repository", - Resource: repo, - Action: "pull", - }), + RequiredScope: pushScope(repo), }) req, err := http.NewRequestWithContext(ctx, "GET", id, nil) if err != nil { @@ -259,7 +233,7 @@ func (c *client) PushBlobChunkedResume(ctx context.Context, repo string, id stri return nil, fmt.Errorf("cannot get location from response: %v", err) } rangeStr := resp.Header.Get("Range") - p0, p1, ok := ocirequest.ParseRange(rangeStr) + p0, p1, ok := parseContentRange(rangeStr) if !ok { return nil, fmt.Errorf("invalid range %q in response", rangeStr) } @@ -286,11 +260,7 @@ func (c *client) PushBlobChunkedResume(ctx context.Context, repo string, id stri } } ctx = ociauth.ContextWithRequestInfo(ctx, ociauth.RequestInfo{ - RequiredScope: ociauth.NewScope(ociauth.ResourceScope{ - ResourceType: "repository", - Resource: repo, - Action: "push", - }), + RequiredScope: pushScope(repo), }) return &blobWriter{ ctx: ctx, @@ -371,7 +341,7 @@ func (w *blobWriter) flush(buf []byte, commitDigest oci.Digest) error { req.ContentLength = int64(len(w.chunk) + len(buf)) // TODO: per the spec, the content-range header here is unnecessary // if we are doing a final PUT without a body. - req.Header.Set("Content-Range", ocirequest.RangeString(w.flushed, w.flushed+req.ContentLength)) + req.Header.Set("Content-Range", contentRange(w.flushed, w.flushed+req.ContentLength)) resp, err := w.client.do(req, expect) if err != nil { return err @@ -482,3 +452,32 @@ func chunkSizeFromResponse(resp *http.Response, chunkSize int) int { } return chunkSize } + +func blobUploadURL(repo string, query url.Values) string { + u := "/v2/" + repo + "/blobs/uploads/" + if len(query) > 0 { + u += "?" + query.Encode() + } + return u +} + +func contentRange(start, end int64) string { + end-- + if end < 0 { + end = 0 + } + return fmt.Sprintf("%d-%d", start, end) +} + +func parseContentRange(s string) (start, end int64, ok bool) { + p0s, p1s, ok := strings.Cut(s, "-") + if !ok { + return 0, 0, false + } + p0, err0 := strconv.ParseInt(p0s, 10, 64) + p1, err1 := strconv.ParseInt(p1s, 10, 64) + if p1 > 0 { + p1++ + } + return p0, p1, err0 == nil && err1 == nil +} diff --git a/ociregistry/README.md b/ociregistry/README.md deleted file mode 100644 index ea55cf6..0000000 --- a/ociregistry/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# `oci` -In the top level package (`oci`) this module defines a [Go interface](./interface.go) that encapsulates the operations provided by an OCI -registry. - -Full reference documentation can be found [here](https://pkg.go.dev/cuelabs.dev/go/oci/oci). - -It also provides a lightweight in-memory implementation of that interface (`ocimem`) -and an HTTP server that implements the [OCI registry protocol](https://github.com/opencontainers/distribution-spec/blob/main/spec.md) on top of it. - -The server currently passes the [conformance tests](https://pkg.go.dev/github.com/opencontainers/distribution-spec/conformance). - -The aim is to provide an ergonomic interface for defining and layering -OCI registry implementations. - -Although the API is fairly stable, it's still in v0 currently, so incompatible changes can't be ruled out. - -The code was originally derived from the [go-containerregistry](https://pkg.go.dev/github.com/google/go-containerregistry/pkg/registry) registry, but has considerably diverged since then. -