Skip to content

Commit 1da1493

Browse files
committed
refactor: Improve ListUsers implementation for macOS keychain
1 parent ac1ec0c commit 1da1493

File tree

6 files changed

+105
-33
lines changed

6 files changed

+105
-33
lines changed

keyring.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import "errors"
66
// keyring_unix.go
77
var provider Keyring = fallbackServiceProvider{}
88

9+
// restoreProvider is set by platform init to restore the real provider after MockInit
10+
var restoreProvider func()
11+
912
var (
1013
// ErrNotFound is the expected error if the secret isn't found in the
1114
// keyring.

keyring_darwin.go

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -141,51 +141,73 @@ func (k macOSXKeychain) ListUsers(service string) ([]string, error) {
141141
return []string{}, nil
142142
}
143143

144-
out, err := exec.Command(execPathKeychain, "dump-keyring").CombinedOutput()
144+
// Get the default keychain since there can be multiple keychains
145+
defaultKeychainOut, err := exec.Command(execPathKeychain, "default-keychain").CombinedOutput()
145146
if err != nil {
146147
return nil, err
147148
}
149+
// Take first line in case multiple default keychains are returned
150+
firstLine := strings.SplitN(strings.TrimSpace(string(defaultKeychainOut)), "\n", 2)[0]
151+
defaultKeychain := strings.Trim(strings.TrimSpace(firstLine), `"`)
152+
153+
// dump-keychain (not dump-keyring) requires a keychain path
154+
out, err := exec.Command(execPathKeychain, "dump-keychain", defaultKeychain).CombinedOutput()
155+
if err != nil {
156+
return nil, err
157+
}
158+
159+
// Parse dump-keychain output. Format:
160+
// keychain: "/Users/username/Library/Keychains/login.keychain-db"
161+
// class: "genp"
162+
// attributes:
163+
// "svce"<blob>="service-name"
164+
// "acct"<blob>="account-name"
165+
valueOf := func(s, prefix string) string {
166+
return strings.Trim(strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), prefix)), `"`)
167+
}
148168

149169
var users []string
150170
seenUsers := make(map[string]bool)
171+
var currentKeychain, currentSvc, currentAcct string
151172
lines := strings.Split(string(out), "\n")
152-
153-
// Parse dump-keyring output looking for generic passwords matching our service
154-
// Format: keychain: "/Users/username/Library/Keychains/login.keychain-db"
155-
// class: "genp"
156-
// attributes:
157-
// "svce"<blob>="service-name"
158-
// "acct"<blob>="account-name"
159-
for i := 0; i < len(lines); i++ {
160-
line := strings.TrimSpace(lines[i])
161-
162-
// Look for service attribute matching our service
163-
if strings.Contains(line, `"svce"`) && strings.Contains(line, `="`+service+`"`) {
164-
// Found a matching service, now look for the account attribute
165-
// It should be nearby in the attributes section
166-
for j := i - 10; j < i+10 && j < len(lines) && j >= 0; j++ {
167-
acctLine := strings.TrimSpace(lines[j])
168-
if strings.Contains(acctLine, `"acct"`) {
169-
// Extract account name from: "acct"<blob>="username"
170-
if idx := strings.Index(acctLine, `="`); idx != -1 {
171-
start := idx + 2
172-
if end := strings.Index(acctLine[start:], `"`); end != -1 {
173-
username := acctLine[start : start+end]
174-
if !seenUsers[username] {
175-
seenUsers[username] = true
176-
users = append(users, username)
177-
}
178-
break
179-
}
180-
}
173+
174+
for _, line := range lines {
175+
switch {
176+
case strings.HasPrefix(strings.TrimSpace(line), "keychain:"):
177+
// Save previous entry if it matched
178+
if currentKeychain == defaultKeychain && currentSvc == service && currentAcct != "" && !seenUsers[currentAcct] {
179+
seenUsers[currentAcct] = true
180+
users = append(users, currentAcct)
181+
}
182+
currentKeychain = valueOf(line, "keychain:")
183+
currentSvc = ""
184+
currentAcct = ""
185+
case strings.Contains(line, `"svce"`):
186+
if idx := strings.Index(line, `="`); idx != -1 {
187+
start := idx + 2
188+
if end := strings.Index(line[start:], `"`); end != -1 {
189+
currentSvc = line[start : start+end]
190+
}
191+
}
192+
case strings.Contains(line, `"acct"`):
193+
if idx := strings.Index(line, `="`); idx != -1 {
194+
start := idx + 2
195+
if end := strings.Index(line[start:], `"`); end != -1 {
196+
currentAcct = line[start : start+end]
181197
}
182198
}
183199
}
184200
}
201+
// Don't forget the last entry
202+
if currentKeychain == defaultKeychain && currentSvc == service && currentAcct != "" && !seenUsers[currentAcct] {
203+
users = append(users, currentAcct)
204+
}
185205

186206
return users, nil
187207
}
188208

189209
func init() {
190-
provider = macOSXKeychain{}
210+
p := macOSXKeychain{}
211+
provider = p
212+
restoreProvider = func() { provider = p }
191213
}

keyring_mock.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ func MockInit() {
7878
provider = &mockProvider{}
7979
}
8080

81+
// MockRestore restores the provider to the platform default.
82+
// Call after MockInit when the mock is no longer needed (e.g. in defer).
83+
func MockRestore() {
84+
if restoreProvider != nil {
85+
restoreProvider()
86+
}
87+
}
88+
8189
// MockInitWithError sets the provider to a mocked memory store
8290
// that returns the given error on all operations
8391
func MockInitWithError(err error) {

keyring_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package keyring
22

33
import (
4+
"fmt"
5+
"log"
46
"runtime"
7+
"sort"
58
"strings"
69
"testing"
710
)
@@ -267,6 +270,38 @@ func TestListUsersSingleUser(t *testing.T) {
267270
_ = Delete(service3, "single-user")
268271
}
269272

273+
// ExampleListUsers demonstrates listing configured users for a service using the mock provider.
274+
// The example uses MockInit so it runs without requiring a system keyring.
275+
func ExampleListUsers() {
276+
MockInit()
277+
defer MockRestore()
278+
279+
service := "my-cli"
280+
281+
// Store some credentials
282+
Set(service, "github", "ghp_token123")
283+
Set(service, "gitlab", "glpat_token456")
284+
Set(service, "aws", "aws_secret789")
285+
286+
// List all configured providers
287+
users, err := ListUsers(service)
288+
if err != nil {
289+
log.Fatal(err)
290+
}
291+
292+
sort.Strings(users) // deterministic output for the example
293+
fmt.Println("Configured providers:")
294+
for _, u := range users {
295+
fmt.Printf(" - %s\n", u)
296+
}
297+
298+
// Output:
299+
// Configured providers:
300+
// - aws
301+
// - github
302+
// - gitlab
303+
}
304+
270305
// TestListUsersMultipleServices tests that ListUsers only returns users for the specified service.
271306
func TestListUsersMultipleServices(t *testing.T) {
272307
const serviceA = "service-a"

keyring_unix.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,5 +222,7 @@ func (s secretServiceProvider) ListUsers(service string) ([]string, error) {
222222
}
223223

224224
func init() {
225-
provider = secretServiceProvider{}
225+
p := secretServiceProvider{}
226+
provider = p
227+
restoreProvider = func() { provider = p }
226228
}

keyring_windows.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,5 +128,7 @@ func (k windowsKeychain) credName(service, username string) string {
128128
}
129129

130130
func init() {
131-
provider = windowsKeychain{}
131+
p := windowsKeychain{}
132+
provider = p
133+
restoreProvider = func() { provider = p }
132134
}

0 commit comments

Comments
 (0)