From 6a998a9eb3b11e1c698adae6d07abcb9bdda25e8 Mon Sep 17 00:00:00 2001 From: Franze M Date: Thu, 19 Mar 2026 07:30:21 -0300 Subject: [PATCH 1/6] Support pulling private images via Docker credentials Image pulls always used anonymous access, causing failures for private registries. Users had to work around this manually. Add registry_auth.go which reads ~/.docker/config.json and resolves credentials via credential helpers, credential stores, or inline base64 auth entries. Pass the resolved token to ImagePull via RegistryAuth. Falls back to anonymous access on any error or missing credentials. --- internal/docker/application.go | 3 +- internal/docker/registry_auth.go | 122 ++++++++++++++ internal/docker/registry_auth_test.go | 234 ++++++++++++++++++++++++++ 3 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 internal/docker/registry_auth.go create mode 100644 internal/docker/registry_auth_test.go diff --git a/internal/docker/application.go b/internal/docker/application.go index 5c02ece..50162c4 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -274,7 +274,8 @@ func (a *Application) saveOperationResult(ctx context.Context, record func(*Stat } func (a *Application) pullImage(ctx context.Context, progress DeployProgressCallback) (bool, error) { - reader, err := a.namespace.client.ImagePull(ctx, a.Settings.Image, image.PullOptions{}) + opts := image.PullOptions{RegistryAuth: registryAuthFor(a.Settings.Image)} + reader, err := a.namespace.client.ImagePull(ctx, a.Settings.Image, opts) if err != nil { return false, fmt.Errorf("%w: %w", ErrPullFailed, err) } diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go new file mode 100644 index 0000000..f79d4ce --- /dev/null +++ b/internal/docker/registry_auth.go @@ -0,0 +1,122 @@ +package docker + +import ( + "encoding/base64" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/distribution/reference" +) + +type dockerConfigFile struct { + Auths map[string]dockerAuthEntry `json:"auths"` + CredHelpers map[string]string `json:"credHelpers"` + CredsStore string `json:"credsStore"` +} + +type dockerAuthEntry struct { + Auth string `json:"auth"` +} + +type credHelperResponse struct { + Username string `json:"Username"` + Secret string `json:"Secret"` +} + +type encodedAuthConfig struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +// registryAuthFor returns a base64-encoded JSON auth string for the registry +// that hosts the given image, suitable for use in image.PullOptions.RegistryAuth. +// Returns "" on any error or missing credentials, falling back to anonymous access. +func registryAuthFor(imageName string) string { + host := registryHostFor(imageName) + if host == "" { + return "" + } + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + + cfg, err := loadDockerConfig(filepath.Join(home, ".docker", "config.json")) + if err != nil { + return "" + } + + if helper, ok := cfg.CredHelpers[host]; ok { + return authFromCredHelper(helper, host) + } + + if cfg.CredsStore != "" { + return authFromCredHelper(cfg.CredsStore, host) + } + + if entry, ok := cfg.Auths[host]; ok && entry.Auth != "" { + return authFromInlineEntry(entry.Auth) + } + + return "" +} + +// Helpers + +func registryHostFor(imageName string) string { + named, err := reference.ParseNormalizedNamed(imageName) + if err != nil { + return "" + } + return reference.Domain(named) +} + +func loadDockerConfig(configPath string) (*dockerConfigFile, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err + } + var cfg dockerConfigFile + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func authFromCredHelper(helper, serverURL string) string { + cmd := exec.Command("docker-credential-"+helper, "get") + cmd.Stdin = strings.NewReader(serverURL) + out, err := cmd.Output() + if err != nil { + return "" + } + var resp credHelperResponse + if err := json.Unmarshal(out, &resp); err != nil { + return "" + } + return encodeAuthConfig(resp.Username, resp.Secret) +} + +func authFromInlineEntry(encodedAuth string) string { + decoded, err := base64.StdEncoding.DecodeString(encodedAuth) + if err != nil { + return "" + } + username, password, found := strings.Cut(string(decoded), ":") + if !found { + return "" + } + return encodeAuthConfig(username, password) +} + +func encodeAuthConfig(username, password string) string { + data, err := json.Marshal(encodedAuthConfig{Username: username, Password: password}) + if err != nil { + return "" + } + return base64.URLEncoding.EncodeToString(data) +} diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go new file mode 100644 index 0000000..e071a26 --- /dev/null +++ b/internal/docker/registry_auth_test.go @@ -0,0 +1,234 @@ +package docker + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRegistryHostFor(t *testing.T) { + assert.Equal(t, "ghcr.io", registryHostFor("ghcr.io/basecamp/once:main")) + assert.Equal(t, "docker.io", registryHostFor("ubuntu")) + assert.Equal(t, "", registryHostFor(":::bad")) +} + +func TestAuthFromInlineEntry(t *testing.T) { + t.Run("valid base64 with colon separator", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("myuser:mypass")) + token := authFromInlineEntry(encoded) + require.NotEmpty(t, token) + + ac := decodeAuthToken(t, token) + assert.Equal(t, "myuser", ac.Username) + assert.Equal(t, "mypass", ac.Password) + }) + + t.Run("invalid base64", func(t *testing.T) { + assert.Equal(t, "", authFromInlineEntry("not-valid-base64!!!")) + }) + + t.Run("base64 with no colon separator", func(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("nocolon")) + assert.Equal(t, "", authFromInlineEntry(encoded)) + }) +} + +func TestEncodeAuthConfig(t *testing.T) { + token := encodeAuthConfig("alice", "secret") + require.NotEmpty(t, token) + + decoded := decodeAuthToken(t, token) + assert.Equal(t, "alice", decoded.Username) + assert.Equal(t, "secret", decoded.Password) +} + +func TestLoadDockerConfig(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + dir := t.TempDir() + cfg := &dockerConfigFile{ + Auths: map[string]dockerAuthEntry{"ghcr.io": {Auth: "dXNlcjpwYXNz"}}, + CredHelpers: map[string]string{"gcr.io": "gcr"}, + CredsStore: "osxkeychain", + } + writeDockerConfig(t, dir, cfg) + + loaded, err := loadDockerConfig(filepath.Join(dir, ".docker", "config.json")) + require.NoError(t, err) + require.NotNil(t, loaded) + assert.Equal(t, "dXNlcjpwYXNz", loaded.Auths["ghcr.io"].Auth) + assert.Equal(t, "gcr", loaded.CredHelpers["gcr.io"]) + assert.Equal(t, "osxkeychain", loaded.CredsStore) + }) + + t.Run("file not found", func(t *testing.T) { + _, err := loadDockerConfig("/nonexistent/path/config.json") + assert.Error(t, err) + }) + + t.Run("invalid JSON", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + require.NoError(t, os.WriteFile(path, []byte("{not json}"), 0600)) + + _, err := loadDockerConfig(path) + assert.Error(t, err) + }) +} + +func TestAuthFromCredHelper(t *testing.T) { + t.Run("valid helper returns JSON", func(t *testing.T) { + installFakeCredHelper(t, "test-valid", credHelperScript(credHelperResponse{Username: "bob", Secret: "topsecret"})) + + token := authFromCredHelper("test-valid", "ghcr.io") + require.NotEmpty(t, token) + + ac := decodeAuthToken(t, token) + assert.Equal(t, "bob", ac.Username) + assert.Equal(t, "topsecret", ac.Password) + }) + + t.Run("helper exits non-zero", func(t *testing.T) { + installFakeCredHelper(t, "test-fail", "#!/bin/sh\nexit 1\n") + assert.Equal(t, "", authFromCredHelper("test-fail", "ghcr.io")) + }) + + t.Run("helper returns malformed JSON", func(t *testing.T) { + installFakeCredHelper(t, "test-badjson", "#!/bin/sh\necho 'not json'\n") + assert.Equal(t, "", authFromCredHelper("test-badjson", "ghcr.io")) + }) + + t.Run("helper binary absent", func(t *testing.T) { + assert.Equal(t, "", authFromCredHelper("nonexistent-helper-xyz", "ghcr.io")) + }) +} + +func TestRegistryAuthFor(t *testing.T) { + t.Run("invalid image string", func(t *testing.T) { + fakeHome(t) + assert.Equal(t, "", registryAuthFor(":::bad")) + }) + + t.Run("no docker config in fake HOME", func(t *testing.T) { + fakeHome(t) + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) + + t.Run("config has credHelpers for host", func(t *testing.T) { + home := fakeHome(t) + installFakeCredHelper(t, "myhelper", credHelperScript(credHelperResponse{Username: "helper-user", Secret: "helper-pass"})) + writeDockerConfig(t, home, &dockerConfigFile{ + CredHelpers: map[string]string{"ghcr.io": "myhelper"}, + }) + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "helper-user", ac.Username) + assert.Equal(t, "helper-pass", ac.Password) + }) + + t.Run("config has credsStore only", func(t *testing.T) { + home := fakeHome(t) + installFakeCredHelper(t, "mystore", credHelperScript(credHelperResponse{Username: "store-user", Secret: "store-pass"})) + writeDockerConfig(t, home, &dockerConfigFile{CredsStore: "mystore"}) + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "store-user", ac.Username) + assert.Equal(t, "store-pass", ac.Password) + }) + + t.Run("credHelpers wins over credsStore", func(t *testing.T) { + home := fakeHome(t) + installFakeCredHelper(t, "specific-helper", credHelperScript(credHelperResponse{Username: "helper-user", Secret: "helper-pass"})) + installFakeCredHelper(t, "global-store", credHelperScript(credHelperResponse{Username: "store-user", Secret: "store-pass"})) + writeDockerConfig(t, home, &dockerConfigFile{ + CredHelpers: map[string]string{"ghcr.io": "specific-helper"}, + CredsStore: "global-store", + }) + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "helper-user", ac.Username) + assert.Equal(t, "helper-pass", ac.Password) + }) + + t.Run("config has inline auths entry", func(t *testing.T) { + home := fakeHome(t) + encoded := base64.StdEncoding.EncodeToString([]byte("inline-user:inline-pass")) + writeDockerConfig(t, home, &dockerConfigFile{ + Auths: map[string]dockerAuthEntry{"ghcr.io": {Auth: encoded}}, + }) + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "inline-user", ac.Username) + assert.Equal(t, "inline-pass", ac.Password) + }) + + t.Run("credHelpers entry but helper fails - no fallback", func(t *testing.T) { + home := fakeHome(t) + installFakeCredHelper(t, "failing-helper", "#!/bin/sh\nexit 1\n") + writeDockerConfig(t, home, &dockerConfigFile{ + CredHelpers: map[string]string{"ghcr.io": "failing-helper"}, + }) + + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) + + t.Run("no matching entry for host", func(t *testing.T) { + home := fakeHome(t) + writeDockerConfig(t, home, &dockerConfigFile{ + Auths: map[string]dockerAuthEntry{"docker.io": {Auth: "dXNlcjpwYXNz"}}, + }) + + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) +} + +// Helpers + +func fakeHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + return dir +} + +func writeDockerConfig(t *testing.T, dir string, cfg *dockerConfigFile) { + t.Helper() + dockerDir := filepath.Join(dir, ".docker") + require.NoError(t, os.MkdirAll(dockerDir, 0700)) + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dockerDir, "config.json"), data, 0600)) +} + +func installFakeCredHelper(t *testing.T, name, script string) { + t.Helper() + dir := t.TempDir() + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-"+name), []byte(script), 0755)) +} + +func credHelperScript(response credHelperResponse) string { + data, _ := json.Marshal(response) + return fmt.Sprintf("#!/bin/sh\necho '%s'\n", data) +} + +func decodeAuthToken(t *testing.T, token string) encodedAuthConfig { + t.Helper() + data, err := base64.URLEncoding.DecodeString(token) + require.NoError(t, err) + var ac encodedAuthConfig + require.NoError(t, json.Unmarshal(data, &ac)) + return ac +} From 47f4d081236adad68b60546b92ea156442328f34 Mon Sep 17 00:00:00 2001 From: Franze M Date: Thu, 19 Mar 2026 07:39:27 -0300 Subject: [PATCH 2/6] Honor DOCKER_CONFIG env var when locating config.json Credential resolution always read ~/.docker/config.json, ignoring the DOCKER_CONFIG environment variable. Users with a non-default Docker config directory would have Once fall back to anonymous pulls even though docker pull worked correctly for them. Extract dockerConfigPath() which checks DOCKER_CONFIG first and falls back to ~/.docker/config.json, matching Docker's own resolution behaviour. This does not support the legacy ~/.dockercfg format. Supporting it would require additional fallback logic but covers a very small number of users on modern Docker installations. --- internal/docker/registry_auth.go | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index f79d4ce..5b22131 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -40,12 +40,7 @@ func registryAuthFor(imageName string) string { return "" } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - - cfg, err := loadDockerConfig(filepath.Join(home, ".docker", "config.json")) + cfg, err := loadDockerConfig(dockerConfigPath()) if err != nil { return "" } @@ -67,6 +62,17 @@ func registryAuthFor(imageName string) string { // Helpers +func dockerConfigPath() string { + if dir := os.Getenv("DOCKER_CONFIG"); dir != "" { + return filepath.Join(dir, "config.json") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".docker", "config.json") +} + func registryHostFor(imageName string) string { named, err := reference.ParseNormalizedNamed(imageName) if err != nil { From ddf9466047a48ddb61c3892e1cdd0bf7020b808f Mon Sep 17 00:00:00 2001 From: Franze M Date: Thu, 19 Mar 2026 07:45:13 -0300 Subject: [PATCH 3/6] Handle URL-style keys in Docker config auths lookup Direct lookup of cfg.Auths[host] missed entries where docker login stored the key as a full URL (e.g. https://index.docker.io/v1/ instead of docker.io). Affected users would silently fall back to anonymous pulls despite having valid credentials configured. Add authEntryFor which tries an exact match first, then falls back to parsing URL-style keys with url.Parse and comparing the extracted host via canonicalHost, which maps the known Docker Hub aliases to docker.io. This does not handle every possible key format Docker has used historically (e.g. registry-1.docker.io without a scheme). Adding more aliases to canonicalHost is straightforward if other cases emerge. --- internal/docker/registry_auth.go | 28 ++++++++++++++++++++++++++- internal/docker/registry_auth_test.go | 24 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index 5b22131..aab7216 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -3,6 +3,7 @@ package docker import ( "encoding/base64" "encoding/json" + "net/url" "os" "os/exec" "path/filepath" @@ -53,7 +54,7 @@ func registryAuthFor(imageName string) string { return authFromCredHelper(cfg.CredsStore, host) } - if entry, ok := cfg.Auths[host]; ok && entry.Auth != "" { + if entry, ok := authEntryFor(cfg.Auths, host); ok && entry.Auth != "" { return authFromInlineEntry(entry.Auth) } @@ -93,6 +94,31 @@ func loadDockerConfig(configPath string) (*dockerConfigFile, error) { return &cfg, nil } +// authEntryFor looks up the auth entry for host in auths, handling keys stored +// as full URLs (e.g. https://index.docker.io/v1/) in addition to bare hostnames. +func authEntryFor(auths map[string]dockerAuthEntry, host string) (dockerAuthEntry, bool) { + if entry, ok := auths[host]; ok { + return entry, true + } + for key, entry := range auths { + if u, err := url.Parse(key); err == nil && u.Host != "" { + if canonicalHost(u.Host) == host { + return entry, true + } + } + } + return dockerAuthEntry{}, false +} + +func canonicalHost(host string) string { + switch strings.ToLower(host) { + case "index.docker.io", "registry-1.docker.io": + return "docker.io" + default: + return strings.ToLower(host) + } +} + func authFromCredHelper(helper, serverURL string) string { cmd := exec.Command("docker-credential-"+helper, "get") cmd.Stdin = strings.NewReader(serverURL) diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index e071a26..272c707 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -80,6 +80,30 @@ func TestLoadDockerConfig(t *testing.T) { }) } +func TestAuthEntryFor(t *testing.T) { + encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) + auths := map[string]dockerAuthEntry{ + "ghcr.io": {Auth: encoded}, + "https://index.docker.io/v1/": {Auth: encoded}, + "https://registry.example.com/": {Auth: encoded}, + } + + entry, ok := authEntryFor(auths, "ghcr.io") + assert.True(t, ok) + assert.Equal(t, encoded, entry.Auth) + + entry, ok = authEntryFor(auths, "docker.io") + assert.True(t, ok, "should resolve https://index.docker.io/v1/ to docker.io") + assert.Equal(t, encoded, entry.Auth) + + entry, ok = authEntryFor(auths, "registry.example.com") + assert.True(t, ok, "should strip scheme and trailing slash from URL keys") + assert.Equal(t, encoded, entry.Auth) + + _, ok = authEntryFor(auths, "notfound.io") + assert.False(t, ok) +} + func TestAuthFromCredHelper(t *testing.T) { t.Run("valid helper returns JSON", func(t *testing.T) { installFakeCredHelper(t, "test-valid", credHelperScript(credHelperResponse{Username: "bob", Secret: "topsecret"})) From 32cdf8eda8254a010b07eb603eeae6c713f06af1 Mon Sep 17 00:00:00 2001 From: Franze M Date: Thu, 19 Mar 2026 07:52:09 -0300 Subject: [PATCH 4/6] Pass correct server URL to credential helpers for Docker Hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credential helpers were called with the bare host (e.g. docker.io), but Docker Hub stores credentials under https://index.docker.io/v1/ — the URL docker login uses. Helpers looking up docker.io would find nothing and fall back to anonymous pulls. Add credHelperServerURL which maps docker.io to the canonical Docker Hub URL and returns the bare host unchanged for all other registries. Other registries could also have legacy URL-keyed entries in their helpers. Those are rare in practice; adding more mappings to credHelperServerURL is straightforward if cases emerge. --- internal/docker/registry_auth.go | 14 ++++++++++++-- internal/docker/registry_auth_test.go | 6 ++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index aab7216..7893596 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -47,11 +47,11 @@ func registryAuthFor(imageName string) string { } if helper, ok := cfg.CredHelpers[host]; ok { - return authFromCredHelper(helper, host) + return authFromCredHelper(helper, credHelperServerURL(host)) } if cfg.CredsStore != "" { - return authFromCredHelper(cfg.CredsStore, host) + return authFromCredHelper(cfg.CredsStore, credHelperServerURL(host)) } if entry, ok := authEntryFor(cfg.Auths, host); ok && entry.Auth != "" { @@ -110,6 +110,16 @@ func authEntryFor(auths map[string]dockerAuthEntry, host string) (dockerAuthEntr return dockerAuthEntry{}, false } +// credHelperServerURL returns the server URL to pass to a credential helper +// for the given host. Docker Hub requires the full legacy URL that docker login +// uses; all other registries use the bare host. +func credHelperServerURL(host string) string { + if host == "docker.io" { + return "https://index.docker.io/v1/" + } + return host +} + func canonicalHost(host string) string { switch strings.ToLower(host) { case "index.docker.io", "registry-1.docker.io": diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index 272c707..7013fb8 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -80,6 +80,12 @@ func TestLoadDockerConfig(t *testing.T) { }) } +func TestCredHelperServerURL(t *testing.T) { + assert.Equal(t, "https://index.docker.io/v1/", credHelperServerURL("docker.io")) + assert.Equal(t, "ghcr.io", credHelperServerURL("ghcr.io")) + assert.Equal(t, "gcr.io", credHelperServerURL("gcr.io")) +} + func TestAuthEntryFor(t *testing.T) { encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) auths := map[string]dockerAuthEntry{ From 9d3b3b4f5170c99f6f7559b7495c64cccde62d6b Mon Sep 17 00:00:00 2001 From: Franze M Date: Thu, 19 Mar 2026 08:04:08 -0300 Subject: [PATCH 5/6] Add tests for DOCKER_CONFIG env var and malformed config fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behaviours introduced in this branch had no end-to-end test coverage: reading config from a DOCKER_CONFIG-specified directory, and falling back to anonymous access when config.json exists but contains invalid JSON. Add subtests in TestRegistryAuthFor for both cases. The DOCKER_CONFIG test also revealed that config.json must be written directly into the directory DOCKER_CONFIG points to, not in a .docker/ subdirectory — which is how Docker itself resolves the path. --- internal/docker/registry_auth_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index 7013fb8..027af74 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -148,6 +148,33 @@ func TestRegistryAuthFor(t *testing.T) { assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) }) + t.Run("reads config from DOCKER_CONFIG when set", func(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + encoded := base64.StdEncoding.EncodeToString([]byte("docker-config-user:docker-config-pass")) + cfg := &dockerConfigFile{ + Auths: map[string]dockerAuthEntry{"ghcr.io": {Auth: encoded}}, + } + data, err := json.Marshal(cfg) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)) + + token := registryAuthFor("ghcr.io/basecamp/once:main") + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "docker-config-user", ac.Username) + assert.Equal(t, "docker-config-pass", ac.Password) + }) + + t.Run("malformed config.json falls back to anonymous", func(t *testing.T) { + home := fakeHome(t) + dockerDir := filepath.Join(home, ".docker") + require.NoError(t, os.MkdirAll(dockerDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(dockerDir, "config.json"), []byte("{not json}"), 0600)) + + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + }) + t.Run("config has credHelpers for host", func(t *testing.T) { home := fakeHome(t) installFakeCredHelper(t, "myhelper", credHelperScript(credHelperResponse{Username: "helper-user", Secret: "helper-pass"})) From a4cfc251c15b331a0c1e1b8dc870a0a2172a3b42 Mon Sep 17 00:00:00 2001 From: Franze M Date: Mon, 23 Mar 2026 21:41:47 -0300 Subject: [PATCH 6/6] Replace hand-rolled registry auth with go-containerregistry The registry_auth.go file manually implemented Docker config parsing, credential helper execution (credHelpers, credsStore), inline auth decoding, and Docker Hub hostname normalization. This was ~165 lines of bespoke logic that needed to stay in sync with Docker's own behaviour. Replace the implementation with authn.DefaultKeychain from github.com/google/go-containerregistry/pkg/authn, which provides the same credential resolution in a well-maintained library. The file shrinks to ~30 lines. name.ParseReference replaces the distribution/reference call for parsing image names. Tests are updated to use DOCKER_CONFIG for reliable isolation (avoiding go-homedir's startup caching of HOME). --- go.mod | 13 +- go.sum | 31 +-- internal/docker/registry_auth.go | 146 +------------- internal/docker/registry_auth_test.go | 263 ++++++++------------------ 4 files changed, 113 insertions(+), 340 deletions(-) diff --git a/go.mod b/go.mod index 5051adf..e7e44ec 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/distribution/reference v0.6.0 github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 + github.com/google/go-containerregistry v0.21.3 github.com/lucasb-eyer/go-colorful v1.3.0 github.com/mattn/go-runewidth v0.0.20 github.com/prometheus/client_model v0.6.2 @@ -25,7 +26,7 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/Microsoft/go-winio v0.4.21 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect @@ -37,6 +38,8 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect + github.com/docker/cli v29.3.0+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect @@ -46,6 +49,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kamstrup/intmap v0.5.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect @@ -58,7 +62,8 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/spf13/pflag v1.0.9 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -70,8 +75,8 @@ require ( go.opentelemetry.io/otel/metric v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2ffb59a..959c08a 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro= -github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/axiomhq/hyperloglog v0.2.6 h1:sRhvvF3RIXWQgAXaTphLp4yJiX4S0IN3MWTaAgZoRJw= @@ -51,8 +51,12 @@ github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mT github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb2F+V9SVvk= +github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -71,6 +75,8 @@ github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiU github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.3 h1:Xr+yt3VvwOOn/5nJzd7UoOhwPGiPkYW0zWDLLUXqAi4= +github.com/google/go-containerregistry v0.21.3/go.mod h1:D5ZrJF1e6dMzvInpBPuMCX0FxURz7GLq2rV3Us9aPkc= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= @@ -89,6 +95,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -124,14 +132,13 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= @@ -169,15 +176,13 @@ golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index 7893596..11c418e 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -3,160 +3,28 @@ package docker import ( "encoding/base64" "encoding/json" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "github.com/distribution/reference" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" ) -type dockerConfigFile struct { - Auths map[string]dockerAuthEntry `json:"auths"` - CredHelpers map[string]string `json:"credHelpers"` - CredsStore string `json:"credsStore"` -} - -type dockerAuthEntry struct { - Auth string `json:"auth"` -} - -type credHelperResponse struct { - Username string `json:"Username"` - Secret string `json:"Secret"` -} - -type encodedAuthConfig struct { - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` -} - // registryAuthFor returns a base64-encoded JSON auth string for the registry // that hosts the given image, suitable for use in image.PullOptions.RegistryAuth. // Returns "" on any error or missing credentials, falling back to anonymous access. func registryAuthFor(imageName string) string { - host := registryHostFor(imageName) - if host == "" { - return "" - } - - cfg, err := loadDockerConfig(dockerConfigPath()) - if err != nil { - return "" - } - - if helper, ok := cfg.CredHelpers[host]; ok { - return authFromCredHelper(helper, credHelperServerURL(host)) - } - - if cfg.CredsStore != "" { - return authFromCredHelper(cfg.CredsStore, credHelperServerURL(host)) - } - - if entry, ok := authEntryFor(cfg.Auths, host); ok && entry.Auth != "" { - return authFromInlineEntry(entry.Auth) - } - - return "" -} - -// Helpers - -func dockerConfigPath() string { - if dir := os.Getenv("DOCKER_CONFIG"); dir != "" { - return filepath.Join(dir, "config.json") - } - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(home, ".docker", "config.json") -} - -func registryHostFor(imageName string) string { - named, err := reference.ParseNormalizedNamed(imageName) - if err != nil { - return "" - } - return reference.Domain(named) -} - -func loadDockerConfig(configPath string) (*dockerConfigFile, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, err - } - var cfg dockerConfigFile - if err := json.Unmarshal(data, &cfg); err != nil { - return nil, err - } - return &cfg, nil -} - -// authEntryFor looks up the auth entry for host in auths, handling keys stored -// as full URLs (e.g. https://index.docker.io/v1/) in addition to bare hostnames. -func authEntryFor(auths map[string]dockerAuthEntry, host string) (dockerAuthEntry, bool) { - if entry, ok := auths[host]; ok { - return entry, true - } - for key, entry := range auths { - if u, err := url.Parse(key); err == nil && u.Host != "" { - if canonicalHost(u.Host) == host { - return entry, true - } - } - } - return dockerAuthEntry{}, false -} - -// credHelperServerURL returns the server URL to pass to a credential helper -// for the given host. Docker Hub requires the full legacy URL that docker login -// uses; all other registries use the bare host. -func credHelperServerURL(host string) string { - if host == "docker.io" { - return "https://index.docker.io/v1/" - } - return host -} - -func canonicalHost(host string) string { - switch strings.ToLower(host) { - case "index.docker.io", "registry-1.docker.io": - return "docker.io" - default: - return strings.ToLower(host) - } -} - -func authFromCredHelper(helper, serverURL string) string { - cmd := exec.Command("docker-credential-"+helper, "get") - cmd.Stdin = strings.NewReader(serverURL) - out, err := cmd.Output() + ref, err := name.ParseReference(imageName) if err != nil { return "" } - var resp credHelperResponse - if err := json.Unmarshal(out, &resp); err != nil { + authenticator, err := authn.DefaultKeychain.Resolve(ref.Context()) + if err != nil || authenticator == authn.Anonymous { return "" } - return encodeAuthConfig(resp.Username, resp.Secret) -} - -func authFromInlineEntry(encodedAuth string) string { - decoded, err := base64.StdEncoding.DecodeString(encodedAuth) + cfg, err := authenticator.Authorization() if err != nil { return "" } - username, password, found := strings.Cut(string(decoded), ":") - if !found { - return "" - } - return encodeAuthConfig(username, password) -} - -func encodeAuthConfig(username, password string) string { - data, err := json.Marshal(encodedAuthConfig{Username: username, Password: password}) + data, err := json.Marshal(cfg) if err != nil { return "" } diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index 027af74..65ef65a 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -12,175 +12,40 @@ import ( "github.com/stretchr/testify/require" ) -func TestRegistryHostFor(t *testing.T) { - assert.Equal(t, "ghcr.io", registryHostFor("ghcr.io/basecamp/once:main")) - assert.Equal(t, "docker.io", registryHostFor("ubuntu")) - assert.Equal(t, "", registryHostFor(":::bad")) -} - -func TestAuthFromInlineEntry(t *testing.T) { - t.Run("valid base64 with colon separator", func(t *testing.T) { - encoded := base64.StdEncoding.EncodeToString([]byte("myuser:mypass")) - token := authFromInlineEntry(encoded) - require.NotEmpty(t, token) - - ac := decodeAuthToken(t, token) - assert.Equal(t, "myuser", ac.Username) - assert.Equal(t, "mypass", ac.Password) - }) - - t.Run("invalid base64", func(t *testing.T) { - assert.Equal(t, "", authFromInlineEntry("not-valid-base64!!!")) - }) - - t.Run("base64 with no colon separator", func(t *testing.T) { - encoded := base64.StdEncoding.EncodeToString([]byte("nocolon")) - assert.Equal(t, "", authFromInlineEntry(encoded)) - }) -} - -func TestEncodeAuthConfig(t *testing.T) { - token := encodeAuthConfig("alice", "secret") - require.NotEmpty(t, token) - - decoded := decodeAuthToken(t, token) - assert.Equal(t, "alice", decoded.Username) - assert.Equal(t, "secret", decoded.Password) -} - -func TestLoadDockerConfig(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - dir := t.TempDir() - cfg := &dockerConfigFile{ - Auths: map[string]dockerAuthEntry{"ghcr.io": {Auth: "dXNlcjpwYXNz"}}, - CredHelpers: map[string]string{"gcr.io": "gcr"}, - CredsStore: "osxkeychain", - } - writeDockerConfig(t, dir, cfg) - - loaded, err := loadDockerConfig(filepath.Join(dir, ".docker", "config.json")) - require.NoError(t, err) - require.NotNil(t, loaded) - assert.Equal(t, "dXNlcjpwYXNz", loaded.Auths["ghcr.io"].Auth) - assert.Equal(t, "gcr", loaded.CredHelpers["gcr.io"]) - assert.Equal(t, "osxkeychain", loaded.CredsStore) - }) - - t.Run("file not found", func(t *testing.T) { - _, err := loadDockerConfig("/nonexistent/path/config.json") - assert.Error(t, err) - }) - - t.Run("invalid JSON", func(t *testing.T) { - path := filepath.Join(t.TempDir(), "config.json") - require.NoError(t, os.WriteFile(path, []byte("{not json}"), 0600)) - - _, err := loadDockerConfig(path) - assert.Error(t, err) - }) -} - -func TestCredHelperServerURL(t *testing.T) { - assert.Equal(t, "https://index.docker.io/v1/", credHelperServerURL("docker.io")) - assert.Equal(t, "ghcr.io", credHelperServerURL("ghcr.io")) - assert.Equal(t, "gcr.io", credHelperServerURL("gcr.io")) -} - -func TestAuthEntryFor(t *testing.T) { - encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) - auths := map[string]dockerAuthEntry{ - "ghcr.io": {Auth: encoded}, - "https://index.docker.io/v1/": {Auth: encoded}, - "https://registry.example.com/": {Auth: encoded}, - } - - entry, ok := authEntryFor(auths, "ghcr.io") - assert.True(t, ok) - assert.Equal(t, encoded, entry.Auth) - - entry, ok = authEntryFor(auths, "docker.io") - assert.True(t, ok, "should resolve https://index.docker.io/v1/ to docker.io") - assert.Equal(t, encoded, entry.Auth) - - entry, ok = authEntryFor(auths, "registry.example.com") - assert.True(t, ok, "should strip scheme and trailing slash from URL keys") - assert.Equal(t, encoded, entry.Auth) - - _, ok = authEntryFor(auths, "notfound.io") - assert.False(t, ok) -} - -func TestAuthFromCredHelper(t *testing.T) { - t.Run("valid helper returns JSON", func(t *testing.T) { - installFakeCredHelper(t, "test-valid", credHelperScript(credHelperResponse{Username: "bob", Secret: "topsecret"})) - - token := authFromCredHelper("test-valid", "ghcr.io") - require.NotEmpty(t, token) - - ac := decodeAuthToken(t, token) - assert.Equal(t, "bob", ac.Username) - assert.Equal(t, "topsecret", ac.Password) - }) - - t.Run("helper exits non-zero", func(t *testing.T) { - installFakeCredHelper(t, "test-fail", "#!/bin/sh\nexit 1\n") - assert.Equal(t, "", authFromCredHelper("test-fail", "ghcr.io")) - }) - - t.Run("helper returns malformed JSON", func(t *testing.T) { - installFakeCredHelper(t, "test-badjson", "#!/bin/sh\necho 'not json'\n") - assert.Equal(t, "", authFromCredHelper("test-badjson", "ghcr.io")) - }) - - t.Run("helper binary absent", func(t *testing.T) { - assert.Equal(t, "", authFromCredHelper("nonexistent-helper-xyz", "ghcr.io")) - }) -} - func TestRegistryAuthFor(t *testing.T) { t.Run("invalid image string", func(t *testing.T) { - fakeHome(t) + isolateDockerConfig(t) assert.Equal(t, "", registryAuthFor(":::bad")) }) - t.Run("no docker config in fake HOME", func(t *testing.T) { - fakeHome(t) + t.Run("no docker config present", func(t *testing.T) { + isolateDockerConfig(t) assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) }) - t.Run("reads config from DOCKER_CONFIG when set", func(t *testing.T) { - dir := t.TempDir() - t.Setenv("DOCKER_CONFIG", dir) - encoded := base64.StdEncoding.EncodeToString([]byte("docker-config-user:docker-config-pass")) - cfg := &dockerConfigFile{ - Auths: map[string]dockerAuthEntry{"ghcr.io": {Auth: encoded}}, - } - data, err := json.Marshal(cfg) - require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)) + t.Run("reads config from DOCKER_CONFIG directory", func(t *testing.T) { + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("myuser:mypass")) + writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") token := registryAuthFor("ghcr.io/basecamp/once:main") require.NotEmpty(t, token) ac := decodeAuthToken(t, token) - assert.Equal(t, "docker-config-user", ac.Username) - assert.Equal(t, "docker-config-pass", ac.Password) + assert.Equal(t, "myuser", ac.Username) + assert.Equal(t, "mypass", ac.Password) }) t.Run("malformed config.json falls back to anonymous", func(t *testing.T) { - home := fakeHome(t) - dockerDir := filepath.Join(home, ".docker") - require.NoError(t, os.MkdirAll(dockerDir, 0700)) - require.NoError(t, os.WriteFile(filepath.Join(dockerDir, "config.json"), []byte("{not json}"), 0600)) + dir := isolateDockerConfig(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{not json}"), 0600)) assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) }) t.Run("config has credHelpers for host", func(t *testing.T) { - home := fakeHome(t) - installFakeCredHelper(t, "myhelper", credHelperScript(credHelperResponse{Username: "helper-user", Secret: "helper-pass"})) - writeDockerConfig(t, home, &dockerConfigFile{ - CredHelpers: map[string]string{"ghcr.io": "myhelper"}, - }) + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "myhelper", credHelperScript("helper-user", "helper-pass")) + writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "myhelper"}, "") token := registryAuthFor("ghcr.io/basecamp/once:main") require.NotEmpty(t, token) @@ -190,9 +55,9 @@ func TestRegistryAuthFor(t *testing.T) { }) t.Run("config has credsStore only", func(t *testing.T) { - home := fakeHome(t) - installFakeCredHelper(t, "mystore", credHelperScript(credHelperResponse{Username: "store-user", Secret: "store-pass"})) - writeDockerConfig(t, home, &dockerConfigFile{CredsStore: "mystore"}) + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "mystore", credHelperScript("store-user", "store-pass")) + writeDockerConfig(t, dir, nil, nil, "mystore") token := registryAuthFor("ghcr.io/basecamp/once:main") require.NotEmpty(t, token) @@ -202,13 +67,20 @@ func TestRegistryAuthFor(t *testing.T) { }) t.Run("credHelpers wins over credsStore", func(t *testing.T) { - home := fakeHome(t) - installFakeCredHelper(t, "specific-helper", credHelperScript(credHelperResponse{Username: "helper-user", Secret: "helper-pass"})) - installFakeCredHelper(t, "global-store", credHelperScript(credHelperResponse{Username: "store-user", Secret: "store-pass"})) - writeDockerConfig(t, home, &dockerConfigFile{ - CredHelpers: map[string]string{"ghcr.io": "specific-helper"}, - CredsStore: "global-store", - }) + dir := isolateDockerConfig(t) + installFakeCredHelper(t, "specific-helper", credHelperScript("helper-user", "helper-pass")) + // Real credential stores are indexed by hostname, so they don't return + // credentials when given a full repo path like "ghcr.io/basecamp/once". + // Simulate that by outputting the "credentials not found" message (the + // docker-credential-helpers protocol for "no credentials for this server"). + installFakeCredHelper(t, "global-store", `#!/bin/sh +input=$(cat) +if echo "$input" | grep -q '/'; then + echo "credentials not found in native keychain"; exit 1 +fi +echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' +`) + writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "specific-helper"}, "global-store") token := registryAuthFor("ghcr.io/basecamp/once:main") require.NotEmpty(t, token) @@ -218,11 +90,9 @@ func TestRegistryAuthFor(t *testing.T) { }) t.Run("config has inline auths entry", func(t *testing.T) { - home := fakeHome(t) + dir := isolateDockerConfig(t) encoded := base64.StdEncoding.EncodeToString([]byte("inline-user:inline-pass")) - writeDockerConfig(t, home, &dockerConfigFile{ - Auths: map[string]dockerAuthEntry{"ghcr.io": {Auth: encoded}}, - }) + writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") token := registryAuthFor("ghcr.io/basecamp/once:main") require.NotEmpty(t, token) @@ -232,20 +102,17 @@ func TestRegistryAuthFor(t *testing.T) { }) t.Run("credHelpers entry but helper fails - no fallback", func(t *testing.T) { - home := fakeHome(t) + dir := isolateDockerConfig(t) installFakeCredHelper(t, "failing-helper", "#!/bin/sh\nexit 1\n") - writeDockerConfig(t, home, &dockerConfigFile{ - CredHelpers: map[string]string{"ghcr.io": "failing-helper"}, - }) + writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "failing-helper"}, "") assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) }) t.Run("no matching entry for host", func(t *testing.T) { - home := fakeHome(t) - writeDockerConfig(t, home, &dockerConfigFile{ - Auths: map[string]dockerAuthEntry{"docker.io": {Auth: "dXNlcjpwYXNz"}}, - }) + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) + writeDockerConfig(t, dir, map[string]string{"docker.io": encoded}, nil, "") assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) }) @@ -253,39 +120,67 @@ func TestRegistryAuthFor(t *testing.T) { // Helpers -func fakeHome(t *testing.T) string { +// isolateDockerConfig sets DOCKER_CONFIG to a fresh temp dir so tests don't +// touch the real Docker config. Returns the temp dir path. +func isolateDockerConfig(t *testing.T) string { t.Helper() dir := t.TempDir() - t.Setenv("HOME", dir) + t.Setenv("DOCKER_CONFIG", dir) return dir } -func writeDockerConfig(t *testing.T, dir string, cfg *dockerConfigFile) { +// writeDockerConfig writes a Docker config.json into dir (which should be the +// value of $DOCKER_CONFIG). auths maps registry hostnames to base64 auth strings. +func writeDockerConfig(t *testing.T, dir string, auths map[string]string, credHelpers map[string]string, credsStore string) { t.Helper() - dockerDir := filepath.Join(dir, ".docker") - require.NoError(t, os.MkdirAll(dockerDir, 0700)) + type authEntry struct { + Auth string `json:"auth"` + } + cfg := struct { + Auths map[string]authEntry `json:"auths,omitempty"` + CredHelpers map[string]string `json:"credHelpers,omitempty"` + CredsStore string `json:"credsStore,omitempty"` + }{ + CredHelpers: credHelpers, + CredsStore: credsStore, + } + if len(auths) > 0 { + cfg.Auths = make(map[string]authEntry, len(auths)) + for k, v := range auths { + cfg.Auths[k] = authEntry{Auth: v} + } + } data, err := json.Marshal(cfg) require.NoError(t, err) - require.NoError(t, os.WriteFile(filepath.Join(dockerDir, "config.json"), data, 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)) } -func installFakeCredHelper(t *testing.T, name, script string) { +func installFakeCredHelper(t *testing.T, helperName, script string) { t.Helper() dir := t.TempDir() t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-"+name), []byte(script), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "docker-credential-"+helperName), []byte(script), 0755)) +} + +func credHelperScript(username, secret string) string { + payload, _ := json.Marshal(struct { + ServerURL string `json:"ServerURL"` + Username string `json:"Username"` + Secret string `json:"Secret"` + }{Username: username, Secret: secret}) + return fmt.Sprintf("#!/bin/sh\necho '%s'\n", payload) } -func credHelperScript(response credHelperResponse) string { - data, _ := json.Marshal(response) - return fmt.Sprintf("#!/bin/sh\necho '%s'\n", data) +type authTokenPayload struct { + Username string `json:"username"` + Password string `json:"password"` } -func decodeAuthToken(t *testing.T, token string) encodedAuthConfig { +func decodeAuthToken(t *testing.T, token string) authTokenPayload { t.Helper() data, err := base64.URLEncoding.DecodeString(token) require.NoError(t, err) - var ac encodedAuthConfig + var ac authTokenPayload require.NoError(t, json.Unmarshal(data, &ac)) return ac }