compose/internal/oci/resolver_test.go
Guillaume Lours cda9f2044f fix(oci): fetch artifact layers from blobs endpoint, not via Resolve
Pulling an oci:// resource resolved each layer digest through the
registry manifests endpoint, which answers 500 when the digest points
to a non-manifest blob. containerd v2.3.0+ (pulled in by buildx v0.36
and buildkit v0.32) no longer falls back to the blobs endpoint unless
manifests returned 404, so publish/pull of compose artifacts broke.
Fetch layers directly with the descriptors already listed in the
manifest instead of resolving them again.

Signed-off-by: Guillaume Lours <glours@users.noreply.github.com>
2026-08-03 14:56:34 +02:00

215 lines
8 KiB
Go

/*
Copyright 2026 Docker Compose CLI authors
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 oci
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/distribution/reference"
"github.com/docker/cli/cli/config/configfile"
"github.com/opencontainers/go-digest"
spec "github.com/opencontainers/image-spec/specs-go/v1"
"gotest.tools/v3/assert"
)
// recordingRoundTripper counts RoundTrip invocations on a delegate so tests
// can verify a supplied transport is actually used by the resolver. It also
// tracks token-endpoint calls separately so tests can assert the authorizer's
// token fetch goes through the same transport.
type recordingRoundTripper struct {
delegate http.RoundTripper
calls atomic.Int32
authCalls atomic.Int32
}
func (r *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
r.calls.Add(1)
if strings.HasSuffix(req.URL.Path, "/token") {
r.authCalls.Add(1)
}
return r.delegate.RoundTrip(req)
}
// TestNewResolver_UsesProvidedTransport guards that the transport passed to
// NewResolver actually carries OCI traffic. The httptest server returns 401
// so the resolver fails fast without real network access.
func TestNewResolver_UsesProvidedTransport(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(server.Close)
host := server.Listener.Addr().String()
// Bare *http.Transport (Proxy: nil) keeps the test hermetic — delegating
// to http.DefaultTransport would honor HTTP[S]_PROXY env vars in CI or
// dev shells and route requests away from our local httptest server.
rec := &recordingRoundTripper{delegate: &http.Transport{}}
// Mark the test host insecure so the resolver uses HTTP scheme; this
// avoids needing a TLS cert chain just to exercise plumbing.
resolver := NewResolver(&configfile.ConfigFile{}, rec, host)
// We expect 401, but only care that the request reached our transport.
_, _, _ = resolver.Resolve(t.Context(), host+"/test/image:latest")
assert.Assert(t, rec.calls.Load() > 0,
"resolver did not invoke the supplied transport — wiring is broken")
}
// TestNewResolver_AuthorizerUsesProvidedTransport guards that the docker
// authorizer's token fetch goes through the supplied transport, not
// http.DefaultClient.
func TestNewResolver_AuthorizerUsesProvidedTransport(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/token") {
_, _ = w.Write([]byte(`{"token":"fake","access_token":"fake","expires_in":300}`))
return
}
if r.Header.Get("Authorization") == "" {
w.Header().Set("Www-Authenticate", `Bearer realm="`+server.URL+`/token",service="test"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
// Authenticated retry — fail fast, we only care the auth dance went
// through the supplied transport.
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(server.Close)
host := server.Listener.Addr().String()
rec := &recordingRoundTripper{delegate: &http.Transport{}}
resolver := NewResolver(&configfile.ConfigFile{}, rec, host)
_, _, _ = resolver.Resolve(t.Context(), host+"/test/image:latest")
assert.Assert(t, rec.authCalls.Load() > 0,
"authorizer token fetch did not go through the supplied transport (bypassed via http.DefaultClient)")
}
func TestNewResolver_NilTransportIsValid(t *testing.T) {
resolver := NewResolver(&configfile.ConfigFile{}, nil)
assert.Assert(t, resolver != nil, "NewResolver must return a non-nil resolver when transport is nil")
}
// TestGetBlob_FetchesFromBlobsEndpoint guards that layer content is fetched
// straight from the blobs endpoint. Registries answer HEAD/GET on
// manifests/<digest> with a 500 when the digest points to a non-manifest blob
// (they try to JSON-parse it), and containerd >= 2.3 no longer falls back to
// blobs unless the manifests endpoint returned 404 — so resolving a layer
// digest through Resolve() breaks the pull.
func TestGetBlob_FetchesFromBlobsEndpoint(t *testing.T) {
content := []byte("services:\n test:\n image: alpine\n")
dgst := digest.FromBytes(content)
var manifestHits atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/v2/":
w.WriteHeader(http.StatusOK)
case r.URL.Path == "/v2/test/artifact/manifests/"+dgst.String():
// mimic distribution: the blob exists but is not a manifest
manifestHits.Add(1)
w.WriteHeader(http.StatusInternalServerError)
case r.URL.Path == "/v2/test/artifact/blobs/"+dgst.String():
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(content)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
host := server.Listener.Addr().String()
resolver := NewResolver(&configfile.ConfigFile{}, &http.Transport{}, host)
ref, err := reference.ParseDockerRef(host + "/test/artifact:latest")
assert.NilError(t, err)
got, err := GetBlob(t.Context(), resolver, ref, spec.Descriptor{
MediaType: ComposeYAMLMediaType,
Digest: dgst,
Size: int64(len(content)),
})
assert.NilError(t, err)
assert.DeepEqual(t, got, content)
assert.Equal(t, manifestHits.Load(), int32(0),
"layer fetch must not go through the manifests endpoint")
}
// blobServer serves fixed bytes on the blobs endpoint for the given digest.
func blobServer(t *testing.T, dgst digest.Digest, served []byte) string {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/":
w.WriteHeader(http.StatusOK)
case "/v2/test/artifact/blobs/" + dgst.String():
_, _ = w.Write(served)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
return server.Listener.Addr().String()
}
// TestGetBlob_RejectsDigestMismatch guards that content served by the
// registry is verified against the descriptor digest: GetBlob bypasses
// containerd's content store, so nothing else checks integrity before the
// bytes are written to disk as a Compose file.
func TestGetBlob_RejectsDigestMismatch(t *testing.T) {
declared := []byte("services:\n test:\n image: alpine\n")
tampered := []byte("services:\n test:\n image: evil/image\n")
dgst := digest.FromBytes(declared)
host := blobServer(t, dgst, tampered)
resolver := NewResolver(&configfile.ConfigFile{}, &http.Transport{}, host)
ref, err := reference.ParseDockerRef(host + "/test/artifact:latest")
assert.NilError(t, err)
_, err = GetBlob(t.Context(), resolver, ref, spec.Descriptor{
MediaType: ComposeYAMLMediaType,
Digest: dgst,
Size: int64(len(tampered)),
})
assert.ErrorContains(t, err, "digest mismatch")
}
// TestGetBlob_RejectsSizeMismatch guards that GetBlob never reads more than
// the declared descriptor size, so a rogue registry cannot cause unbounded
// memory allocation.
func TestGetBlob_RejectsSizeMismatch(t *testing.T) {
content := []byte("services:\n test:\n image: alpine\n")
dgst := digest.FromBytes(content)
host := blobServer(t, dgst, content)
resolver := NewResolver(&configfile.ConfigFile{}, &http.Transport{}, host)
ref, err := reference.ParseDockerRef(host + "/test/artifact:latest")
assert.NilError(t, err)
_, err = GetBlob(t.Context(), resolver, ref, spec.Descriptor{
MediaType: ComposeYAMLMediaType,
Digest: dgst,
Size: int64(len(content)) - 1, // registry serves more than declared
})
assert.ErrorContains(t, err, "size mismatch")
}