Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
7 changes: 5 additions & 2 deletions image/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
154 changes: 154 additions & 0 deletions image/dockerconfig.go
Original file line number Diff line number Diff line change
@@ -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-<helper> 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="<token>" to signal a bearer/identity token.
// In that case Secret is the token value, not a password.
if resp.Username == "<token>" {
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
}
153 changes: 153 additions & 0 deletions image/dockerconfig_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading