package registry import ( "context" "encoding/base64" "errors" "fmt" "io" "log" "log/slog" "net" "net/http" "net/http/httptest" "net/url" "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" ) var discardLog = slog.New(slog.NewTextHandler(io.Discard, nil)) func newClient() *Client { return New("test", discardLog) } // 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 := newClient().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 := newClient().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 := newClient().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", discardLog).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 := newClient().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 := newClient().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 := newClient().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 = newClient().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 := newClient().Digest(context.Background(), reg.host+"/team/app:nope") assert.Error(t, err) } func TestRefWithoutTagIsAnError(t *testing.T) { _, err := newClient().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 := newClient().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 := newClient().Digest(context.Background(), strings.TrimPrefix(srv.URL, "http://")+"/a/b:1") assert.Error(t, err) assert.Equal(t, []string{http.MethodHead}, methods) } // ggcr copies up to 64 KiB of the registry's response into its errors. The // registry host comes from an image name, so that text must not reach the // stored poll error. func TestRegistryErrorBodyDoesNotLeak(t *testing.T) { dockerConfig(t, "") tests := []struct { name string body string want string }{ {"raw body", "internal-secret-body", "registry answered 400 Bad Request"}, {"structured errors", `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"internal-secret-body","detail":"internal-secret-body"},{"code":"internal-secret-body"}]}`, "registry answered 400 Bad Request (MANIFEST_UNKNOWN)"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // HEAD answers carry no body, so the leak is in the GET /v2/ ping. w.WriteHeader(http.StatusBadRequest) _, _ = w.Write([]byte(tt.body)) })) t.Cleanup(srv.Close) host := strings.TrimPrefix(srv.URL, "http://") _, err := newClient().Digest(context.Background(), host+"/a/b:1") require.Error(t, err) assert.NotContains(t, err.Error(), "internal-secret-body") assert.Equal(t, "lookup "+host+"/a/b:1: "+tt.want, strings.Replace(err.Error(), "index.docker.io/", "", 1)) }) } } // ggcr puts the token server's answer into its error when the answer has no // token, and the token server is named by the registry's challenge. func TestNonRegistryErrorsAreFixedText(t *testing.T) { dockerConfig(t, "") tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"detail":"internal-secret-body"}`)) })) t.Cleanup(tokenSrv.Close) reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("WWW-Authenticate", `Bearer realm="`+tokenSrv.URL+`/internal-secret-realm",service="internal-secret-service"`) w.WriteHeader(http.StatusUnauthorized) })) t.Cleanup(reg.Close) host := strings.TrimPrefix(reg.URL, "http://") _, err := newClient().Digest(context.Background(), host+"/a/b:1") require.Error(t, err) assert.NotContains(t, err.Error(), "internal-secret") assert.Equal(t, "lookup "+host+"/a/b:1: lookup failed", err.Error()) } func TestClassify(t *testing.T) { refused := &url.Error{Op: "Get", URL: "http://internal-secret-host/v2/", Err: errors.New("connection refused")} tests := []struct { name string err error want string }{ {"deadline", fmt.Errorf("x: %w", context.DeadlineExceeded), "timeout"}, {"canceled", context.Canceled, "timeout"}, {"url error", refused, "cannot reach registry"}, {"net error", &net.DNSError{Name: "internal-secret-host"}, "cannot reach registry"}, {"deadline inside url error", &url.Error{Op: "Get", URL: "http://x", Err: context.DeadlineExceeded}, "timeout"}, {"anything else", errors.New("internal-secret"), "lookup failed"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, classify(tt.err)) }) } }