Skip to content
Merged
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
36 changes: 36 additions & 0 deletions credstore/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions credstore/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading