Skip to content

Commit e081cab

Browse files
authored
Add file-based token storage for Linux to avoid dbus dependency (#16)
1 parent 1f4e5a2 commit e081cab

6 files changed

Lines changed: 320 additions & 1 deletion

File tree

internal/keyring/keyring.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package keyring
2+
3+
// Provider defines the interface for token storage
4+
type Provider interface {
5+
// Set stores a token for the given service and user
6+
Set(service, user, token string) error
7+
// Get retrieves a token for the given service and user
8+
Get(service, user string) (string, error)
9+
}
10+
11+
// provider is the platform-specific implementation
12+
var provider Provider
13+
14+
// Set stores a token using the platform-specific provider
15+
func Set(service, user, token string) error {
16+
return provider.Set(service, user, token)
17+
}
18+
19+
// Get retrieves a token using the platform-specific provider
20+
func Get(service, user string) (string, error) {
21+
return provider.Get(service, user)
22+
}

internal/keyring/keyring_darwin.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build darwin
2+
3+
package keyring
4+
5+
import (
6+
"github.com/zalando/go-keyring"
7+
)
8+
9+
func init() {
10+
provider = &systemKeyringProvider{}
11+
}
12+
13+
// systemKeyringProvider implements token storage using the system keyring
14+
type systemKeyringProvider struct{}
15+
16+
// Set stores a token in the system keyring
17+
func (s *systemKeyringProvider) Set(service, user, token string) error {
18+
return keyring.Set(service, user, token)
19+
}
20+
21+
// Get retrieves a token from the system keyring
22+
func (s *systemKeyringProvider) Get(service, user string) (string, error) {
23+
return keyring.Get(service, user)
24+
}

internal/keyring/keyring_linux.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
//go:build linux
2+
3+
package keyring
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
"os"
9+
"path/filepath"
10+
)
11+
12+
const tokenFile = "token"
13+
14+
func init() {
15+
provider = &fileProvider{}
16+
}
17+
18+
// fileProvider implements token storage using files
19+
type fileProvider struct{}
20+
21+
// Set stores a token in a file
22+
func (f *fileProvider) Set(service, user, token string) error {
23+
tokenPath, err := getTokenFilePath()
24+
if err != nil {
25+
return err
26+
}
27+
28+
// Create config directory if it doesn't exist
29+
configDirPath := filepath.Dir(tokenPath)
30+
if err := os.MkdirAll(configDirPath, 0700); err != nil {
31+
return fmt.Errorf("failed to create config directory: %w", err)
32+
}
33+
34+
// Store as a simple key-value map
35+
tokens := make(map[string]string)
36+
37+
// Try to load existing tokens
38+
if data, err := os.ReadFile(tokenPath); err == nil {
39+
_ = json.Unmarshal(data, &tokens)
40+
}
41+
42+
// Add or update token for this user
43+
tokens[user] = token
44+
45+
// Save to file
46+
data, err := json.MarshalIndent(tokens, "", " ")
47+
if err != nil {
48+
return fmt.Errorf("failed to marshal tokens: %w", err)
49+
}
50+
51+
// Write with 0600 permissions (only owner can read/write)
52+
if err := os.WriteFile(tokenPath, data, 0600); err != nil {
53+
return fmt.Errorf("failed to write token file: %w", err)
54+
}
55+
56+
return nil
57+
}
58+
59+
// Get retrieves a token from a file
60+
func (f *fileProvider) Get(service, user string) (string, error) {
61+
tokenPath, err := getTokenFilePath()
62+
if err != nil {
63+
return "", err
64+
}
65+
66+
data, err := os.ReadFile(tokenPath)
67+
if err != nil {
68+
if os.IsNotExist(err) {
69+
return "", fmt.Errorf("token not found")
70+
}
71+
return "", fmt.Errorf("failed to read token file: %w", err)
72+
}
73+
74+
tokens := make(map[string]string)
75+
if err := json.Unmarshal(data, &tokens); err != nil {
76+
return "", fmt.Errorf("failed to parse token file: %w", err)
77+
}
78+
79+
token, ok := tokens[user]
80+
if !ok {
81+
return "", fmt.Errorf("token not found for user: %s", user)
82+
}
83+
84+
return token, nil
85+
}
86+
87+
// getTokenFilePath returns the path to the token file
88+
func getTokenFilePath() (string, error) {
89+
configDirPath, err := os.UserConfigDir()
90+
if err != nil {
91+
return "", fmt.Errorf("failed to get config directory: %w", err)
92+
}
93+
94+
tokenPath := filepath.Join(configDirPath, "slack-cli", tokenFile)
95+
return tokenPath, nil
96+
}

internal/keyring/keyring_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package keyring
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// TestSetGet tests basic set and get operations
10+
func TestSetGet(t *testing.T) {
11+
// Create a temporary directory for testing
12+
tmpDir := t.TempDir()
13+
14+
// Override the config directory
15+
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
16+
os.Setenv("XDG_CONFIG_HOME", tmpDir)
17+
defer func() {
18+
if origConfigDir != "" {
19+
os.Setenv("XDG_CONFIG_HOME", origConfigDir)
20+
} else {
21+
os.Unsetenv("XDG_CONFIG_HOME")
22+
}
23+
}()
24+
25+
testService := "test-service"
26+
testUser := "test-user"
27+
testToken := "test-token-12345"
28+
29+
// Test Set
30+
err := Set(testService, testUser, testToken)
31+
if err != nil {
32+
t.Fatalf("Failed to set token: %v", err)
33+
}
34+
35+
// Test Get
36+
retrievedToken, err := Get(testService, testUser)
37+
if err != nil {
38+
t.Fatalf("Failed to get token: %v", err)
39+
}
40+
41+
if retrievedToken != testToken {
42+
t.Errorf("Expected token %q, got %q", testToken, retrievedToken)
43+
}
44+
}
45+
46+
// TestMultipleUsers tests storing tokens for multiple users
47+
func TestMultipleUsers(t *testing.T) {
48+
// Create a temporary directory for testing
49+
tmpDir := t.TempDir()
50+
51+
// Override the config directory
52+
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
53+
os.Setenv("XDG_CONFIG_HOME", tmpDir)
54+
defer func() {
55+
if origConfigDir != "" {
56+
os.Setenv("XDG_CONFIG_HOME", origConfigDir)
57+
} else {
58+
os.Unsetenv("XDG_CONFIG_HOME")
59+
}
60+
}()
61+
62+
testService := "test-service"
63+
users := map[string]string{
64+
"user1": "token1",
65+
"user2": "token2",
66+
"user3": "token3",
67+
}
68+
69+
// Set all tokens
70+
for user, token := range users {
71+
err := Set(testService, user, token)
72+
if err != nil {
73+
t.Fatalf("Failed to set token for %s: %v", user, err)
74+
}
75+
}
76+
77+
// Get and verify all tokens
78+
for user, expectedToken := range users {
79+
retrievedToken, err := Get(testService, user)
80+
if err != nil {
81+
t.Fatalf("Failed to get token for %s: %v", user, err)
82+
}
83+
if retrievedToken != expectedToken {
84+
t.Errorf("For user %s, expected token %q, got %q", user, expectedToken, retrievedToken)
85+
}
86+
}
87+
}
88+
89+
// TestGetNotFound tests error handling when token doesn't exist
90+
func TestGetNotFound(t *testing.T) {
91+
// Create a temporary directory for testing
92+
tmpDir := t.TempDir()
93+
94+
// Override the config directory
95+
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
96+
os.Setenv("XDG_CONFIG_HOME", tmpDir)
97+
defer func() {
98+
if origConfigDir != "" {
99+
os.Setenv("XDG_CONFIG_HOME", origConfigDir)
100+
} else {
101+
os.Unsetenv("XDG_CONFIG_HOME")
102+
}
103+
}()
104+
105+
// Try to get from non-existent file
106+
_, err := Get("test-service", "nonexistent-user")
107+
if err == nil {
108+
t.Error("Expected error when getting non-existent token, got nil")
109+
}
110+
}
111+
112+
// TestFilePermissions tests that token file has correct permissions (Linux only)
113+
func TestFilePermissions(t *testing.T) {
114+
// Skip on non-Linux platforms since file implementation is Linux-specific
115+
if provider == nil {
116+
t.Skip("Skipping file permissions test on non-Linux platform")
117+
}
118+
119+
// Check if provider is fileProvider (Linux)
120+
if _, ok := provider.(*fileProvider); !ok {
121+
t.Skip("Skipping file permissions test on non-Linux platform")
122+
}
123+
124+
tmpDir := t.TempDir()
125+
126+
origConfigDir := os.Getenv("XDG_CONFIG_HOME")
127+
os.Setenv("XDG_CONFIG_HOME", tmpDir)
128+
defer func() {
129+
if origConfigDir != "" {
130+
os.Setenv("XDG_CONFIG_HOME", origConfigDir)
131+
} else {
132+
os.Unsetenv("XDG_CONFIG_HOME")
133+
}
134+
}()
135+
136+
// Set a token
137+
err := Set("test-service", "test-user", "test-token")
138+
if err != nil {
139+
t.Fatalf("Failed to set token: %v", err)
140+
}
141+
142+
// Check file permissions
143+
tokenPath := filepath.Join(tmpDir, "slack-cli", "token")
144+
info, err := os.Stat(tokenPath)
145+
if err != nil {
146+
t.Fatalf("Failed to stat token file: %v", err)
147+
}
148+
149+
expectedPerm := os.FileMode(0600)
150+
if info.Mode().Perm() != expectedPerm {
151+
t.Errorf("Expected file permissions %v, got %v", expectedPerm, info.Mode().Perm())
152+
}
153+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//go:build windows
2+
3+
package keyring
4+
5+
import (
6+
"github.com/zalando/go-keyring"
7+
)
8+
9+
func init() {
10+
provider = &systemKeyringProvider{}
11+
}
12+
13+
// systemKeyringProvider implements token storage using the system keyring
14+
type systemKeyringProvider struct{}
15+
16+
// Set stores a token in the system keyring
17+
func (s *systemKeyringProvider) Set(service, user, token string) error {
18+
return keyring.Set(service, user, token)
19+
}
20+
21+
// Get retrieves a token from the system keyring
22+
func (s *systemKeyringProvider) Get(service, user string) (string, error) {
23+
return keyring.Get(service, user)
24+
}

main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import (
1010
"strings"
1111
"syscall"
1212

13+
"github.com/kitproj/slack-cli/internal/keyring"
1314
"github.com/slack-go/slack"
14-
"github.com/zalando/go-keyring"
1515
"golang.org/x/term"
1616
)
1717

0 commit comments

Comments
 (0)