From 94126be06afd275b667c43ae64889b9a96fa5cc4 Mon Sep 17 00:00:00 2001 From: deveshctl Date: Sat, 3 Oct 2026 12:48:22 +0530 Subject: [PATCH] fix(image): resolve registry credentials from docker config for pulls (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Private-registry pulls were always sent unauthenticated because pullOpts() left RegistryAuth empty. The Docker daemon does not fall back to its own credential store when the header is explicitly absent — it treats the call as anonymous, so pulls fail with 401 even when docker pull succeeds. resolveRegistryAuth (image/dockerconfig.go) now reads ~/.docker/config.json and follows the same lookup chain as the Docker CLI: per-registry credHelpers first, then the global credsStore, then inline auths. Credential helpers are invoked via the docker-credential- subprocess interface, which covers osxkeychain, desktop, ecr-login, and any other compliant helper. Encoding delegates to authconfig.Encode from github.com/moby/moby/api/pkg/authconfig — the canonical encoder whose decoder the daemon uses for X-Registry-Auth. --- CHANGELOG.md | 1 + go.mod | 5 +- image/docker.go | 7 +- image/dockerconfig.go | 154 +++++++++++++++++++++++++++++++++++++ image/dockerconfig_test.go | 153 ++++++++++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+), 3 deletions(-) create mode 100644 image/dockerconfig.go create mode 100644 image/dockerconfig_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index e594667..f372589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- `layerx` now resolves registry credentials from `~/.docker/config.json` (including `credHelpers` per-registry entries, the global `credsStore`, and inline `auths` tokens) before calling the Docker daemon's pull API. Previously the pull was always sent unauthenticated, causing private-registry pulls to fail with "unauthorized" even when `docker pull` of the same reference succeeded. - `layerx compare` daemon and resolver errors now go through the same friendly error formatter as `layerx ci` and `layerx` itself, so actionable hints ("Is Docker running?", "pass a saved-image archive path instead") are shown consistently. - `ErrPodmanSocketNotSet.Error()` wording now matches the hint shown by the CLI, so the message is consistent across all output paths. - Status bar "toggle / view" hint in split-pane mode now tracks the correct pane's cursor; previously it read the top pane's cursor position even when the bottom pane had focus. diff --git a/go.mod b/go.mod index 652d3c9..544a20e 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,11 @@ require ( charm.land/bubbles/v2 v2.1.0 charm.land/bubbletea/v2 v2.0.6 charm.land/lipgloss/v2 v2.0.3 + github.com/alecthomas/assert/v2 v2.11.0 github.com/alecthomas/chroma/v2 v2.24.1 github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/charmbracelet/x/ansi v0.11.7 + github.com/distribution/reference v0.6.0 github.com/goccy/go-yaml v1.19.2 github.com/moby/moby/api v1.54.2 github.com/moby/moby/client v0.4.1 @@ -20,6 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alecthomas/repr v0.5.2 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect @@ -30,13 +33,13 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.12.0 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect diff --git a/image/docker.go b/image/docker.go index f560b2a..a54abec 100644 --- a/image/docker.go +++ b/image/docker.go @@ -275,7 +275,7 @@ func (r *DockerResolver) ensureImageWithProgress(ctx context.Context, imageRef s emitProgress(progress, ProgressEvent{Phase: PhasePulling}) - rc, err := r.cli.ImagePull(ctx, imageRef, r.pullOpts()) + rc, err := r.cli.ImagePull(ctx, imageRef, r.pullOpts(ctx, imageRef)) if err != nil { if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { return err @@ -312,11 +312,14 @@ func (r *DockerResolver) ensureImageWithProgress(ctx context.Context, imageRef s return nil } -func (r *DockerResolver) pullOpts() client.ImagePullOptions { +func (r *DockerResolver) pullOpts(ctx context.Context, imageRef string) client.ImagePullOptions { opts := client.ImagePullOptions{} if r.platform != nil { opts.Platforms = []ocispec.Platform{*r.platform} } + if auth, err := resolveRegistryAuth(ctx, registryHostFrom(imageRef)); err == nil && auth != "" { + opts.RegistryAuth = auth + } return opts } diff --git a/image/dockerconfig.go b/image/dockerconfig.go new file mode 100644 index 0000000..436227c --- /dev/null +++ b/image/dockerconfig.go @@ -0,0 +1,154 @@ +package image + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + distreference "github.com/distribution/reference" + "github.com/moby/moby/api/pkg/authconfig" + registrytypes "github.com/moby/moby/api/types/registry" +) + +// registryHostFrom extracts the registry hostname (including port if present) +// from an image reference. Returns "docker.io" for bare image names like +// "alpine" or "library/ubuntu:latest" — matching Docker's normalisation. +func registryHostFrom(imageRef string) string { + named, err := distreference.ParseNormalizedNamed(imageRef) + if err != nil { + return "" + } + return distreference.Domain(named) +} + +// dockerConfigFile is the on-disk layout of ~/.docker/config.json that is +// relevant for credential lookup. Only the fields we need are decoded. +type dockerConfigFile struct { + Auths map[string]dockerConfigAuth `json:"auths"` + CredsStore string `json:"credsStore"` + CredHelpers map[string]string `json:"credHelpers"` +} + +type dockerConfigAuth struct { + Auth string `json:"auth"` +} + +// resolveRegistryAuth returns a base64-encoded RegistryAuth value for the +// given registry hostname by reading ~/.docker/config.json and, when +// necessary, invoking the configured credential helper. +// +// Lookup order (matches Docker CLI behaviour): +// 1. Per-registry credHelper entry (credHelpers["registry"]) +// 2. Global credential store (credsStore) +// 3. Inline auth token (auths["registry"].auth) +// +// Returns ("", nil) when no credentials are found — callers should treat +// that as an anonymous pull, not an error. +func resolveRegistryAuth(ctx context.Context, registry string) (string, error) { + cfgPath, err := dockerConfigPath() + if err != nil { + return "", nil + } + data, err := os.ReadFile(cfgPath) + if err != nil { + return "", nil + } + var cfg dockerConfigFile + if err := json.Unmarshal(data, &cfg); err != nil { + return "", nil + } + + // 1. Per-registry credHelper + if helper, ok := cfg.CredHelpers[registry]; ok { + return credHelperGet(ctx, helper, registry) + } + + // 2. Global credential store + if cfg.CredsStore != "" { + auth, err := credHelperGet(ctx, cfg.CredsStore, registry) + if err == nil && auth != "" { + return auth, nil + } + // If the global store fails (helper not installed, no entry for this + // registry), fall through to inline auths rather than erroring. + } + + // 3. Inline auth token + if entry, ok := cfg.Auths[registry]; ok && entry.Auth != "" { + // The inline auth field is base64("username:password"). Decode it and + // re-encode as a full AuthConfig JSON so the daemon gets Username+Password + // rather than a bare auth token, which some registry implementations reject. + decoded, err := base64.StdEncoding.DecodeString(entry.Auth) + if err != nil { + return "", nil + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "", nil + } + return authconfig.Encode(registrytypes.AuthConfig{ + Username: parts[0], + Password: parts[1], + ServerAddress: registry, + }) + } + + return "", nil +} + +// credHelperGet invokes docker-credential- get for the given server +// address and returns a base64-encoded RegistryAuth value on success. +func credHelperGet(ctx context.Context, helper, serverURL string) (string, error) { + helperBin := "docker-credential-" + helper + cmd := exec.CommandContext(ctx, helperBin, "get") + cmd.Stdin = strings.NewReader(serverURL + "\n") + var out bytes.Buffer + cmd.Stdout = &out + + if err := cmd.Run(); err != nil { + // Helper not installed or has no entry — treat as "no credentials". + return "", nil + } + + type helperResponse struct { + Username string `json:"Username"` + Secret string `json:"Secret"` + } + var resp helperResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + return "", fmt.Errorf("credential helper %s returned unexpected output: %w", helperBin, err) + } + if resp.Username == "" && resp.Secret == "" { + return "", nil + } + + ac := registrytypes.AuthConfig{ServerAddress: serverURL} + // Credential helpers use Username="" to signal a bearer/identity token. + // In that case Secret is the token value, not a password. + if resp.Username == "" { + ac.IdentityToken = resp.Secret + } else { + ac.Username = resp.Username + ac.Password = resp.Secret + } + return authconfig.Encode(ac) +} + +// dockerConfigPath returns the path to the active Docker config.json. +// Respects DOCKER_CONFIG env var; falls back to ~/.docker/config.json. +func dockerConfigPath() (string, error) { + if dir := os.Getenv("DOCKER_CONFIG"); dir != "" { + return filepath.Join(dir, "config.json"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".docker", "config.json"), nil +} diff --git a/image/dockerconfig_test.go b/image/dockerconfig_test.go new file mode 100644 index 0000000..0363ff6 --- /dev/null +++ b/image/dockerconfig_test.go @@ -0,0 +1,153 @@ +package image + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/alecthomas/assert/v2" + "github.com/moby/moby/api/pkg/authconfig" + registrytypes "github.com/moby/moby/api/types/registry" +) + +func TestRegistryHostFrom(t *testing.T) { + tests := []struct { + ref string + want string + }{ + {"alpine", "docker.io"}, + {"alpine:3.20", "docker.io"}, + {"library/ubuntu:latest", "docker.io"}, + {"ghcr.io/some/private-image:latest", "ghcr.io"}, + {"localhost:5001/private/testimage:latest", "localhost:5001"}, + {"registry.example.com/org/image:tag", "registry.example.com"}, + {"", ""}, + } + for _, tt := range tests { + got := registryHostFrom(tt.ref) + assert.Equal(t, tt.want, got, "registryHostFrom(%q)", tt.ref) + } +} + +func TestResolveRegistryAuth_NoConfigFile(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) // points at an empty dir, no config.json + auth, err := resolveRegistryAuth(context.Background(), "ghcr.io") + assert.NoError(t, err) + assert.Equal(t, "", auth) +} + +func TestResolveRegistryAuth_InlineAuth(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + + // Inline auth entry: base64("user:pass") + inlineToken := base64.StdEncoding.EncodeToString([]byte("user:pass")) + cfg := map[string]any{ + "auths": map[string]any{ + "localhost:5001": map[string]any{"auth": inlineToken}, + }, + } + writeDockerConfig(t, dir, cfg) + + auth, err := resolveRegistryAuth(context.Background(), "localhost:5001") + assert.NoError(t, err) + assert.True(t, auth != "", "expected non-empty RegistryAuth for inline credentials") + + // Verify the encoded value round-trips to a valid AuthConfig with Username+Password + decoded, err := base64.URLEncoding.DecodeString(auth) + assert.NoError(t, err) + var ac registrytypes.AuthConfig + assert.NoError(t, json.Unmarshal(decoded, &ac)) + assert.Equal(t, "user", ac.Username) + assert.Equal(t, "pass", ac.Password) + assert.Equal(t, "localhost:5001", ac.ServerAddress) +} + +func TestResolveRegistryAuth_NoMatchingEntry(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + + cfg := map[string]any{ + "auths": map[string]any{ + "other.registry.io": map[string]any{"auth": "dXNlcjpwYXNz"}, + }, + } + writeDockerConfig(t, dir, cfg) + + // Different registry — should return empty, not an error + auth, err := resolveRegistryAuth(context.Background(), "ghcr.io") + assert.NoError(t, err) + assert.Equal(t, "", auth) +} + +func TestResolveRegistryAuth_MissingCredsStore(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + + // credsStore points at a helper that doesn't exist — should fall through + // to inline auths (none here) and return empty without error. + inlineToken := base64.StdEncoding.EncodeToString([]byte("user:pass")) + cfg := map[string]any{ + "credsStore": "nonexistent-helper-xyz", + "auths": map[string]any{ + "localhost:5001": map[string]any{"auth": inlineToken}, + }, + } + writeDockerConfig(t, dir, cfg) + + // credsStore helper is absent; inline auth for localhost:5001 should be returned + auth, err := resolveRegistryAuth(context.Background(), "localhost:5001") + assert.NoError(t, err) + assert.True(t, auth != "", "expected inline auth as fallback when credsStore helper is absent") +} + +func TestResolveRegistryAuth_CredHelperPerRegistry(t *testing.T) { + dir := t.TempDir() + t.Setenv("DOCKER_CONFIG", dir) + + // credHelpers entry pointing at a non-existent helper — should return empty + cfg := map[string]any{ + "credHelpers": map[string]any{ + "ghcr.io": "nonexistent-helper-xyz", + }, + } + writeDockerConfig(t, dir, cfg) + + auth, err := resolveRegistryAuth(context.Background(), "ghcr.io") + assert.NoError(t, err) + assert.Equal(t, "", auth) +} + +func TestEncodeRegistryAuth(t *testing.T) { + ac := registrytypes.AuthConfig{ + Username: "user", + Password: "pass", + ServerAddress: "localhost:5001", + } + encoded, err := authconfig.Encode(ac) + assert.NoError(t, err) + assert.True(t, encoded != "") + + raw, err := base64.URLEncoding.DecodeString(encoded) + assert.NoError(t, err) + var got registrytypes.AuthConfig + assert.NoError(t, json.Unmarshal(raw, &got)) + assert.Equal(t, "user", got.Username) + assert.Equal(t, "pass", got.Password) +} + +// writeDockerConfig writes a config.json into dir from a map value. +func writeDockerConfig(t *testing.T, dir string, cfg map[string]any) { + t.Helper() + data, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "config.json"), data, 0600); err != nil { + t.Fatalf("write config.json: %v", err) + } +}