diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 0000000..9306010 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,22 @@ +package keyring + +// Provider defines the interface for token storage +type Provider interface { + // Set stores a token for the given service and user + Set(service, user, token string) error + // Get retrieves a token for the given service and user + Get(service, user string) (string, error) +} + +// provider is the platform-specific implementation +var provider Provider + +// Set stores a token using the platform-specific provider +func Set(service, user, token string) error { + return provider.Set(service, user, token) +} + +// Get retrieves a token using the platform-specific provider +func Get(service, user string) (string, error) { + return provider.Get(service, user) +} diff --git a/internal/keyring/keyring_darwin.go b/internal/keyring/keyring_darwin.go new file mode 100644 index 0000000..9c1e751 --- /dev/null +++ b/internal/keyring/keyring_darwin.go @@ -0,0 +1,24 @@ +//go:build darwin + +package keyring + +import ( + "github.com/zalando/go-keyring" +) + +func init() { + provider = &systemKeyringProvider{} +} + +// systemKeyringProvider implements token storage using the system keyring +type systemKeyringProvider struct{} + +// Set stores a token in the system keyring +func (s *systemKeyringProvider) Set(service, user, token string) error { + return keyring.Set(service, user, token) +} + +// Get retrieves a token from the system keyring +func (s *systemKeyringProvider) Get(service, user string) (string, error) { + return keyring.Get(service, user) +} diff --git a/internal/keyring/keyring_linux.go b/internal/keyring/keyring_linux.go new file mode 100644 index 0000000..3bdfe20 --- /dev/null +++ b/internal/keyring/keyring_linux.go @@ -0,0 +1,96 @@ +//go:build linux + +package keyring + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +const tokenFile = "token" + +func init() { + provider = &fileProvider{} +} + +// fileProvider implements token storage using files +type fileProvider struct{} + +// Set stores a token in a file +func (f *fileProvider) Set(service, user, token string) error { + tokenPath, err := getTokenFilePath() + if err != nil { + return err + } + + // Create config directory if it doesn't exist + configDirPath := filepath.Dir(tokenPath) + if err := os.MkdirAll(configDirPath, 0700); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Store as a simple key-value map + tokens := make(map[string]string) + + // Try to load existing tokens + if data, err := os.ReadFile(tokenPath); err == nil { + _ = json.Unmarshal(data, &tokens) + } + + // Add or update token for this user + tokens[user] = token + + // Save to file + data, err := json.MarshalIndent(tokens, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal tokens: %w", err) + } + + // Write with 0600 permissions (only owner can read/write) + if err := os.WriteFile(tokenPath, data, 0600); err != nil { + return fmt.Errorf("failed to write token file: %w", err) + } + + return nil +} + +// Get retrieves a token from a file +func (f *fileProvider) Get(service, user string) (string, error) { + tokenPath, err := getTokenFilePath() + if err != nil { + return "", err + } + + data, err := os.ReadFile(tokenPath) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("token not found") + } + return "", fmt.Errorf("failed to read token file: %w", err) + } + + tokens := make(map[string]string) + if err := json.Unmarshal(data, &tokens); err != nil { + return "", fmt.Errorf("failed to parse token file: %w", err) + } + + token, ok := tokens[user] + if !ok { + return "", fmt.Errorf("token not found for user: %s", user) + } + + return token, nil +} + +// getTokenFilePath returns the path to the token file +func getTokenFilePath() (string, error) { + configDirPath, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("failed to get config directory: %w", err) + } + + tokenPath := filepath.Join(configDirPath, "slack-cli", tokenFile) + return tokenPath, nil +} diff --git a/internal/keyring/keyring_test.go b/internal/keyring/keyring_test.go new file mode 100644 index 0000000..687d2fb --- /dev/null +++ b/internal/keyring/keyring_test.go @@ -0,0 +1,153 @@ +package keyring + +import ( + "os" + "path/filepath" + "testing" +) + +// TestSetGet tests basic set and get operations +func TestSetGet(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override the config directory + origConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { + if origConfigDir != "" { + os.Setenv("XDG_CONFIG_HOME", origConfigDir) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }() + + testService := "test-service" + testUser := "test-user" + testToken := "test-token-12345" + + // Test Set + err := Set(testService, testUser, testToken) + if err != nil { + t.Fatalf("Failed to set token: %v", err) + } + + // Test Get + retrievedToken, err := Get(testService, testUser) + if err != nil { + t.Fatalf("Failed to get token: %v", err) + } + + if retrievedToken != testToken { + t.Errorf("Expected token %q, got %q", testToken, retrievedToken) + } +} + +// TestMultipleUsers tests storing tokens for multiple users +func TestMultipleUsers(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override the config directory + origConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { + if origConfigDir != "" { + os.Setenv("XDG_CONFIG_HOME", origConfigDir) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }() + + testService := "test-service" + users := map[string]string{ + "user1": "token1", + "user2": "token2", + "user3": "token3", + } + + // Set all tokens + for user, token := range users { + err := Set(testService, user, token) + if err != nil { + t.Fatalf("Failed to set token for %s: %v", user, err) + } + } + + // Get and verify all tokens + for user, expectedToken := range users { + retrievedToken, err := Get(testService, user) + if err != nil { + t.Fatalf("Failed to get token for %s: %v", user, err) + } + if retrievedToken != expectedToken { + t.Errorf("For user %s, expected token %q, got %q", user, expectedToken, retrievedToken) + } + } +} + +// TestGetNotFound tests error handling when token doesn't exist +func TestGetNotFound(t *testing.T) { + // Create a temporary directory for testing + tmpDir := t.TempDir() + + // Override the config directory + origConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { + if origConfigDir != "" { + os.Setenv("XDG_CONFIG_HOME", origConfigDir) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }() + + // Try to get from non-existent file + _, err := Get("test-service", "nonexistent-user") + if err == nil { + t.Error("Expected error when getting non-existent token, got nil") + } +} + +// TestFilePermissions tests that token file has correct permissions (Linux only) +func TestFilePermissions(t *testing.T) { + // Skip on non-Linux platforms since file implementation is Linux-specific + if provider == nil { + t.Skip("Skipping file permissions test on non-Linux platform") + } + + // Check if provider is fileProvider (Linux) + if _, ok := provider.(*fileProvider); !ok { + t.Skip("Skipping file permissions test on non-Linux platform") + } + + tmpDir := t.TempDir() + + origConfigDir := os.Getenv("XDG_CONFIG_HOME") + os.Setenv("XDG_CONFIG_HOME", tmpDir) + defer func() { + if origConfigDir != "" { + os.Setenv("XDG_CONFIG_HOME", origConfigDir) + } else { + os.Unsetenv("XDG_CONFIG_HOME") + } + }() + + // Set a token + err := Set("test-service", "test-user", "test-token") + if err != nil { + t.Fatalf("Failed to set token: %v", err) + } + + // Check file permissions + tokenPath := filepath.Join(tmpDir, "slack-cli", "token") + info, err := os.Stat(tokenPath) + if err != nil { + t.Fatalf("Failed to stat token file: %v", err) + } + + expectedPerm := os.FileMode(0600) + if info.Mode().Perm() != expectedPerm { + t.Errorf("Expected file permissions %v, got %v", expectedPerm, info.Mode().Perm()) + } +} diff --git a/internal/keyring/keyring_windows.go b/internal/keyring/keyring_windows.go new file mode 100644 index 0000000..5e3a410 --- /dev/null +++ b/internal/keyring/keyring_windows.go @@ -0,0 +1,24 @@ +//go:build windows + +package keyring + +import ( + "github.com/zalando/go-keyring" +) + +func init() { + provider = &systemKeyringProvider{} +} + +// systemKeyringProvider implements token storage using the system keyring +type systemKeyringProvider struct{} + +// Set stores a token in the system keyring +func (s *systemKeyringProvider) Set(service, user, token string) error { + return keyring.Set(service, user, token) +} + +// Get retrieves a token from the system keyring +func (s *systemKeyringProvider) Get(service, user string) (string, error) { + return keyring.Get(service, user) +} diff --git a/main.go b/main.go index ce9be2a..544334a 100644 --- a/main.go +++ b/main.go @@ -10,8 +10,8 @@ import ( "strings" "syscall" + "github.com/kitproj/slack-cli/internal/keyring" "github.com/slack-go/slack" - "github.com/zalando/go-keyring" "golang.org/x/term" )