diff --git a/internal/dockerauth/dockerauth.go b/internal/dockerauth/dockerauth.go index 244fc30..3968f5f 100644 --- a/internal/dockerauth/dockerauth.go +++ b/internal/dockerauth/dockerauth.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "fmt" + "io/fs" "net/http" "os" "os/exec" @@ -32,6 +33,8 @@ func splitRegistryHost(registry string) (host, path string) { type DockerAuth struct { logger *logger.Logger skipCredVerification bool + + authFiles []string } // DockerConfig represents Docker configuration structure. @@ -60,8 +63,16 @@ type Credentials struct { // New creates a new DockerAuth instance. func New(log *logger.Logger) *DockerAuth { + authFiles := []string{filepath.Join(os.Getenv("HOME"), ".docker", "config.json")} + xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") + if xdgRuntimeDir != "" { + authFiles = append(authFiles, filepath.Join(xdgRuntimeDir, "containers", "auth.json")) + } + authFiles = append(authFiles, filepath.Join(os.Getenv("HOME"), ".config", "containers", "auth.json")) + return &DockerAuth{ - logger: log, + logger: log, + authFiles: authFiles, } } @@ -85,15 +96,14 @@ func (d *DockerAuth) GetAndVerifyCredentials(ctx context.Context, registry strin } if username == "" { - // Try to get from Docker config file. - dockerConfigPath := filepath.Join(os.Getenv("HOME"), ".docker", "config.json") - d.logger.Dimf("REGISTRY_USERNAME/REGISTRY_PASSWORD unset. Trying to obtain Docker credentials from config file: %s", dockerConfigPath) - if _, err := os.Stat(dockerConfigPath); err == nil { - var err error - username, password, err = d.getCredentialsFromDockerConfig(dockerConfigPath, host) - if err != nil { - return nil, err - } + registryAuthPath, err := d.findAuthConfigPath() + if err != nil { + return nil, err + } + d.logger.Dimf("REGISTRY_USERNAME/REGISTRY_PASSWORD unset. Trying to obtain registry credentials from config file: %s", registryAuthPath) + username, password, err = d.getCredentialsFromDockerConfig(registryAuthPath, host) + if err != nil { + return nil, err } } @@ -114,6 +124,20 @@ func (d *DockerAuth) GetAndVerifyCredentials(ctx context.Context, registry strin }, nil } +func (d *DockerAuth) findAuthConfigPath() (string, error) { + for _, path := range d.authFiles { + _, err := os.Stat(path) + if errors.Is(err, fs.ErrNotExist) { + d.logger.Dimf("%q not found", path) + } else if err != nil { + return "", err + } else { + return path, nil + } + } + return "", errors.New("no registry authentication file found") +} + // getCredentialsFromDockerConfig extracts credentials from existing Docker config // for the given registry host. func (d *DockerAuth) getCredentialsFromDockerConfig(configPath, host string) (string, string, error) { diff --git a/internal/dockerauth/dockerauth_test.go b/internal/dockerauth/dockerauth_test.go index ec623de..37d3672 100644 --- a/internal/dockerauth/dockerauth_test.go +++ b/internal/dockerauth/dockerauth_test.go @@ -7,6 +7,8 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -24,7 +26,44 @@ func TestGetAndVerifyCredentialsFromEnv(t *testing.T) { log := logger.New() da := New(log) da.skipCredVerification = true // Skip verification in tests + da.authFiles = []string{} + testGetAndVerifyCredentials(t, da) +} + +func TestGetAndVerifyCredentialsFromAuthFile(t *testing.T) { + tests := []struct { + name string + authFile string + }{ + { + name: "docker style auth path", + authFile: filepath.Join(t.TempDir(), ".docker", "config.json"), + }, { + name: "podman style auth path", + authFile: filepath.Join(t.TempDir(), ".config", "containers", "auth.json"), + }, { + name: "podman XDG style auth path", + authFile: filepath.Join(t.TempDir(), "containers", "auth.json"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupMockAuthEnvironment(t) + authFile := createMockAuthFile(t, tt.authFile) + + log := logger.New() + da := New(log) + da.skipCredVerification = true // Skip verification in tests + da.authFiles = []string{authFile} + + testGetAndVerifyCredentials(t, da) + }) + } +} + +func testGetAndVerifyCredentials(t *testing.T, da *DockerAuth) { creds, err := da.GetAndVerifyCredentials(t.Context(), constants.DefaultRegistry) if err != nil { t.Fatalf("GetAndVerifyCredentials failed: %v", err) @@ -89,12 +128,13 @@ func TestGetAndVerifyCredentialsNoCredentials(t *testing.T) { t.Setenv("REGISTRY_USERNAME", "") t.Setenv("REGISTRY_PASSWORD", "") - // Use a temporary home directory to simulate missing credentials. - t.Setenv("HOME", t.TempDir()) + // Use temporary directories to simulate missing credentials. + setupMockAuthEnvironment(t) log := logger.New() da := New(log) da.skipCredVerification = true // Skip verification in tests + da.authFiles = []string{} _, err := da.GetAndVerifyCredentials(t.Context(), constants.DefaultRegistry) assert.Errorf(t, err, "Expected error when no credentials are available") @@ -173,6 +213,90 @@ func TestRepositoryRequiresAuth(t *testing.T) { } } +func TestFindAuthConfigPath(t *testing.T) { + tests := []struct { + name string + mockAuthPaths []string + expectAuthFileIndex uint32 + expectErr bool + }{ + { + "empty auth types", + []string{}, + 0, + true, + }, + { + "docker style auth path", + []string{filepath.Join(t.TempDir(), ".docker", "config.json")}, + 0, + false, + }, + { + "podman style auth path", + []string{filepath.Join(t.TempDir(), ".config", "containers", "auth.json")}, + 0, + false, + }, + { + "podman XDG style auth path", + []string{filepath.Join(t.TempDir(), "containers", "auth.json")}, + 0, + false, + }, + { + "Use first path", + []string{ + filepath.Join(t.TempDir(), ".docker", "config.json"), + filepath.Join(t.TempDir(), ".config", "containers", "auth.json"), + filepath.Join(t.TempDir(), "containers", "auth.json"), + }, + 0, + false, + }, + { + "Use middle path", + []string{ + filepath.Join(t.TempDir(), ".docker", "config.json"), + filepath.Join(t.TempDir(), ".config", "containers", "auth.json"), + filepath.Join(t.TempDir(), "containers", "auth.json"), + }, + 1, + false, + }, { + "Use last path", + []string{ + filepath.Join(t.TempDir(), ".docker", "config.json"), + filepath.Join(t.TempDir(), ".config", "containers", "auth.json"), + filepath.Join(t.TempDir(), "containers", "auth.json"), + }, + 2, + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !tt.expectErr { + createMockAuthFile(t, tt.mockAuthPaths[tt.expectAuthFileIndex]) + } + + log := logger.New() + da := New(log) + da.authFiles = tt.mockAuthPaths + + authFile, err := da.findAuthConfigPath() + if tt.expectErr { + assert.Error(t, err) + return + } else { + assert.NoError(t, err) + assert.Equal(t, authFile, tt.mockAuthPaths[tt.expectAuthFileIndex]) + } + }) + } +} + // newFakeRegistry starts an httptest server that simulates an OCI registry's // authentication and tags-list endpoints. func newFakeRegistry(t *testing.T, challengeAuth bool, tokenStatus, tagsListStatus int) (string, func()) { @@ -204,3 +328,37 @@ func newFakeRegistry(t *testing.T, challengeAuth bool, tokenStatus, tagsListStat registryAddr = strings.TrimPrefix(server.URL, "http://") return registryAddr, server.Close } + +func setupMockAuthEnvironment(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) +} + +func createMockAuthFile(t *testing.T, authFile string) string { + err := os.MkdirAll(filepath.Dir(authFile), 0755) + if err != nil { + t.Fatalf("Auth directory creation failed: %s", err) + } + + f, err := os.OpenFile(authFile, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("Auth file creation failed: %s", err) + } + defer f.Close() + + mockCredentials := base64.StdEncoding.EncodeToString([]byte("user:pass")) + mockAuth := fmt.Sprintf(`{ + "auths": { + "quay.io": { + "auth": %q + } + } + }`, mockCredentials) + + _, err = f.WriteString(mockAuth) + if err != nil { + t.Fatalf("Writing credentials failed: %s", err) + } + + return authFile +}