From d8af5b2c9640e007b5c58b7905b9ff126d072878 Mon Sep 17 00:00:00 2001 From: Pavel Anni Date: Thu, 28 May 2026 12:01:21 -0400 Subject: [PATCH 1/2] fix: search all Podman auth file locations per containers-auth.json(5) spec On Linux, Podman writes credentials to $XDG_RUNTIME_DIR/containers/auth.json, but skillctl only checked one path. Now podmanAuthPaths() returns all candidate locations in spec order and credentialStore() chains them as fallbacks. Fixes #45 Assisted-By: Claude (Anthropic AI) Signed-off-by: Pavel Anni --- pkg/oci/credentials_test.go | 79 +++++++++++++++++++++++++++++++++++++ pkg/oci/push.go | 54 ++++++++++++++++--------- 2 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 pkg/oci/credentials_test.go diff --git a/pkg/oci/credentials_test.go b/pkg/oci/credentials_test.go new file mode 100644 index 0000000..380a6f7 --- /dev/null +++ b/pkg/oci/credentials_test.go @@ -0,0 +1,79 @@ +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_CONFIG_HOME equals default", func(t *testing.T) { + t.Setenv("XDG_RUNTIME_DIR", "") + + home, err := os.UserHomeDir() + if err != nil { + t.Skip("cannot determine home dir") + } + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + paths := podmanAuthPaths() + + // Should not contain the same path twice. + seen := make(map[string]bool) + for _, p := range paths { + if seen[p] { + t.Errorf("duplicate path: %s", p) + } + seen[p] = true + } + }) + + 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..291a8a1 100644 --- a/pkg/oci/push.go +++ b/pkg/oci/push.go @@ -81,40 +81,56 @@ 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 { + continue + } + store, err := credentials.NewStore(p, credentials.StoreOptions{}) + if err != nil { + 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. From ac734f1eb4db98e7a0a3bc46cecafe85dab67d4b Mon Sep 17 00:00:00 2001 From: Pavel Anni Date: Thu, 28 May 2026 12:05:17 -0400 Subject: [PATCH 2/2] fix: strengthen dedupe test and add debug logging for auth paths Replace tautological dedupe test with one that exercises the actual dedup branch (both XDG vars pointing to same directory). Add slog.Debug for skipped auth files to help users troubleshoot credential issues. Addresses review feedback from PR #46. Assisted-By: Claude (Anthropic AI) Signed-off-by: Pavel Anni --- pkg/oci/credentials_test.go | 20 +++++--------------- pkg/oci/push.go | 3 +++ 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/pkg/oci/credentials_test.go b/pkg/oci/credentials_test.go index 380a6f7..cce88ed 100644 --- a/pkg/oci/credentials_test.go +++ b/pkg/oci/credentials_test.go @@ -37,24 +37,14 @@ func TestPodmanAuthPaths(t *testing.T) { } }) - t.Run("deduplicates when XDG_CONFIG_HOME equals default", func(t *testing.T) { - t.Setenv("XDG_RUNTIME_DIR", "") - - home, err := os.UserHomeDir() - if err != nil { - t.Skip("cannot determine home dir") - } - t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + 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() - // Should not contain the same path twice. - seen := make(map[string]bool) - for _, p := range paths { - if seen[p] { - t.Errorf("duplicate path: %s", p) - } - seen[p] = true + if len(paths) != 1 { + t.Fatalf("expected 1 path, got %d: %v", len(paths), paths) } }) diff --git a/pkg/oci/push.go b/pkg/oci/push.go index 291a8a1..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" @@ -91,10 +92,12 @@ func credentialStore() (credentials.Store, error) { 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)