diff --git a/pkg/oci/credentials_test.go b/pkg/oci/credentials_test.go new file mode 100644 index 0000000..cce88ed --- /dev/null +++ b/pkg/oci/credentials_test.go @@ -0,0 +1,69 @@ +package oci + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPodmanAuthPaths(t *testing.T) { + t.Run("returns XDG_RUNTIME_DIR path first on Linux-like env", func(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv("XDG_CONFIG_HOME", "") + + paths := podmanAuthPaths() + + if len(paths) < 1 { + t.Fatal("expected at least 1 path") + } + want := filepath.Join("/run/user/1000", "containers", "auth.json") + if paths[0] != want { + t.Errorf("paths[0] = %q, want %q", paths[0], want) + } + }) + + t.Run("includes XDG_CONFIG_HOME path as fallback", func(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/run/user/1000") + t.Setenv("XDG_CONFIG_HOME", "/home/testuser/.config") + + paths := podmanAuthPaths() + + if len(paths) < 2 { + t.Fatalf("expected at least 2 paths, got %d", len(paths)) + } + want := filepath.Join("/home/testuser/.config", "containers", "auth.json") + if paths[1] != want { + t.Errorf("paths[1] = %q, want %q", paths[1], want) + } + }) + + t.Run("deduplicates when XDG_RUNTIME_DIR and XDG_CONFIG_HOME match", func(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "/tmp/shared-config") + t.Setenv("XDG_CONFIG_HOME", "/tmp/shared-config") + + paths := podmanAuthPaths() + + if len(paths) != 1 { + t.Fatalf("expected 1 path, got %d: %v", len(paths), paths) + } + }) + + t.Run("falls back to home config when no XDG vars set", func(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + t.Setenv("XDG_CONFIG_HOME", "") + + paths := podmanAuthPaths() + + if len(paths) == 0 { + t.Fatal("expected at least 1 path") + } + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home dir") + } + want := filepath.Join(home, ".config", "containers", "auth.json") + if paths[0] != want { + t.Errorf("paths[0] = %q, want %q", paths[0], want) + } + }) +} diff --git a/pkg/oci/push.go b/pkg/oci/push.go index cd89cc7..8fd2ad7 100644 --- a/pkg/oci/push.go +++ b/pkg/oci/push.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "log/slog" "net/http" "os" "path/filepath" @@ -81,40 +82,58 @@ func newAuthClient(store credentials.Store, skipTLSVerify bool) *auth.Client { } // credentialStore returns a credential store that checks Docker config first, -// then falls back to Podman's auth.json. +// then falls back to Podman auth files found via podmanAuthPaths. func credentialStore() (credentials.Store, error) { dockerStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) if err != nil { return nil, err } - podmanPath := podmanAuthPath() - if podmanPath == "" { - return dockerStore, nil - } - - if _, err := os.Stat(podmanPath); err != nil { - return dockerStore, nil + var fallbacks []credentials.Store + for _, p := range podmanAuthPaths() { + if _, err := os.Stat(p); err != nil { + slog.Debug("podman auth file not found", "path", p) + continue + } + store, err := credentials.NewStore(p, credentials.StoreOptions{}) + if err != nil { + slog.Debug("skipping unreadable podman auth file", "path", p, "error", err) + continue + } + fallbacks = append(fallbacks, store) } - podmanStore, err := credentials.NewStore(podmanPath, credentials.StoreOptions{}) - if err != nil { - // Podman config unreadable but Docker config works — fall back gracefully. + if len(fallbacks) == 0 { return dockerStore, nil } - - return credentials.NewStoreWithFallbacks(dockerStore, podmanStore), nil + return credentials.NewStoreWithFallbacks(dockerStore, fallbacks...), nil } -func podmanAuthPath() string { +// podmanAuthPaths returns Podman/containers auth file locations in search +// order per the containers-auth.json(5) spec: +// 1. $XDG_RUNTIME_DIR/containers/auth.json (Linux primary) +// 2. $XDG_CONFIG_HOME/containers/auth.json (fallback; defaults to ~/.config) +func podmanAuthPaths() []string { + var paths []string + if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" { - return filepath.Join(xdg, "containers", "auth.json") + paths = append(paths, filepath.Join(xdg, "containers", "auth.json")) } - home, err := os.UserHomeDir() - if err != nil { - return "" + + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + if home, err := os.UserHomeDir(); err == nil { + configHome = filepath.Join(home, ".config") + } } - return filepath.Join(home, ".config", "containers", "auth.json") + if configHome != "" { + p := filepath.Join(configHome, "containers", "auth.json") + if len(paths) == 0 || paths[0] != p { + paths = append(paths, p) + } + } + + return paths } // splitRefTag splits an OCI reference into repository and tag/digest.