Files
at-container-registry/test/integration/clients.go
T
Evan JarrettandClaude Opus 5 43bf79c71f test/integration: make every pull client actually read a blob
Two of the three matrix clients never fetched one. orasClient.Pull stopped at
repo.Resolve (a HEAD on the manifest) and regclient's stopped at ManifestHead,
so their pull rows — including anonymous_pull_denied, stranger_pull and
crew_read_only_pull on the private-hold matrix — were passing without ever
exercising blob authorization. They passed on the manifest denial alone.

That is invisible from the outside because it produces the right verdicts for
the wrong reason. The full suite is still green after the fix, so no
authorization bug was hiding behind it; what was hiding was the coverage.

craneClient.Pull was already correct: 5aa13ab added its layer-materialization
loop precisely because crane is lazy and ATCR serves manifests from the user's
PDS, where they are world-readable by design. The oras comment still carried
the pre-5aa13ab rationale — "We don't need to fetch blobs; that mirrors
crane.Pull followed by .Digest(), which is also manifest-only" — which that
commit had already invalidated. Both clients now match crane, and the stale
comment is gone.

TestPullClientsReadBlobs guards all three against regressing to manifest-only.
It runs each client against a registry that serves the manifest happily and
403s the layer, and asserts both that a blob was requested and that the refusal
surfaces as an error. Written first and run before the fix, where it passed for
crane and failed for oras and regclient — which is how the gap was found.

This also closes the hole the plan flagged for craneClient.Pull alone: nothing
protected that loop, and deleting it left the entire suite green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
2026-08-25 16:34:25 -05:00

520 lines
17 KiB
Go

//go:build integration
package integration
import (
"context"
"encoding/json"
"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/manifest"
"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{},
&regclientClient{},
}
// --- 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.
desc, err := repo.Resolve(ctx, tag.TagStr())
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
// Then read the layer bytes, for the same reason craneClient.Pull does:
// resolving the manifest alone never asks the registry for a blob, and
// ATCR serves manifests from the user's PDS where they are world-readable.
// A manifest-only "pull" cannot fail an authorization check on blobs, so
// every pull row in the matrix would pass without testing what it claims.
if err := orasReadLayers(ctx, repo, desc); 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
}
// orasReadLayers fetches every layer blob named by a manifest and discards the
// bytes. Pulling is the only matrix operation that can exercise blob
// authorization, and it only does so if something actually reads a blob.
func orasReadLayers(ctx context.Context, repo *orasremote.Repository, desc ocispec.Descriptor) error {
rc, err := repo.Manifests().Fetch(ctx, desc)
if err != nil {
return err
}
body, err := io.ReadAll(rc)
if closeErr := rc.Close(); err == nil {
err = closeErr
}
if err != nil {
return err
}
var m ocispec.Manifest
if err := json.Unmarshal(body, &m); err != nil {
// An index rather than an image manifest: PullIndex covers that shape.
return nil //nolint:nilerr
}
for _, layer := range m.Layers {
lr, err := repo.Blobs().Fetch(ctx, layer)
if err != nil {
return err
}
_, copyErr := io.Copy(io.Discard, lr)
closeErr := lr.Close()
if copyErr != nil {
return copyErr
}
if closeErr != nil {
return closeErr
}
}
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)
// ManifestGet rather than ManifestHead: the head gives the digest, but the
// body is what names the layers, and the layers are the only part of a
// pull that exercises blob authorization. See craneClient.Pull.
m, err := rc.ManifestGet(ctx, r)
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
desc := m.GetDescriptor()
if imager, ok := m.(manifest.Imager); ok {
layers, err := imager.GetLayers()
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
for _, l := range layers {
rdr, err := rc.BlobGet(ctx, r, l)
if err != nil {
return v1.Hash{}, normalizeErr(err)
}
_, copyErr := io.Copy(io.Discard, rdr)
closeErr := rdr.Close()
if copyErr != nil {
return v1.Hash{}, normalizeErr(copyErr)
}
if closeErr != nil {
return v1.Hash{}, normalizeErr(closeErr)
}
}
}
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
}