diff --git a/credstore/bundle.go b/credstore/bundle.go index 7fc3259..8505be9 100644 --- a/credstore/bundle.go +++ b/credstore/bundle.go @@ -84,6 +84,42 @@ func (s *Store) ListBundle(profile string) ([]string, error) { return s.bundleBareKeys(profile) } +// ListProfiles returns the sorted distinct profiles that have at least one +// stored key under this service. Like ListBundle it is not allowlist-gated — +// it reports stored reality (§1.5.2 gates writes/deletes, not reads). A +// service with no stored items returns (nil, nil). It exists so a CLI can +// offer a "profiles list" affordance without shelling out to OS keyring +// tools: the profile segment is not secret (§1.12), and discovering which +// profiles exist must not require dumping the keychain by hand. An item key +// with no '/' separator (never written by this package) is ignored rather +// than surfaced as a phantom profile. +func (s *Store) ListProfiles() ([]string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return nil, ErrStoreClosed + } + all, err := s.be.listKeys() + if err != nil { + return nil, err + } + seen := map[string]struct{}{} + var profiles []string + for _, ik := range all { + profile, _, found := strings.Cut(ik, "/") + if !found || profile == "" { + continue + } + if _, dup := seen[profile]; dup { + continue + } + seen[profile] = struct{}{} + profiles = append(profiles, profile) + } + sort.Strings(profiles) + return profiles, nil +} + // DeleteBundle removes every key under profile (config clear, §1.7). It // is idempotent (a valid profile with no keys → (nil, nil)) and not // allowlist-gated. It does not fail-fast: every key is attempted; if any diff --git a/credstore/bundle_test.go b/credstore/bundle_test.go index 6c3a2fc..5d6848d 100644 --- a/credstore/bundle_test.go +++ b/credstore/bundle_test.go @@ -61,6 +61,42 @@ func TestListBundleClosed(t *testing.T) { } } +func TestListProfiles(t *testing.T) { + s := openMem(t) + if got, err := s.ListProfiles(); err != nil || got != nil { + t.Fatalf("ListProfiles(empty store) = (%v,%v), want (nil,nil)", got, err) + } + + mustSet(t, s, "work", "oauth_token", "1") + mustSet(t, s, "default", "oauth_token", "2") + mustSet(t, s, "default", "second_key", "3") // dedupe: two keys, one profile + got, err := s.ListProfiles() + if err != nil { + t.Fatalf("ListProfiles: %v", err) + } + eqStrings(t, "ListProfiles", got, []string{"default", "work"}) + + // Not allowlist-gated: a profile whose only key was stored directly + // (legacy / another tool run) is still reported — stored reality. + if err := memOf(t, s).set("legacy/legacy_key", "x", false); err != nil { + t.Fatalf("seed legacy profile: %v", err) + } + // A malformed item key with no separator is ignored, not a profile. + if err := memOf(t, s).set("noslash", "x", false); err != nil { + t.Fatalf("seed malformed key: %v", err) + } + got, _ = s.ListProfiles() + eqStrings(t, "ListProfiles after legacy", got, []string{"default", "legacy", "work"}) +} + +func TestListProfilesClosed(t *testing.T) { + s, _ := Open("svc", &Options{Backend: BackendMemory}) + _ = s.Close() + if _, err := s.ListProfiles(); !errors.Is(err, ErrStoreClosed) { + t.Fatalf("ListProfiles after Close = %v, want ErrStoreClosed", err) + } +} + func TestDeleteBundle(t *testing.T) { s := openMem(t) if _, err := s.DeleteBundle(""); !errors.Is(err, ErrRefEmpty) {