mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
a7569a7added credential-less pulls of public images. Three things about it were wrong, all of them in how the appview handled the decision that belongs to the hold. **Scope handling was all-or-nothing.** IsPullOnlyScope required every requested action to already be "pull", but clients routinely ask for more than the operation needs — pull,push is common for a plain read, and some ask for pull,push,delete up front. Those were rejected and challenged, leaving a credential-less client no way to pull even a public image, which is the entire feature. NarrowToPullOnly drops the write actions and issues a token carrying "pull" and nothing else. Granting a subset is what the distribution token spec expects. The allowlist property is preserved: "pull" is the only action that survives, and "*" is deliberately not expanded into it, since a wildcard request is not evidence the caller wants a read. **The appview-side read gate was inert.** checkReadAccess passed p.ctx.DID, the DID of the repository *owner*, not the requester. Any non-empty DID satisfies a private hold's check, and the owner's is never empty, so it asked "may the owner read their own hold", answered yes, and admitted everyone. Worse, CheckReadAccessWithCaptain admitted any authenticated DID to a private hold at all, on an explicitly-MVP assumption that holding a DID was close enough to being a sailor. Every doc says otherwise (docs/hold.md:109 "Crew with blob:read", CLAUDE.md:140, docs/BYOS.md:280) and so does the hold (ValidateBlobReadAccess: owner, or crew carrying blob:read/blob:write). It now takes isCrew and requires owner-or-crew, and callers only pay for the crew lookup when it can change the answer — a public hold or an anonymous caller is decided by the captain record alone. Nothing here loosens access; it brings the local gate into agreement with the authority. **Denials could not reach the client.** distribution's blobHandler.GetBlob maps everything except ErrBlobUnknown to ErrorCodeUnknown, so a 401 raised in the blob store left as a 500 — misreporting an auth failure as a server fault, and giving BearerChallenge no 401 to attach WWW-Authenticate to, so Docker was told "server error" instead of being prompted for credentials. Clients that retry 5xx looped: 4.1s per case in the matrix, now 0.01s. The check moves to Repository(), where an errcode.Error is passed through verbatim by the registry app — the same mechanisma7569a7used for NAME_UNKNOWN. It fails open on a lookup error, since the hold is the authority and a transient failure should not break public pulls. Removes auth.allow_anonymous_pull. It could only ever withhold — captain.Public is what grants — so it was a second flag for a decision the hold already owns, and gating it appview-side was never the intent. Layer bytes 307 straight to S3, so the appview is not even in the path whose cost might have justified an operator-side lever. Tests: TestAuthMatrix only ever ran against a public hold, and its pull cases never fetched a layer — crane.Pull is lazy and img.Digest() needs only the manifest, which ATCR serves from the user's PDS where it is world-readable, so no pull row in the matrix touched blob authorization at all. Pulls now materialize layer bytes, and testharness.WithPrivateHold plus TestAuthMatrixPrivateHold cover public:false + allow_all_crew:true — the production shape, where anyone with an account pulls and pushes and anonymous gets nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
452 lines
15 KiB
Go
452 lines
15 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/crane"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/empty"
|
|
"github.com/google/go-containerregistry/pkg/v1/layout"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
|
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
"github.com/regclient/regclient"
|
|
"github.com/regclient/regclient/config"
|
|
"github.com/regclient/regclient/types/ref"
|
|
"oras.land/oras-go/v2"
|
|
orasoci "oras.land/oras-go/v2/content/oci"
|
|
orasremote "oras.land/oras-go/v2/registry/remote"
|
|
"oras.land/oras-go/v2/registry/remote/auth"
|
|
|
|
"atcr.io/internal/testharness"
|
|
)
|
|
|
|
// Client is the minimum OCI client surface the matrix tests need: push a
|
|
// ggcr-built image and pull back its manifest digest. Pairing crane (ggcr)
|
|
// with a second independent OCI implementation catches dialect divergence
|
|
// — different Accept-header ordering, blob upload chunking, manifest
|
|
// validation, auth-challenge parsing — that single-client coverage misses.
|
|
type Client interface {
|
|
Name() string
|
|
Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error
|
|
Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error)
|
|
PushIndex(ctx context.Context, t *testing.T, ref string, idx v1.ImageIndex, a testharness.Auth) error
|
|
PullIndex(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error)
|
|
}
|
|
|
|
// Clients are the OCI clients exercised by the matrix tests. Tests range over
|
|
// this and wrap each iteration in t.Run(c.Name(), …) so failures attribute
|
|
// cleanly to whichever client tripped.
|
|
//
|
|
// Note on digest comparisons: every assertion happens within one client's own
|
|
// round-trip (push and pull use the same c). Cross-client digest comparison
|
|
// would be unsafe — the two libraries can send different Accept-header
|
|
// orderings, and the registry could return different manifest content-types
|
|
// to each.
|
|
var Clients = []Client{
|
|
&craneClient{},
|
|
&orasClient{},
|
|
®clientClient{},
|
|
}
|
|
|
|
// --- crane client (ggcr) ----------------------------------------------------
|
|
|
|
type craneClient struct{}
|
|
|
|
func (craneClient) Name() string { return "crane" }
|
|
|
|
func (craneClient) Push(_ context.Context, _ *testing.T, ref string, img v1.Image, a testharness.Auth) error {
|
|
return normalizeErr(crane.Push(img, ref,
|
|
crane.WithAuth(toAuthn(a)),
|
|
crane.Insecure,
|
|
))
|
|
}
|
|
|
|
func (craneClient) Pull(_ context.Context, ref string, a testharness.Auth) (v1.Hash, error) {
|
|
img, err := crane.Pull(ref,
|
|
crane.WithAuth(toAuthn(a)),
|
|
crane.Insecure,
|
|
)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
d, err := img.Digest()
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("crane pulled image digest: %w", err)
|
|
}
|
|
// Materialize the layer bytes. crane.Pull is lazy and img.Digest() needs
|
|
// only the manifest, which ATCR serves from the user's PDS where it is
|
|
// world-readable by design — so a "pull" that stops here never touches the
|
|
// hold and never exercises blob authorization at all. Reading the layers is
|
|
// what makes a pull case a real test of who may read blobs.
|
|
layers, err := img.Layers()
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
for _, l := range layers {
|
|
rc, err := l.Compressed()
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
_, cerr := io.Copy(io.Discard, rc)
|
|
rc.Close()
|
|
if cerr != nil {
|
|
return v1.Hash{}, normalizeErr(cerr)
|
|
}
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (craneClient) PushIndex(_ context.Context, _ *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
|
|
// crane.Push only accepts v1.Image, so drop to remote.Push (which takes
|
|
// any Taggable). Parsing with name.Insecure switches the scheme to http;
|
|
// crane.Insecure also sets insecure on the default transport, but for a
|
|
// 127.0.0.1 dev registry the scheme flip is what matters.
|
|
ref, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("crane: parse ref %q: %w", refStr, err)
|
|
}
|
|
return normalizeErr(remote.Push(ref, idx, remote.WithAuth(toAuthn(a))))
|
|
}
|
|
|
|
func (craneClient) PullIndex(_ context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
ref, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("crane: parse ref %q: %w", refStr, err)
|
|
}
|
|
desc, err := remote.Get(ref, remote.WithAuth(toAuthn(a)))
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
return desc.Digest, nil
|
|
}
|
|
|
|
func toAuthn(a testharness.Auth) authn.Authenticator {
|
|
if a.Username == "" && a.Password == "" {
|
|
return authn.Anonymous
|
|
}
|
|
return &authn.Basic{Username: a.Username, Password: a.Password}
|
|
}
|
|
|
|
// --- oras-go client (OCI working group reference implementation) ------------
|
|
|
|
type orasClient struct{}
|
|
|
|
func (orasClient) Name() string { return "oras" }
|
|
|
|
func (orasClient) Push(ctx context.Context, t *testing.T, ref string, img v1.Image, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(ref, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: parse tag %q: %w", ref, err)
|
|
}
|
|
|
|
// Bridge ggcr -> oras-go via an OCI image layout dir. The ref-name
|
|
// annotation lets oras resolve the image by tag inside the layout.
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("oras: append image to layout: %w", err)
|
|
}
|
|
|
|
src, err := orasoci.New(layoutDir)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: open layout as oci store: %w", err)
|
|
}
|
|
|
|
dst, err := newOrasRepository(parsed, a)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
|
|
if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (orasClient) Pull(ctx context.Context, ref string, a testharness.Auth) (v1.Hash, error) {
|
|
parsed, err := name.ParseReference(ref, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", ref, err)
|
|
}
|
|
tag, ok := parsed.(name.Tag)
|
|
if !ok {
|
|
return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed)
|
|
}
|
|
repo, err := newOrasRepository(tag, a)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
// Resolve performs HEAD /v2/<name>/manifests/<tag> and returns the
|
|
// manifest descriptor — its Digest matches ggcr's img.Digest() for the
|
|
// same content. We don't need to fetch blobs; that mirrors crane.Pull
|
|
// followed by .Digest(), which is also manifest-only.
|
|
desc, err := repo.Resolve(ctx, tag.TagStr())
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func (orasClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: parse tag %q: %w", refStr, err)
|
|
}
|
|
|
|
// Same OCI layout bridge as Push, but AppendIndex walks the index and
|
|
// writes every child manifest + blob into the layout in one shot.
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("oras: append index to layout: %w", err)
|
|
}
|
|
|
|
src, err := orasoci.New(layoutDir)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: open layout as oci store: %w", err)
|
|
}
|
|
|
|
dst, err := newOrasRepository(parsed, a)
|
|
if err != nil {
|
|
return fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
|
|
if _, err := oras.Copy(ctx, src, parsed.TagStr(), dst, parsed.TagStr(), oras.DefaultCopyOptions); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (orasClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
// Same head-only resolve as Pull — manifest digest is the same value for
|
|
// images and indexes (it's just the manifest body's sha256).
|
|
parsed, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: parse ref %q: %w", refStr, err)
|
|
}
|
|
tag, ok := parsed.(name.Tag)
|
|
if !ok {
|
|
return v1.Hash{}, fmt.Errorf("oras: expected tagged ref, got %T", parsed)
|
|
}
|
|
repo, err := newOrasRepository(tag, a)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("oras: new repository: %w", err)
|
|
}
|
|
desc, err := repo.Resolve(ctx, tag.TagStr())
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func newOrasRepository(tag name.Tag, a testharness.Auth) (*orasremote.Repository, error) {
|
|
// orasremote.NewRepository wants "host[:port]/repo" form (no tag/digest).
|
|
repoRef := tag.Repository.String()
|
|
repo, err := orasremote.NewRepository(repoRef)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %q: %w", repoRef, err)
|
|
}
|
|
repo.PlainHTTP = true
|
|
if a.Username != "" || a.Password != "" {
|
|
host := tag.RegistryStr()
|
|
repo.Client = &auth.Client{
|
|
Credential: auth.StaticCredential(host, auth.Credential{
|
|
Username: a.Username,
|
|
Password: a.Password,
|
|
}),
|
|
}
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
// --- regclient client (regctl) ---------------------------------------------
|
|
|
|
type regclientClient struct{}
|
|
|
|
func (regclientClient) Name() string { return "regclient" }
|
|
|
|
func (regclientClient) Push(ctx context.Context, t *testing.T, refStr string, img v1.Image, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse tag %q: %w", refStr, err)
|
|
}
|
|
|
|
// Same OCI layout bridge as the oras client.
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendImage(img, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("regclient: append image to layout: %w", err)
|
|
}
|
|
|
|
srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr()))
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse ocidir ref: %w", err)
|
|
}
|
|
dstRef, err := ref.New(refStr)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse dest ref: %w", err)
|
|
}
|
|
|
|
rc := newRegclient(parsed.RegistryStr(), a)
|
|
defer rc.Close(ctx, dstRef)
|
|
|
|
if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (regclientClient) Pull(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
parsed, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err)
|
|
}
|
|
r, err := ref.New(refStr)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err)
|
|
}
|
|
rc := newRegclient(parsed.Context().RegistryStr(), a)
|
|
defer rc.Close(ctx, r)
|
|
|
|
// ManifestHead matches crane's lazy-pull semantics: HEAD /manifests/<tag>
|
|
// returns the descriptor with the registry-computed digest.
|
|
mh, err := rc.ManifestHead(ctx, r)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
desc := mh.GetDescriptor()
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func (regclientClient) PushIndex(ctx context.Context, t *testing.T, refStr string, idx v1.ImageIndex, a testharness.Auth) error {
|
|
t.Helper()
|
|
parsed, err := name.NewTag(refStr, name.Insecure)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse tag %q: %w", refStr, err)
|
|
}
|
|
|
|
// Same ocidir bridge as regclientClient.Push. AppendIndex walks the
|
|
// index and writes every child manifest + blob into the layout; ImageCopy
|
|
// then ships the whole tree (it dispatches on the source descriptor's
|
|
// media type, so OCI indexes work without extra plumbing).
|
|
layoutDir := filepath.Join(t.TempDir(), "oci-layout")
|
|
lp, err := layout.Write(layoutDir, empty.Index)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: init layout: %w", err)
|
|
}
|
|
if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{
|
|
ocispec.AnnotationRefName: parsed.TagStr(),
|
|
})); err != nil {
|
|
return fmt.Errorf("regclient: append index to layout: %w", err)
|
|
}
|
|
|
|
srcRef, err := ref.New(fmt.Sprintf("ocidir://%s:%s", layoutDir, parsed.TagStr()))
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse ocidir ref: %w", err)
|
|
}
|
|
dstRef, err := ref.New(refStr)
|
|
if err != nil {
|
|
return fmt.Errorf("regclient: parse dest ref: %w", err)
|
|
}
|
|
|
|
rc := newRegclient(parsed.RegistryStr(), a)
|
|
defer rc.Close(ctx, dstRef)
|
|
|
|
if err := rc.ImageCopy(ctx, srcRef, dstRef); err != nil {
|
|
return normalizeErr(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (regclientClient) PullIndex(ctx context.Context, refStr string, a testharness.Auth) (v1.Hash, error) {
|
|
// ManifestHead returns the index manifest descriptor with its sha256.
|
|
parsed, err := name.ParseReference(refStr, name.Insecure)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref %q: %w", refStr, err)
|
|
}
|
|
r, err := ref.New(refStr)
|
|
if err != nil {
|
|
return v1.Hash{}, fmt.Errorf("regclient: parse ref: %w", err)
|
|
}
|
|
rc := newRegclient(parsed.Context().RegistryStr(), a)
|
|
defer rc.Close(ctx, r)
|
|
|
|
mh, err := rc.ManifestHead(ctx, r)
|
|
if err != nil {
|
|
return v1.Hash{}, normalizeErr(err)
|
|
}
|
|
desc := mh.GetDescriptor()
|
|
return v1.Hash{Algorithm: desc.Digest.Algorithm().String(), Hex: desc.Digest.Encoded()}, nil
|
|
}
|
|
|
|
func newRegclient(host string, a testharness.Auth) *regclient.RegClient {
|
|
cfg := config.Host{
|
|
Name: host,
|
|
Hostname: host,
|
|
TLS: config.TLSDisabled,
|
|
User: a.Username,
|
|
Pass: a.Password,
|
|
}
|
|
return regclient.New(regclient.WithConfigHost(cfg))
|
|
}
|
|
|
|
// --- error normalization ----------------------------------------------------
|
|
|
|
// normalizeErr surfaces the registry-supplied error message from either
|
|
// client's wrapper, so the substring assertions in auth_matrix_test.go and
|
|
// quota_test.go ("blob:write", "crew membership required",
|
|
// "authentication required", "quota exceeded") match for both clients.
|
|
//
|
|
// Both libraries route distribution-spec error bodies through
|
|
// errcode.Error / errcode.Errors at some layer of the wrapping. We unwrap
|
|
// when we can; otherwise return the original error (its .Error() string
|
|
// usually already contains the registry body).
|
|
func normalizeErr(err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var ec errcode.Error
|
|
if errors.As(err, &ec) {
|
|
return fmt.Errorf("%s: %s: %w", ec.Code.String(), ec.Message, err)
|
|
}
|
|
var ecs errcode.Errors
|
|
if errors.As(err, &ecs) && len(ecs) > 0 {
|
|
var first errcode.Error
|
|
if errors.As(ecs[0], &first) {
|
|
return fmt.Errorf("%s: %s: %w", first.Code.String(), first.Message, err)
|
|
}
|
|
}
|
|
// If neither shape matches, the original error's .Error() string from
|
|
// either client typically already includes the registry response body.
|
|
return err
|
|
}
|