From 5dcf4640b1e2854c85ecf559392c7170a959de9d Mon Sep 17 00:00:00 2001 From: Damian Sloane Date: Mon, 7 Sep 2026 15:29:20 +1000 Subject: [PATCH] remotecache: split the import blob size limits (manifest 4 MiB, cache config 16 MiB) readBlob refused any blob over 1 MiB, for the cache manifest and the cache config alike. The manifest comes from the registry's manifest endpoint, which registries cap at a few MiB; the cache config (application/vnd.buildkit.cacheconfig.v0) is served from the blob endpoint and has no such ceiling, and a mode=max export of a large multi-stage build on a busy shared daemon produces a config of 1-2 MiB, after which every import fails with "blob ... is too large" and the build silently loses its registry cache. Give readBlob a per-call limit: 4 MiB for the manifest read, 16 MiB for the cache config read, as discussed in #3719. Add a unit test that pins both ceilings and covers the previously refused 1 MiB + 1 byte config. Fixes #3719 Fixes #4916 Signed-off-by: Damian Sloane --- cache/remotecache/import.go | 18 +++++-- cache/remotecache/import_test.go | 86 ++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 cache/remotecache/import_test.go diff --git a/cache/remotecache/import.go b/cache/remotecache/import.go index 5f635a0d02b3..0115ffcaded2 100644 --- a/cache/remotecache/import.go +++ b/cache/remotecache/import.go @@ -46,7 +46,7 @@ type contentCacheImporter struct { } func (ci *contentCacheImporter) Resolve(ctx context.Context, desc ocispecs.Descriptor, id string, w worker.Worker) (solver.CacheManager, error) { - dt, err := readBlob(ctx, ci.provider, desc) + dt, err := readBlob(ctx, ci.provider, desc, maxManifestBlobSize) if err != nil { return nil, err } @@ -112,7 +112,7 @@ func (ci *contentCacheImporter) Resolve(ctx context.Context, desc ocispecs.Descr return ci.importInlineCache(ctx, dt, id, w) } - dt, err = readBlob(ctx, ci.provider, configDesc) + dt, err = readBlob(ctx, ci.provider, configDesc, maxCacheConfigBlobSize) if err != nil { return nil, err } @@ -129,8 +129,18 @@ func (ci *contentCacheImporter) Resolve(ctx context.Context, desc ocispecs.Descr return solver.NewCacheManager(ctx, id, keysStorage, resultStorage), nil } -func readBlob(ctx context.Context, provider content.Provider, desc ocispecs.Descriptor) ([]byte, error) { - maxBlobSize := int64(1 << 20) +const ( + // maxManifestBlobSize bounds the cache manifest (image manifest or index) + // read from the registry's manifest endpoint, which registries cap at a few MiB. + maxManifestBlobSize = int64(4 << 20) + // maxCacheConfigBlobSize bounds the cache config blob (records + layers, + // application/vnd.buildkit.cacheconfig.v0). It is served from the blob + // endpoint, so the manifest ceiling does not apply; a mode=max export of a + // large multi-stage build on a busy shared daemon reaches 1-2 MiB. + maxCacheConfigBlobSize = int64(16 << 20) +) + +func readBlob(ctx context.Context, provider content.Provider, desc ocispecs.Descriptor, maxBlobSize int64) ([]byte, error) { if desc.Size > maxBlobSize { return nil, errors.Errorf("blob %s is too large (%d > %d)", desc.Digest, desc.Size, maxBlobSize) } diff --git a/cache/remotecache/import_test.go b/cache/remotecache/import_test.go new file mode 100644 index 000000000000..2972707cbd24 --- /dev/null +++ b/cache/remotecache/import_test.go @@ -0,0 +1,86 @@ +package remotecache + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/containerd/containerd/v2/core/content" + digest "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/stretchr/testify/require" +) + +// memProvider serves one blob from memory. +type memProvider struct { + dt []byte +} + +func (p *memProvider) ReaderAt(_ context.Context, _ ocispecs.Descriptor) (content.ReaderAt, error) { + return &memReaderAt{Reader: bytes.NewReader(p.dt), size: int64(len(p.dt))}, nil +} + +type memReaderAt struct { + *bytes.Reader + size int64 +} + +func (r *memReaderAt) Size() int64 { return r.size } +func (r *memReaderAt) Close() error { return nil } + +func descFor(dt []byte, mediaType string) ocispecs.Descriptor { + return ocispecs.Descriptor{ + MediaType: mediaType, + Digest: digest.FromBytes(dt), + Size: int64(len(dt)), + } +} + +func TestReadBlobLimits(t *testing.T) { + ctx := context.Background() + + // The limits must both sit above the 1 MiB ceiling that refused real + // cache configs (mode=max exports of large builds, buildkit issues #3719 + // and #4916); the config ceiling is the larger of the two. + require.Greater(t, maxManifestBlobSize, int64(1<<20)) + require.Greater(t, maxCacheConfigBlobSize, maxManifestBlobSize) + + t.Run("cache config just over 1 MiB imports", func(t *testing.T) { + dt := bytes.Repeat([]byte{'x'}, 1<<20+1) + desc := descFor(dt, "application/vnd.buildkit.cacheconfig.v0") + got, err := readBlob(ctx, &memProvider{dt: dt}, desc, maxCacheConfigBlobSize) + require.NoError(t, err) + require.Equal(t, dt, got) + }) + + t.Run("cache config over its ceiling is refused before reading", func(t *testing.T) { + desc := ocispecs.Descriptor{ + MediaType: "application/vnd.buildkit.cacheconfig.v0", + Digest: digest.FromString("unread"), + Size: maxCacheConfigBlobSize + 1, + } + _, err := readBlob(ctx, &memProvider{dt: nil}, desc, maxCacheConfigBlobSize) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "is too large"), err.Error()) + }) + + t.Run("manifest over its ceiling is refused before reading", func(t *testing.T) { + desc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromString("unread"), + Size: maxManifestBlobSize + 1, + } + _, err := readBlob(ctx, &memProvider{dt: nil}, desc, maxManifestBlobSize) + require.Error(t, err) + require.True(t, strings.Contains(err.Error(), "is too large"), err.Error()) + }) + + t.Run("manifest within its ceiling reads", func(t *testing.T) { + dt := []byte(`{"schemaVersion":2}`) + desc := descFor(dt, ocispecs.MediaTypeImageManifest) + got, err := readBlob(ctx, &memProvider{dt: dt}, desc, maxManifestBlobSize) + require.NoError(t, err) + require.Equal(t, dt, got) + }) +}