From f2d2875a6ec361066e06f5b6a7b8aaf31575bd65 Mon Sep 17 00:00:00 2001 From: mpuig Date: Tue, 11 Aug 2026 16:49:49 +0200 Subject: [PATCH 1/2] images: mirror digest-pinned indexes byte for byte A Dockerfile that pins its base image by digest almost always pins the multi-arch *index* digest -- it is the digest `docker pull` and Docker Hub print. MirrorBaseImage resolved that reference to the platform image and pushed it under the index digest, which the local registry correctly refuses: push to local registry: PUT .../v2/library/python/manifests/sha256:9b4929a7...: unexpected status code 400 Bad Request: digest mismatch: expected sha256:9b4929a7..., got sha256:1e58d36e... The mirror failure is only a WARN, so the build then proceeds and fails later with an unrelated error -- every digest-pinned FROM is unbuildable, and supply-chain-pinned Dockerfiles are exactly the ones that hit it. An index named by digest is now mirrored as the index itself (children first, then the index manifest), so the destination stores content whose digest is exactly the one the caller pinned. Tag references keep the existing platform-image behavior, which is what saves storage; a digest reference that names a plain manifest is also unchanged. The push logic moves into pushMirrored so the property is testable against in-memory registries: the new tests pin a random two-manifest index by digest, mirror it, and prove the pinned digest resolves at the destination. --- lib/images/mirror.go | 61 +++++++++++++++++++++++----- lib/images/mirror_test.go | 84 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 9 deletions(-) diff --git a/lib/images/mirror.go b/lib/images/mirror.go index 1889ed5ea..17df7c8d1 100644 --- a/lib/images/mirror.go +++ b/lib/images/mirror.go @@ -7,6 +7,7 @@ import ( "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/remote" ) @@ -47,8 +48,16 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, return nil, err } - // Pull the image from source - img, err := remote.Image(srcRef, + // Fetch the source descriptor first: what gets pushed depends on what the + // reference names. A digest reference can name a multi-arch *index*, and + // the platform-resolved image inside it has a different digest than the + // index — pushing that image under the index digest fails content-address + // verification at the destination ("digest mismatch"), which is exactly + // what any Dockerfile with a digest-pinned FROM produces. An index named + // by digest is therefore mirrored as the index itself, byte for byte. + // Tag references keep the existing behavior of mirroring only the + // requested platform, which is what saves storage. + desc, err := remote.Get(srcRef, remote.WithContext(ctx), remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithPlatform(platform.ToGCR())) @@ -56,11 +65,7 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, return nil, fmt.Errorf("pull source image: %w", ClassifyRegistryError(err)) } - // Get the digest - digest, err := img.Digest() - if err != nil { - return nil, fmt.Errorf("get image digest: %w", err) - } + _, isDigestRef := srcRef.(name.Digest) // Build the local reference under bases/ namespace // Normalize the source to strip docker.io/ prefix for cleaner local refs @@ -87,8 +92,9 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, opts = append(opts, remote.WithAuth(authn.FromConfig(*authConfig))) } - if err := remote.Write(dstRef, img, opts...); err != nil { - return nil, fmt.Errorf("push to local registry: %w", ClassifyRegistryError(err)) + digest, err := pushMirrored(desc, isDigestRef, dstRef, opts...) + if err != nil { + return nil, err } return &MirrorResult{ @@ -98,6 +104,43 @@ func MirrorBaseImage(ctx context.Context, registryURL string, req MirrorRequest, }, nil } +// pushMirrored writes the fetched content to dstRef and returns the digest it +// is retrievable under. +// +// An index named by digest is written as the index itself — children first, +// then the index manifest — so the destination stores content whose digest is +// exactly the one the caller pinned. Everything else keeps the original +// behavior: the (platform-resolved) image is written, under its own digest. +func pushMirrored(desc *remote.Descriptor, isDigestRef bool, dstRef name.Reference, opts ...remote.Option) (v1.Hash, error) { + if isDigestRef && desc.MediaType.IsIndex() { + idx, err := desc.ImageIndex() + if err != nil { + return v1.Hash{}, fmt.Errorf("load source index: %w", err) + } + digest, err := idx.Digest() + if err != nil { + return v1.Hash{}, fmt.Errorf("get image digest: %w", err) + } + if err := remote.WriteIndex(dstRef, idx, opts...); err != nil { + return v1.Hash{}, fmt.Errorf("push to local registry: %w", ClassifyRegistryError(err)) + } + return digest, nil + } + + img, err := desc.Image() + if err != nil { + return v1.Hash{}, fmt.Errorf("pull source image: %w", ClassifyRegistryError(err)) + } + digest, err := img.Digest() + if err != nil { + return v1.Hash{}, fmt.Errorf("get image digest: %w", err) + } + if err := remote.Write(dstRef, img, opts...); err != nil { + return v1.Hash{}, fmt.Errorf("push to local registry: %w", ClassifyRegistryError(err)) + } + return digest, nil +} + // normalizeToLocalRef converts a source image reference to a normalized local reference. // It strips the docker.io/ prefix but preserves the library/ prefix for official images. // The library/ prefix is kept because BuildKit's mirror protocol requests official images diff --git a/lib/images/mirror_test.go b/lib/images/mirror_test.go index 30fe0d3c9..e0c553707 100644 --- a/lib/images/mirror_test.go +++ b/lib/images/mirror_test.go @@ -1,9 +1,14 @@ package images import ( + "net/http/httptest" + "strings" "testing" "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,3 +94,82 @@ func TestStripScheme(t *testing.T) { }) } } + +// A digest-pinned FROM usually pins the multi-arch *index* digest (it is the +// digest `docker pull` prints), while the mirror pulls the platform-resolved +// image whose digest differs. Pushing that image under the index digest is +// refused by any registry that verifies content addresses — so an index named +// by digest must arrive at the local registry as the index itself, retrievable +// under exactly the digest the caller pinned. +// +// Tested at the pushMirrored seam: httptest registries live on 127.0.0.1:port, +// and a host:port cannot ride through normalizeToLocalRef as a path prefix the +// way docker.io/gcr.io sources do in production. +func TestPushMirroredPreservesIndexDigest(t *testing.T) { + src := httptest.NewServer(registry.New()) + defer src.Close() + dst := httptest.NewServer(registry.New()) + defer dst.Close() + srcHost := strings.TrimPrefix(src.URL, "http://") + dstHost := strings.TrimPrefix(dst.URL, "http://") + + // A two-manifest index, pushed to the source registry and then referenced + // by its digest — the exact shape of a digest-pinned Docker Hub image. + idx, err := random.Index(1024, 1, 2) + require.NoError(t, err) + indexDigest, err := idx.Digest() + require.NoError(t, err) + seed, err := name.ParseReference(srcHost + "/library/python:3.13-alpine") + require.NoError(t, err) + require.NoError(t, remote.WriteIndex(seed, idx)) + + srcRef, err := name.ParseReference(srcHost + "/library/python@" + indexDigest.String()) + require.NoError(t, err) + desc, err := remote.Get(srcRef) + require.NoError(t, err) + + dstRef, err := name.ParseReference(dstHost + "/library/python@" + indexDigest.String()) + require.NoError(t, err) + digest, err := pushMirrored(desc, true, dstRef) + require.NoError(t, err) + assert.Equal(t, indexDigest, digest, + "the mirrored digest must be the digest the caller pinned") + + // The proof the old code could not give: the content is retrievable from + // the destination under the pinned digest, i.e. it verified on push. + mirrored, err := remote.Get(dstRef) + require.NoError(t, err, "the pinned digest must resolve at the local registry") + assert.Equal(t, indexDigest, mirrored.Digest) + assert.True(t, mirrored.MediaType.IsIndex(), "an index must arrive as an index") +} + +// The storage-saving path is unchanged: a tag reference mirrors only the +// (platform-resolved) image, not the whole index. +func TestPushMirroredByTagStillMirrorsImage(t *testing.T) { + src := httptest.NewServer(registry.New()) + defer src.Close() + dst := httptest.NewServer(registry.New()) + defer dst.Close() + srcHost := strings.TrimPrefix(src.URL, "http://") + dstHost := strings.TrimPrefix(dst.URL, "http://") + + img, err := random.Image(1024, 1) + require.NoError(t, err) + imgDigest, err := img.Digest() + require.NoError(t, err) + srcRef, err := name.ParseReference(srcHost + "/library/alpine:3.21") + require.NoError(t, err) + require.NoError(t, remote.Write(srcRef, img)) + + desc, err := remote.Get(srcRef) + require.NoError(t, err) + dstRef, err := name.ParseReference(dstHost + "/library/alpine:3.21") + require.NoError(t, err) + digest, err := pushMirrored(desc, false, dstRef) + require.NoError(t, err) + assert.Equal(t, imgDigest, digest) + + mirrored, err := remote.Get(dstRef) + require.NoError(t, err) + assert.False(t, mirrored.MediaType.IsIndex()) +} From eb23dca8ea541629da49c14675d35fd9149db893 Mon Sep 17 00:00:00 2001 From: "Puig, Marc" Date: Sun, 23 Aug 2026 11:17:17 +0200 Subject: [PATCH 2/2] images: test that a tagged multi-platform index resolves to the requested platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the review note on #391: the existing by-tag test used a single- platform image, so it proved only that the tag path avoids mirroring the whole index — not that it resolves a multi-platform index down to the requested platform. The new test builds a two-platform index (linux/amd64 + linux/arm64), fetches the tag with WithPlatform(linux/arm64), and asserts the mirror arrives as that arm64 image: not the index, and not the amd64 sibling. --- lib/images/mirror_test.go | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/lib/images/mirror_test.go b/lib/images/mirror_test.go index e0c553707..020b9118f 100644 --- a/lib/images/mirror_test.go +++ b/lib/images/mirror_test.go @@ -7,6 +7,9 @@ import ( "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/registry" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/random" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/stretchr/testify/assert" @@ -173,3 +176,63 @@ func TestPushMirroredByTagStillMirrorsImage(t *testing.T) { require.NoError(t, err) assert.False(t, mirrored.MediaType.IsIndex()) } + +// The tag path does not just avoid mirroring the whole index — it resolves a +// multi-platform index down to *the requested platform's* image. The single- +// platform test above cannot see this, because with one manifest there is +// nothing to pick between. Here the source tag names a two-platform index and +// the request asks for linux/arm64: the mirror must arrive as that arm64 image, +// not as the index and not as the other platform's amd64 image. +func TestPushMirroredByTagResolvesMultiPlatformIndexToRequestedPlatform(t *testing.T) { + src := httptest.NewServer(registry.New()) + defer src.Close() + dst := httptest.NewServer(registry.New()) + defer dst.Close() + srcHost := strings.TrimPrefix(src.URL, "http://") + dstHost := strings.TrimPrefix(dst.URL, "http://") + + // Two images with distinct digests (distinct sizes), each pinned to a + // distinct platform in the index manifest — the shape of a real multi-arch + // Docker Hub tag. + amd64Img, err := random.Image(1024, 1) + require.NoError(t, err) + arm64Img, err := random.Image(2048, 1) + require.NoError(t, err) + amd64Digest, err := amd64Img.Digest() + require.NoError(t, err) + arm64Digest, err := arm64Img.Digest() + require.NoError(t, err) + require.NotEqual(t, amd64Digest, arm64Digest, "the platforms must be distinguishable") + + idx := mutate.AppendManifests(empty.Index, + mutate.IndexAddendum{ + Add: amd64Img, + Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "amd64"}}, + }, + mutate.IndexAddendum{ + Add: arm64Img, + Descriptor: v1.Descriptor{Platform: &v1.Platform{OS: "linux", Architecture: "arm64"}}, + }, + ) + srcRef, err := name.ParseReference(srcHost + "/library/multi:latest") + require.NoError(t, err) + require.NoError(t, remote.WriteIndex(srcRef, idx)) + + // The production tag path: fetch the tag with a requested platform, then + // mirror as a non-digest reference (isDigestRef=false). + desc, err := remote.Get(srcRef, remote.WithPlatform(v1.Platform{OS: "linux", Architecture: "arm64"})) + require.NoError(t, err) + dstRef, err := name.ParseReference(dstHost + "/library/multi:latest") + require.NoError(t, err) + digest, err := pushMirrored(desc, false, dstRef) + require.NoError(t, err) + assert.Equal(t, arm64Digest, digest, + "the tag path must resolve to the requested platform's image, not the index or another platform") + assert.NotEqual(t, amd64Digest, digest, "it must not mirror the other platform") + + mirrored, err := remote.Get(dstRef) + require.NoError(t, err) + assert.False(t, mirrored.MediaType.IsIndex(), + "a tagged multi-platform index must arrive as a single platform-resolved image") + assert.Equal(t, arm64Digest, mirrored.Digest) +}