252 lines
8.0 KiB
Go
252 lines
8.0 KiB
Go
package registry
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
ggcrregistry "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"
|
|
)
|
|
|
|
// request is one request the test registry received.
|
|
type request struct{ method, path string }
|
|
|
|
// testRegistry is an in-memory registry that records every request.
|
|
type testRegistry struct {
|
|
srv *httptest.Server
|
|
host string // "127.0.0.1:port", the registry part of a ref
|
|
|
|
mu sync.Mutex
|
|
requests []request
|
|
}
|
|
|
|
// newTestRegistry starts a registry. When user is set every request needs
|
|
// those basic-auth credentials.
|
|
func newTestRegistry(t *testing.T, user, pass string) *testRegistry {
|
|
t.Helper()
|
|
backend := ggcrregistry.New(ggcrregistry.Logger(log.New(io.Discard, "", 0)))
|
|
tr := &testRegistry{}
|
|
tr.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tr.mu.Lock()
|
|
tr.requests = append(tr.requests, request{r.Method, r.URL.Path})
|
|
tr.mu.Unlock()
|
|
if user != "" {
|
|
if u, p, ok := r.BasicAuth(); !ok || u != user || p != pass {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="test"`)
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
}
|
|
backend.ServeHTTP(w, r)
|
|
}))
|
|
t.Cleanup(tr.srv.Close)
|
|
tr.host = strings.TrimPrefix(tr.srv.URL, "http://")
|
|
return tr
|
|
}
|
|
|
|
// pushIndex stores a random multi-arch index as repo:tag and returns its
|
|
// digest. user and pass are used when the registry requires them.
|
|
func (tr *testRegistry) pushIndex(t *testing.T, repo, tag string, opts ...remote.Option) string {
|
|
t.Helper()
|
|
idx, err := random.Index(64, 1, 2)
|
|
require.NoError(t, err)
|
|
ref, err := name.ParseReference(tr.host + "/" + repo + ":" + tag)
|
|
require.NoError(t, err)
|
|
require.NoError(t, remote.WriteIndex(ref, idx, opts...))
|
|
d, err := idx.Digest()
|
|
require.NoError(t, err)
|
|
return d.String()
|
|
}
|
|
|
|
// manifestRequests returns the requests that touched a manifest.
|
|
func (tr *testRegistry) manifestRequests() []request {
|
|
tr.mu.Lock()
|
|
defer tr.mu.Unlock()
|
|
var out []request
|
|
for _, r := range tr.requests {
|
|
if strings.Contains(r.path, "/manifests/") {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// reset forgets the requests received so far.
|
|
func (tr *testRegistry) reset() {
|
|
tr.mu.Lock()
|
|
defer tr.mu.Unlock()
|
|
tr.requests = nil
|
|
}
|
|
|
|
// dockerConfig points DOCKER_CONFIG at a fresh directory holding config.json
|
|
// with the given content; "" leaves the directory empty.
|
|
func dockerConfig(t *testing.T, configJSON string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
if configJSON != "" {
|
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte(configJSON), 0o600))
|
|
}
|
|
t.Setenv("DOCKER_CONFIG", dir)
|
|
}
|
|
|
|
func TestDigestOfIndexIsIndexDigest(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
reg := newTestRegistry(t, "", "")
|
|
want := reg.pushIndex(t, "team/app", "1.2")
|
|
|
|
got, err := New("test").Digest(context.Background(), reg.host+"/team/app:1.2")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
func TestDigestIgnoresDigestSuffix(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
reg := newTestRegistry(t, "", "")
|
|
want := reg.pushIndex(t, "team/app", "1.2")
|
|
|
|
got, err := New("test").Digest(context.Background(), reg.host+"/team/app:1.2@sha256:"+strings.Repeat("0", 64))
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
func TestDigestUsesHeadOnly(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
reg := newTestRegistry(t, "", "")
|
|
reg.pushIndex(t, "team/app", "1.2")
|
|
reg.reset()
|
|
|
|
_, err := New("test").Digest(context.Background(), reg.host+"/team/app:1.2")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, []request{{http.MethodHead, "/v2/team/app/manifests/1.2"}}, reg.manifestRequests())
|
|
for _, r := range reg.requests {
|
|
if !strings.Contains(r.path, "/manifests/") {
|
|
assert.Equal(t, request{http.MethodGet, "/v2/"}, r, "the only other request is the API ping")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDigestSendsUserAgent(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
var ua string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ua = r.Header.Get("User-Agent")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
_, _ = New("package-updater-test/1").Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
|
|
|
|
assert.Contains(t, ua, "package-updater-test/1")
|
|
}
|
|
|
|
func TestCredentialsFromDockerConfig(t *testing.T) {
|
|
reg := newTestRegistry(t, "bot", "s3cret")
|
|
want := reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
|
auth := base64.StdEncoding.EncodeToString([]byte("bot:s3cret"))
|
|
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
|
|
|
got, err := New("test").Digest(context.Background(), reg.host+"/team/app:1.2")
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got)
|
|
}
|
|
|
|
func TestWrongCredentialsFail(t *testing.T) {
|
|
reg := newTestRegistry(t, "bot", "s3cret")
|
|
reg.pushIndex(t, "team/app", "1.2", remote.WithAuth(&authn.Basic{Username: "bot", Password: "s3cret"}))
|
|
auth := base64.StdEncoding.EncodeToString([]byte("bot:wrong"))
|
|
dockerConfig(t, `{"auths":{"`+reg.host+`":{"auth":"`+auth+`"}}}`)
|
|
|
|
_, err := New("test").Digest(context.Background(), reg.host+"/team/app:1.2")
|
|
|
|
require.Error(t, err)
|
|
assert.NotContains(t, err.Error(), "wrong", "credentials do not leak into errors")
|
|
assert.NotContains(t, err.Error(), auth)
|
|
}
|
|
|
|
func TestAnonymousWithoutConfig(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
open := newTestRegistry(t, "", "")
|
|
want := open.pushIndex(t, "team/app", "1.2")
|
|
protected := newTestRegistry(t, "bot", "s3cret")
|
|
|
|
got, err := New("test").Digest(context.Background(), open.host+"/team/app:1.2")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, want, got, "anonymous access works without a config file")
|
|
|
|
_, err = New("test").Digest(context.Background(), protected.host+"/team/app:1.2")
|
|
assert.Error(t, err, "a registry that needs credentials fails without them")
|
|
}
|
|
|
|
func TestUnknownTagIsAnError(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
reg := newTestRegistry(t, "", "")
|
|
reg.pushIndex(t, "team/app", "1.2")
|
|
|
|
_, err := New("test").Digest(context.Background(), reg.host+"/team/app:nope")
|
|
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestRefWithoutTagIsAnError(t *testing.T) {
|
|
_, err := New("test").Digest(context.Background(), "team/app@sha256:"+strings.Repeat("a", 64))
|
|
|
|
assert.ErrorContains(t, err, "has no tag")
|
|
}
|
|
|
|
// A config.json whose credsStore names a helper that is not installed makes
|
|
// the lookup fail for every registry, including ones that would work
|
|
// anonymously. The master container has no credential helpers, so only
|
|
// inline "auths" entries are supported there.
|
|
func TestCredsStoreWithoutHelperFailsTheLookup(t *testing.T) {
|
|
reg := newTestRegistry(t, "", "")
|
|
reg.pushIndex(t, "team/app", "1.2")
|
|
dockerConfig(t, `{"credsStore":"no-such-helper-for-package-updater-tests"}`)
|
|
|
|
_, err := New("test").Digest(context.Background(), reg.host+"/team/app:1.2")
|
|
|
|
assert.ErrorContains(t, err, "error getting credentials")
|
|
assert.ErrorContains(t, err, "docker-credential-no-such-helper-for-package-updater-tests")
|
|
}
|
|
|
|
// A registry that leaves out Docker-Content-Digest must not make the client
|
|
// retry with a manifest download, which Docker Hub counts as a pull.
|
|
func TestMissingDigestHeaderDoesNotFallBackToDownload(t *testing.T) {
|
|
dockerConfig(t, "")
|
|
var mu sync.Mutex
|
|
var methods []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if !strings.Contains(r.URL.Path, "/manifests/") {
|
|
return
|
|
}
|
|
mu.Lock()
|
|
methods = append(methods, r.Method)
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/vnd.oci.image.index.v1+json")
|
|
w.Header().Set("Content-Length", "10")
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
_, err := New("test").Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1")
|
|
|
|
assert.Error(t, err)
|
|
assert.Equal(t, []string{http.MethodHead}, methods)
|
|
}
|