diff --git a/internal/app/app.go b/internal/app/app.go index 205e8e2..98ed56c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1484,8 +1484,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { // Connection failed - clear pending password (don't save wrong password) a.pendingPasswordSave = nil - a.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s:%d\n\nError: %v", - msg.Config.Host, msg.Config.Port, msg.Err)) + a.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s\n\nError: %v", + msg.Config.DisplayTarget(), msg.Err)) return a, nil } @@ -2099,11 +2099,7 @@ func (a *App) renderNormalView() string { if a.state.ActiveConnection != nil { // Build connection string with elegant formatting conn := a.state.ActiveConnection - connStr := fmt.Sprintf("%s@%s:%d/%s", - conn.Config.User, - conn.Config.Host, - conn.Config.Port, - conn.Config.Database) + connStr := conn.Config.ConnectionLabel() connStatus = " " + styles.connGreen.Render("") + " " + styles.connText.Render(connStr) } else { @@ -3160,17 +3156,7 @@ func (a *App) connectToHistoryEntry(entry models.ConnectionHistoryEntry) (tea.Mo // connectToDiscoveredInstance connects using a discovered instance func (a *App) connectToDiscoveredInstance(instance models.DiscoveredInstance) (tea.Model, tea.Cmd) { - // Create connection config from discovered instance - config := models.ConnectionConfig{ - Host: instance.Host, - Port: instance.Port, - Database: "postgres", // Default database - User: os.Getenv("USER"), // Current user - Password: "", // No password for now - SSLMode: "prefer", - } - - return a.performConnection(config) + return a.performConnection(discovery.BuildConnectionConfig(instance)) } // performConnection starts an async connection attempt @@ -3325,56 +3311,21 @@ func (a *App) handleConnectionDialog(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a.performConnection(config) - } else { - var config models.ConnectionConfig - - // Check if browsing history or discovered instances - if a.connectionDialog.InHistorySection { - // Get selected history entry - historyEntry := a.connectionDialog.GetSelectedHistory() - if historyEntry == nil { - // No history entry selected - return a, nil - } - - // Convert history entry to connection config WITH password from keyring - if a.connectionHistory != nil { - result := a.connectionHistory.GetConnectionConfigWithPassword(historyEntry) - config = result.Config - - // If password is missing, show password dialog - if result.PasswordMissing { - entryCopy := *historyEntry - a.pendingConnectionInfo = &entryCopy - a.passwordDialog.SetConnectionInfo(historyEntry.Host, historyEntry.Port, historyEntry.Database, historyEntry.User) - a.showPasswordDialog = true - a.showConnectionDialog = false - return a, a.passwordDialog.Init() - } - } else { - config = historyEntry.ToConnectionConfig() - } - } else { - // Get selected discovered instance - instance := a.connectionDialog.GetSelectedInstance() - if instance == nil { - // No instance selected - return a, nil - } + } - // Create connection config from discovered instance - config = models.ConnectionConfig{ - Host: instance.Host, - Port: instance.Port, - Database: "postgres", - User: os.Getenv("USER"), - Password: "", - SSLMode: "prefer", - } + if a.connectionDialog.InHistorySection { + historyEntry := a.connectionDialog.GetSelectedHistory() + if historyEntry == nil { + return a, nil } + return a.connectToHistoryEntry(*historyEntry) + } - return a.performConnection(config) + instance := a.connectionDialog.GetSelectedInstance() + if instance == nil { + return a, nil } + return a.connectToDiscoveredInstance(*instance) default: // In manual mode, delegate to textinput for cursor and text handling @@ -3653,12 +3604,7 @@ func (a *App) renderConnectingOverlay() string { hostStyle := lipgloss.NewStyle().Foreground(a.theme.Foreground) hintStyle := lipgloss.NewStyle().Foreground(a.theme.Metadata) - hostInfo := fmt.Sprintf("%s@%s:%d/%s", - a.connectingConfig.User, - a.connectingConfig.Host, - a.connectingConfig.Port, - a.connectingConfig.Database, - ) + hostInfo := a.connectingConfig.ConnectionLabel() // Build each line separately line1 := a.executeSpinner.View() + " " + loadingStyle.Render("Connecting...") + " " + elapsedStyle.Render(elapsedStr) diff --git a/internal/app/delegates/connection.go b/internal/app/delegates/connection.go index 79be5cc..10c4ff2 100644 --- a/internal/app/delegates/connection.go +++ b/internal/app/delegates/connection.go @@ -71,8 +71,8 @@ func (d *ConnectionDelegate) handleConnectionResult(msg messages.ConnectionResul if msg.Err != nil { // Connection failed - clear pending password (don't save wrong password) app.ClearPendingPasswordSave() - app.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s:%d\n\nError: %v", - msg.Config.Host, msg.Config.Port, msg.Err)) + app.ShowError("Connection Failed", fmt.Sprintf("Could not connect to %s\n\nError: %v", + msg.Config.DisplayTarget(), msg.Err)) return true, nil } diff --git a/internal/connection_history/manager.go b/internal/connection_history/manager.go index ef9e7a6..b09e6fe 100644 --- a/internal/connection_history/manager.go +++ b/internal/connection_history/manager.go @@ -145,7 +145,7 @@ func (m *Manager) Add(config models.ConnectionConfig) (*AddResult, error) { // Create new entry name := config.Name if name == "" { - name = fmt.Sprintf("%s@%s:%d/%s", config.User, config.Host, config.Port, config.Database) + name = config.ConnectionLabel() } entry := models.ConnectionHistoryEntry{ diff --git a/internal/connection_history/password_store.go b/internal/connection_history/password_store.go index 602f4f2..3ef610c 100644 --- a/internal/connection_history/password_store.go +++ b/internal/connection_history/password_store.go @@ -7,6 +7,7 @@ import ( "runtime" "github.com/99designs/keyring" + "github.com/pgplex/pgtui/internal/models" ) const serviceName = "pgtui" @@ -113,7 +114,7 @@ func (ps *PasswordStore) Save(host string, port int, database, user, password st err := ps.ring.Set(keyring.Item{ Key: key, Data: []byte(password), - Label: fmt.Sprintf("pgtui: %s@%s:%d/%s", user, host, port, database), + Label: "pgtui: " + (models.ConnectionConfig{Host: host, Port: port, Database: database, User: user}).ConnectionLabel(), Description: "PostgreSQL connection password for pgtui", }) if err != nil { diff --git a/internal/db/connection/manager.go b/internal/db/connection/manager.go index 3160d9a..86356ff 100644 --- a/internal/db/connection/manager.go +++ b/internal/db/connection/manager.go @@ -179,5 +179,5 @@ func generateConnectionID(config models.ConnectionConfig) string { if config.Name != "" { return config.Name } - return fmt.Sprintf("%s@%s:%d/%s", config.User, config.Host, config.Port, config.Database) + return config.ConnectionLabel() } diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go new file mode 100644 index 0000000..9cbc96c --- /dev/null +++ b/internal/db/discovery/config.go @@ -0,0 +1,87 @@ +package discovery + +import ( + "os" + "strings" + + "github.com/pgplex/pgtui/internal/models" +) + +// BuildConnectionConfig turns a discovered instance into a connection config. +// The returned config intentionally omits the password for environment and +// .pgpass sources: libpq (pgx) reads PGPASSWORD and ~/.pgpass itself, and +// leaving the password empty prevents it from being persisted to the keyring +// (those secrets already have their own source). +func BuildConnectionConfig(instance models.DiscoveredInstance) models.ConnectionConfig { + switch instance.Source { + case models.SourceEnvironment: + if envConfig := GetEnvironmentConfig(); envConfig != nil { + config := *envConfig + config.Name = "" // avoid leaking the generic "Environment" label into connection ID/history + config.Password = "" + return config + } + case models.SourcePgPass: + if pgpassConfig := buildPgPassConfig(instance.Host, instance.Port); pgpassConfig != nil { + return *pgpassConfig + } + } + + return buildDefaultConfig(instance) +} + +// buildPgPassConfig maps a .pgpass entry to connection fields. Password is left +// empty: libpq reads ~/.pgpass itself, and omitting it here keeps secrets out of +// the keyring (same rationale as BuildConnectionConfig for env/.pgpass). +func buildPgPassConfig(host string, port int) *models.ConnectionConfig { + entries, err := ParsePgPass() + if err != nil { + return nil + } + + for _, entry := range entries { + if entry.Host != host || entry.Port != port { + continue + } + + user := entry.User + if user == "" || user == "*" { + user = defaultUser() + } + + database := entry.Database + if database == "" || database == "*" { + database = user + } + + return &models.ConnectionConfig{ + Host: host, + Port: port, + Database: database, + User: user, + SSLMode: "prefer", + } + } + + return nil +} + +func buildDefaultConfig(instance models.DiscoveredInstance) models.ConnectionConfig { + return models.ConnectionConfig{ + Host: instance.Host, + Port: instance.Port, + Database: "postgres", + User: defaultUser(), + SSLMode: "prefer", + } +} + +func defaultUser() string { + for _, key := range []string{"PGUSER", "USER", "USERNAME"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + + return "postgres" +} diff --git a/internal/db/discovery/discovery.go b/internal/db/discovery/discovery.go index 90001c3..56ad7d8 100644 --- a/internal/db/discovery/discovery.go +++ b/internal/db/discovery/discovery.go @@ -29,34 +29,47 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc instances = append(instances, *envInstance) } - // 2. Scan localhost ports - localInstances := d.scanner.ScanLocalhost(ctx) - instances = append(instances, localInstances...) + // 2. Scan common Unix socket directories + instances = append(instances, d.scanner.ScanUnixSockets(ctx)...) - // 3. Parse .pgpass - pgpassInstances := GetDiscoveredInstances() - instances = append(instances, pgpassInstances...) + // 3. Scan localhost ports + instances = append(instances, d.scanner.ScanLocalhost(ctx)...) + + // 4. Parse .pgpass + instances = append(instances, GetDiscoveredInstances()...) // Deduplicate instances = deduplicateInstances(instances) // Sort by source priority - sort.Slice(instances, func(i, j int) bool { - return instances[i].Source < instances[j].Source - }) + sortDiscoveredInstances(instances) return instances } -// deduplicateInstances removes duplicate host:port combinations +func sortDiscoveredInstances(instances []models.DiscoveredInstance) { + sort.Slice(instances, func(i, j int) bool { + if instances[i].Source != instances[j].Source { + return discoverySourcePriority(instances[i].Source) < discoverySourcePriority(instances[j].Source) + } + + if instances[i].Host != instances[j].Host { + return instances[i].Host < instances[j].Host + } + + return instances[i].Port < instances[j].Port + }) +} + +// deduplicateInstances removes duplicate connection targets. func deduplicateInstances(instances []models.DiscoveredInstance) []models.DiscoveredInstance { seen := make(map[string]models.DiscoveredInstance) for _, instance := range instances { - key := instance.Host + ":" + strconv.Itoa(instance.Port) + key := instanceKey(instance) // Keep the one with higher priority source - if existing, exists := seen[key]; !exists || instance.Source < existing.Source { + if existing, exists := seen[key]; !exists || discoverySourcePriority(instance.Source) < discoverySourcePriority(existing.Source) { seen[key] = instance } } @@ -68,3 +81,31 @@ func deduplicateInstances(instances []models.DiscoveredInstance) []models.Discov return result } + +func instanceKey(instance models.DiscoveredInstance) string { + host := instance.Host + if instance.UsesUnixSocket() { + host = socketDirKey(host) + } + + return host + ":" + strconv.Itoa(instance.Port) +} + +func discoverySourcePriority(source models.DiscoverySource) int { + switch source { + case models.SourceEnvironment: + return 0 + case models.SourcePgPass: + return 1 + case models.SourcePgService: + return 2 + case models.SourceConfig: + return 3 + case models.SourceUnixSocket: + return 4 + case models.SourcePortScan: + return 5 + default: + return 100 + } +} diff --git a/internal/db/discovery/environment.go b/internal/db/discovery/environment.go index 35415b0..178c985 100644 --- a/internal/db/discovery/environment.go +++ b/internal/db/discovery/environment.go @@ -2,25 +2,23 @@ package discovery import ( "os" - "strconv" + "strings" "github.com/pgplex/pgtui/internal/models" ) // ParseEnvironment reads PostgreSQL environment variables func ParseEnvironment() *models.DiscoveredInstance { - host := os.Getenv("PGHOST") - portStr := os.Getenv("PGPORT") + host := envString("PGHOST") + portStr := envString("PGPORT") if host == "" { return nil } port := 5432 - if portStr != "" { - if p, err := strconv.Atoi(portStr); err == nil && p > 0 && p <= 65535 { - port = p - } + if p, ok := validPort(portStr); ok { + port = p } return &models.DiscoveredInstance{ @@ -33,12 +31,13 @@ func ParseEnvironment() *models.DiscoveredInstance { // GetEnvironmentConfig gets connection config from environment func GetEnvironmentConfig() *models.ConnectionConfig { - host := os.Getenv("PGHOST") - portStr := os.Getenv("PGPORT") - database := os.Getenv("PGDATABASE") - user := os.Getenv("PGUSER") + host := envString("PGHOST") + portStr := envString("PGPORT") + database := envString("PGDATABASE") + user := envString("PGUSER") + // Password is not trimmed: leading/trailing spaces can be intentional. password := os.Getenv("PGPASSWORD") - sslMode := os.Getenv("PGSSLMODE") + sslMode := envString("PGSSLMODE") if host == "" && database == "" && user == "" { return nil @@ -49,17 +48,15 @@ func GetEnvironmentConfig() *models.ConnectionConfig { host = "localhost" } if user == "" { - user = os.Getenv("USER") + user = defaultUser() } if database == "" { database = user } port := 5432 - if portStr != "" { - if p, err := strconv.Atoi(portStr); err == nil && p > 0 && p <= 65535 { - port = p - } + if p, ok := validPort(portStr); ok { + port = p } if sslMode == "" { @@ -76,3 +73,7 @@ func GetEnvironmentConfig() *models.ConnectionConfig { SSLMode: sslMode, } } + +func envString(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} diff --git a/internal/db/discovery/scanner.go b/internal/db/discovery/scanner.go index 83d60c2..78c6662 100644 --- a/internal/db/discovery/scanner.go +++ b/internal/db/discovery/scanner.go @@ -2,8 +2,8 @@ package discovery import ( "context" - "fmt" "net" + "strconv" "sync" "time" @@ -73,7 +73,7 @@ func (s *Scanner) scanPort(ctx context.Context, host string, port int) models.Di } start := time.Now() - address := fmt.Sprintf("%s:%d", host, port) + address := net.JoinHostPort(host, strconv.Itoa(port)) dialer := &net.Dialer{ Timeout: s.timeout, diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go new file mode 100644 index 0000000..ff85160 --- /dev/null +++ b/internal/db/discovery/unix_socket.go @@ -0,0 +1,234 @@ +package discovery + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/pgplex/pgtui/internal/models" +) + +var defaultUnixSocketDirs = []string{ + "/var/run/postgresql", + "/run/postgresql", + "/tmp", + "/private/tmp", + "/var/pgsql_socket", + "/private/var/run/postgresql", + "/opt/homebrew/var/run/postgresql", + "/usr/local/var/run/postgresql", +} + +var broadUnixSocketDirs = map[string]struct{}{ + "/tmp": {}, + "/private/tmp": {}, +} + +// ScanUnixSockets scans common PostgreSQL socket directories. +func (s *Scanner) ScanUnixSockets(ctx context.Context) []models.DiscoveredInstance { + if runtime.GOOS == "windows" { + return nil + } + + return s.ScanUnixSocketDirs(ctx, candidateUnixSocketDirs()) +} + +// ScanUnixSocketDirs scans the provided directories for PostgreSQL socket files. +func (s *Scanner) ScanUnixSocketDirs(ctx context.Context, dirs []string) []models.DiscoveredInstance { + if runtime.GOOS == "windows" { + return nil + } + + instances := make([]models.DiscoveredInstance, 0) + seen := make(map[string]struct{}) + + for _, dir := range uniqueSocketDirs(dirs) { + if ctx.Err() != nil { + break + } + + for _, instance := range s.scanUnixSocketDir(ctx, dir) { + key := instanceKey(instance) + if _, exists := seen[key]; exists { + continue + } + + seen[key] = struct{}{} + instances = append(instances, instance) + } + } + + return instances +} + +func (s *Scanner) scanUnixSocketDir(ctx context.Context, dir string) []models.DiscoveredInstance { + if isBroadUnixSocketDir(dir) { + return s.scanKnownUnixSocketPorts(ctx, dir) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + + instances := make([]models.DiscoveredInstance, 0) + for _, entry := range entries { + if ctx.Err() != nil { + break + } + + port, ok := postgresSocketPort(entry.Name()) + if !ok { + continue + } + + instance := s.scanUnixSocket(ctx, dir, port) + if instance.Available { + instances = append(instances, instance) + } + } + + return instances +} + +func (s *Scanner) scanKnownUnixSocketPorts(ctx context.Context, dir string) []models.DiscoveredInstance { + ports := candidateUnixSocketPorts() + instances := make([]models.DiscoveredInstance, 0, len(ports)) + + for _, port := range ports { + if ctx.Err() != nil { + break + } + + instance := s.scanUnixSocket(ctx, dir, port) + if instance.Available { + instances = append(instances, instance) + } + } + + return instances +} + +func (s *Scanner) scanUnixSocket(ctx context.Context, dir string, port int) models.DiscoveredInstance { + instance := models.DiscoveredInstance{ + Host: dir, + Port: port, + Source: models.SourceUnixSocket, + } + + start := time.Now() + socketPath := filepath.Join(dir, fmt.Sprintf(".s.PGSQL.%d", port)) + + dialer := &net.Dialer{Timeout: s.timeout} + conn, err := dialer.DialContext(ctx, "unix", socketPath) + instance.ResponseTime = time.Since(start) + if err != nil { + return instance + } + + _ = conn.Close() + instance.Available = true + return instance +} + +func candidateUnixSocketDirs() []string { + pgHosts := strings.Split(os.Getenv("PGHOST"), ",") + dirs := make([]string, 0, len(defaultUnixSocketDirs)+len(pgHosts)) + + for _, host := range pgHosts { + host = strings.TrimSpace(host) + if strings.HasPrefix(host, "/") { + dirs = append(dirs, host) + } + } + + dirs = append(dirs, defaultUnixSocketDirs...) + return dirs +} + +func uniqueSocketDirs(dirs []string) []string { + unique := make([]string, 0, len(dirs)) + seen := make(map[string]struct{}) + + for _, dir := range dirs { + dir = strings.TrimSpace(dir) + if dir == "" { + continue + } + dir = filepath.Clean(dir) + + key := socketDirKey(dir) + if _, exists := seen[key]; exists { + continue + } + + seen[key] = struct{}{} + unique = append(unique, dir) + } + + return unique +} + +func socketDirKey(dir string) string { + resolved, err := filepath.EvalSymlinks(dir) + if err != nil { + return dir + } + + return filepath.Clean(resolved) +} + +func isBroadUnixSocketDir(dir string) bool { + _, ok := broadUnixSocketDirs[socketDirKey(filepath.Clean(dir))] + return ok +} + +func candidateUnixSocketPorts() []int { + ports := append([]int(nil), DefaultPorts...) + + if port, ok := validPort(os.Getenv("PGPORT")); ok { + ports = append([]int{port}, ports...) + } + + return uniquePorts(ports) +} + +func postgresSocketPort(name string) (int, bool) { + if !strings.HasPrefix(name, ".s.PGSQL.") { + return 0, false + } + + portStr := strings.TrimPrefix(name, ".s.PGSQL.") + if portStr == "" || strings.Contains(portStr, ".") { + return 0, false + } + + return validPort(portStr) +} + +func validPort(value string) (int, bool) { + port, err := strconv.Atoi(strings.TrimSpace(value)) + return port, err == nil && port >= 1 && port <= 65535 +} + +func uniquePorts(ports []int) []int { + unique := make([]int, 0, len(ports)) + seen := make(map[int]struct{}, len(ports)) + + for _, port := range ports { + if _, exists := seen[port]; exists { + continue + } + + seen[port] = struct{}{} + unique = append(unique, port) + } + + return unique +} diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go new file mode 100644 index 0000000..025ee88 --- /dev/null +++ b/internal/db/discovery/unix_socket_test.go @@ -0,0 +1,331 @@ +package discovery + +import ( + "context" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "testing" + "time" + + "github.com/pgplex/pgtui/internal/models" +) + +func TestScanUnixSocketDirs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not supported on windows") + } + + tempDir := t.TempDir() + acceptDone := listenPostgresSocket(t, tempDir, 6543) + + scanner := NewScanner() + instances := scanner.ScanUnixSocketDirs(context.Background(), []string{tempDir}) + + if len(instances) != 1 { + t.Fatalf("expected 1 discovered socket, got %d", len(instances)) + } + + instance := instances[0] + if instance.Host != tempDir { + t.Fatalf("expected host %q, got %q", tempDir, instance.Host) + } + if instance.Port != 6543 { + t.Fatalf("expected port 6543, got %d", instance.Port) + } + if instance.Source != models.SourceUnixSocket { + t.Fatalf("expected source %v, got %v", models.SourceUnixSocket, instance.Source) + } + if !instance.Available { + t.Fatal("expected discovered socket to be available") + } + + select { + case <-acceptDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for socket accept") + } +} + +func TestBuildConnectionConfigForSocketDefaults(t *testing.T) { + t.Setenv("PGUSER", "") + t.Setenv("USER", "socket-user") + t.Setenv("USERNAME", "") + + config := BuildConnectionConfig(models.DiscoveredInstance{ + Host: "/tmp", + Port: 5432, + Source: models.SourceUnixSocket, + }) + + if config.Host != "/tmp" { + t.Fatalf("expected host /tmp, got %q", config.Host) + } + if config.Port != 5432 { + t.Fatalf("expected port 5432, got %d", config.Port) + } + if config.User != "socket-user" { + t.Fatalf("expected user socket-user, got %q", config.User) + } + if config.Database != "postgres" { + t.Fatalf("expected database postgres, got %q", config.Database) + } + if config.SSLMode != "prefer" { + t.Fatalf("expected sslmode prefer, got %q", config.SSLMode) + } + if !config.UsesUnixSocket() { + t.Fatal("expected config to use a unix socket") + } + if got := config.DisplayTarget(); got != filepath.Join("/tmp", ".s.PGSQL.5432") { + t.Fatalf("unexpected display target %q", got) + } +} + +func TestGetEnvironmentConfigUsesUsernameFallback(t *testing.T) { + t.Setenv("PGHOST", "localhost") + t.Setenv("PGPORT", "") + t.Setenv("PGDATABASE", "") + t.Setenv("PGUSER", "") + t.Setenv("PGPASSWORD", "") + t.Setenv("PGSSLMODE", "") + t.Setenv("USER", "") + t.Setenv("USERNAME", "windows-user") + + config := GetEnvironmentConfig() + if config == nil { + t.Fatal("expected environment config") + } + if config.User != "windows-user" { + t.Fatalf("expected user windows-user, got %q", config.User) + } + if config.Database != "windows-user" { + t.Fatalf("expected database windows-user, got %q", config.Database) + } + if config.SSLMode != "prefer" { + t.Fatalf("expected sslmode prefer, got %q", config.SSLMode) + } +} + +func TestBuildConnectionConfigForEnvironmentUsesEnvironmentConfig(t *testing.T) { + t.Setenv("PGHOST", " localhost ") + t.Setenv("PGPORT", " 5432 ") + t.Setenv("PGDATABASE", " envdb ") + t.Setenv("PGUSER", " envuser ") + t.Setenv("PGPASSWORD", "secret") + t.Setenv("PGSSLMODE", " require ") + + config := BuildConnectionConfig(models.DiscoveredInstance{ + Host: "localhost", + Port: 5432, + Source: models.SourceEnvironment, + }) + + if config.Host != "localhost" { + t.Fatalf("expected trimmed environment host, got %q", config.Host) + } + if config.Port != 5432 { + t.Fatalf("expected environment port 5432, got %d", config.Port) + } + if config.Database != "envdb" { + t.Fatalf("expected trimmed environment database, got %q", config.Database) + } + if config.User != "envuser" { + t.Fatalf("expected trimmed environment user, got %q", config.User) + } + // Password is intentionally left empty: libpq reads PGPASSWORD from the + // environment, and keeping it out of the config prevents persisting it to + // the keyring. + if config.Password != "" { + t.Fatalf("expected empty password (sourced via PGPASSWORD), got %q", config.Password) + } + if config.Name != "" { + t.Fatalf("expected empty name to avoid leaking into connection ID/history, got %q", config.Name) + } + if config.SSLMode != "require" { + t.Fatalf("expected trimmed environment sslmode, got %q", config.SSLMode) + } +} + +func TestParseEnvironmentTrimsHost(t *testing.T) { + t.Setenv("PGHOST", " /var/run/postgresql ") + t.Setenv("PGPORT", " 5433 ") + + instance := ParseEnvironment() + if instance == nil { + t.Fatal("expected environment instance") + } + if instance.Host != "/var/run/postgresql" { + t.Fatalf("expected trimmed host, got %q", instance.Host) + } + if instance.Port != 5433 { + t.Fatalf("expected port 5433, got %d", instance.Port) + } +} + +func TestCandidateUnixSocketDirsSplitsPGHost(t *testing.T) { + t.Setenv("PGHOST", "/custom/socket,localhost, /tmp ") + + dirs := candidateUnixSocketDirs() + if len(dirs) < 2 { + t.Fatalf("expected PGHOST dirs plus defaults, got %#v", dirs) + } + if dirs[0] != "/custom/socket" { + t.Fatalf("expected first custom socket dir, got %#v", dirs) + } + if dirs[1] != "/tmp" { + t.Fatalf("expected second custom socket dir, got %#v", dirs) + } +} + +func TestUniqueSocketDirsNormalizesAndResolvesSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated permissions on some windows setups") + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "target") + link := filepath.Join(tempDir, "link") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink target: %v", err) + } + + dirs := uniqueSocketDirs([]string{ + filepath.Join(target, "."), + target + string(os.PathSeparator), + link, + }) + + if len(dirs) != 1 { + t.Fatalf("expected symlinked dirs to deduplicate, got %#v", dirs) + } + if dirs[0] != target { + t.Fatalf("expected cleaned target dir, got %q", dirs[0]) + } +} + +func TestInstanceKeyNormalizesSocketSymlinks(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated permissions on some windows setups") + } + + tempDir := t.TempDir() + target := filepath.Join(tempDir, "target") + link := filepath.Join(tempDir, "link") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatalf("mkdir target: %v", err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatalf("symlink target: %v", err) + } + + targetInstance := models.DiscoveredInstance{Host: target, Port: 5432} + linkInstance := models.DiscoveredInstance{Host: link, Port: 5432} + if instanceKey(targetInstance) != instanceKey(linkInstance) { + t.Fatalf("expected symlinked socket dirs to share an instance key") + } + if targetInstance.DisplayTarget() == linkInstance.DisplayTarget() { + t.Fatalf("expected display targets to preserve original paths") + } +} + +func TestDeduplicateInstancesPrefersCredentialSources(t *testing.T) { + instances := deduplicateInstances([]models.DiscoveredInstance{ + {Host: "localhost", Port: 5432, Source: models.SourcePortScan}, + {Host: "localhost", Port: 5432, Source: models.SourcePgPass}, + {Host: "localhost", Port: 5432, Source: models.SourceEnvironment}, + }) + + if len(instances) != 1 { + t.Fatalf("expected 1 deduplicated instance, got %d", len(instances)) + } + if instances[0].Source != models.SourceEnvironment { + t.Fatalf("expected environment source to win, got %v", instances[0].Source) + } +} + +func TestDiscoverAllSortsByDiscoveryPriority(t *testing.T) { + instances := []models.DiscoveredInstance{ + {Host: "localhost", Port: 5432, Source: models.SourcePortScan}, + {Host: "localhost", Port: 5433, Source: models.SourceEnvironment}, + } + + sortDiscoveredInstances(instances) + + if instances[0].Source != models.SourceEnvironment { + t.Fatalf("expected environment source first, got %v", instances[0].Source) + } +} + +func TestScanBroadUnixSocketDirChecksKnownPortsOnly(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not supported on windows") + } + t.Setenv("PGPORT", "") + + oldBroadDirs := broadUnixSocketDirs + tempDir, err := os.MkdirTemp("/tmp", "pgtui-socket-test-") + if err != nil { + t.Fatalf("create temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.RemoveAll(tempDir) + }) + + broadUnixSocketDirs = map[string]struct{}{socketDirKey(tempDir): {}} + t.Cleanup(func() { + broadUnixSocketDirs = oldBroadDirs + }) + + listenPostgresSocket(t, tempDir, 6543) + + scanner := NewScanner() + instances := scanner.scanUnixSocketDir(context.Background(), tempDir) + if len(instances) != 0 { + t.Fatalf("expected broad scan to ignore non-candidate port, got %#v", instances) + } +} + +func TestCandidateUnixSocketPortsIncludesPGPort(t *testing.T) { + t.Setenv("PGPORT", "6543") + + ports := candidateUnixSocketPorts() + if len(ports) == 0 || ports[0] != 6543 { + t.Fatalf("expected PGPORT first, got %#v", ports) + } +} + +func listenPostgresSocket(t *testing.T, dir string, port int) <-chan struct{} { + t.Helper() + + socketPath := filepath.Join(dir, ".s.PGSQL."+strconv.Itoa(port)) + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen on unix socket: %v", err) + } + // Bound Accept so a missed dial cannot hang the test forever. + if unixListener, ok := listener.(*net.UnixListener); ok { + if err := unixListener.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + _ = listener.Close() + t.Fatalf("set unix listener deadline: %v", err) + } + } + t.Cleanup(func() { + _ = listener.Close() + }) + + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + conn, err := listener.Accept() + if err == nil { + _ = conn.Close() + } + }() + + return acceptDone +} diff --git a/internal/models/connection.go b/internal/models/connection.go index 2fb5e29..d0e5664 100644 --- a/internal/models/connection.go +++ b/internal/models/connection.go @@ -1,6 +1,11 @@ package models import ( + "fmt" + "net" + "path/filepath" + "strconv" + "strings" "time" ) @@ -15,6 +20,26 @@ type ConnectionConfig struct { SSLMode string `yaml:"ssl_mode"` } +// UsesUnixSocket returns true when the host represents a Unix socket directory. +func (c ConnectionConfig) UsesUnixSocket() bool { + return isUnixSocketHost(c.Host) +} + +// DisplayTarget returns a user-friendly connection target. +func (c ConnectionConfig) DisplayTarget() string { + return c.Endpoint() +} + +// Endpoint returns the connection endpoint formatted for labels and identifiers. +func (c ConnectionConfig) Endpoint() string { + return formatConnectionEndpoint(c.Host, c.Port) +} + +// ConnectionLabel returns a concise user/database connection label. +func (c ConnectionConfig) ConnectionLabel() string { + return fmt.Sprintf("%s@%s/%s", c.User, c.Endpoint(), c.Database) +} + // Connection represents an active database connection type Connection struct { ID string @@ -44,6 +69,16 @@ type DiscoveredInstance struct { ResponseTime time.Duration } +// UsesUnixSocket returns true when the discovered host represents a Unix socket directory. +func (d DiscoveredInstance) UsesUnixSocket() bool { + return isUnixSocketHost(d.Host) +} + +// DisplayTarget returns a user-friendly discovery target. +func (d DiscoveredInstance) DisplayTarget() string { + return formatConnectionEndpoint(d.Host, d.Port) +} + // DiscoverySource indicates how an instance was discovered type DiscoverySource int @@ -77,17 +112,17 @@ func (s DiscoverySource) String() string { // ConnectionHistoryEntry represents a saved connection from history type ConnectionHistoryEntry struct { - ID string `yaml:"id"` - Name string `yaml:"name"` // User-friendly name (auto-generated or custom) - Host string `yaml:"host"` - Port int `yaml:"port"` - Database string `yaml:"database"` - User string `yaml:"user"` + ID string `yaml:"id"` + Name string `yaml:"name"` // User-friendly name (auto-generated or custom) + Host string `yaml:"host"` + Port int `yaml:"port"` + Database string `yaml:"database"` + User string `yaml:"user"` // Note: Password is NOT stored for security reasons - SSLMode string `yaml:"ssl_mode"` - LastUsed time.Time `yaml:"last_used"` - UsageCount int `yaml:"usage_count"` - CreatedAt time.Time `yaml:"created_at"` + SSLMode string `yaml:"ssl_mode"` + LastUsed time.Time `yaml:"last_used"` + UsageCount int `yaml:"usage_count"` + CreatedAt time.Time `yaml:"created_at"` } // ToConnectionConfig converts a history entry to a ConnectionConfig (without password) @@ -102,3 +137,15 @@ func (e *ConnectionHistoryEntry) ToConnectionConfig() ConnectionConfig { SSLMode: e.SSLMode, } } + +func isUnixSocketHost(host string) bool { + return strings.HasPrefix(host, "/") +} + +func formatConnectionEndpoint(host string, port int) string { + if isUnixSocketHost(host) { + return filepath.Join(host, fmt.Sprintf(".s.PGSQL.%d", port)) + } + + return net.JoinHostPort(host, strconv.Itoa(port)) +} diff --git a/internal/models/connection_test.go b/internal/models/connection_test.go new file mode 100644 index 0000000..af56d53 --- /dev/null +++ b/internal/models/connection_test.go @@ -0,0 +1,33 @@ +package models + +import "testing" + +func TestConnectionEndpointFormatsIPv6(t *testing.T) { + config := ConnectionConfig{ + Host: "::1", + Port: 5432, + Database: "postgres", + User: "alice", + } + + if got := config.Endpoint(); got != "[::1]:5432" { + t.Fatalf("expected bracketed IPv6 endpoint, got %q", got) + } + if got := config.DisplayTarget(); got != "[::1]:5432" { + t.Fatalf("expected bracketed IPv6 display target, got %q", got) + } + if got := config.ConnectionLabel(); got != "alice@[::1]:5432/postgres" { + t.Fatalf("unexpected connection label %q", got) + } +} + +func TestConnectionEndpointFormatsUnixSocket(t *testing.T) { + config := ConnectionConfig{ + Host: "/tmp", + Port: 5432, + } + + if got := config.Endpoint(); got != "/tmp/.s.PGSQL.5432" { + t.Fatalf("unexpected unix socket endpoint %q", got) + } +} diff --git a/internal/ui/components/connection_dialog.go b/internal/ui/components/connection_dialog.go index e885117..c9b4ab4 100644 --- a/internal/ui/components/connection_dialog.go +++ b/internal/ui/components/connection_dialog.go @@ -29,9 +29,9 @@ type ConnectionDialog struct { searchInput textinput.Model // Text input fields for manual mode - inputs []textinput.Model - focusIndex int - cursorMode cursor.Mode + inputs []textinput.Model + focusIndex int + cursorMode cursor.Mode } const ( @@ -308,9 +308,8 @@ func (c *ConnectionDialog) renderDiscoveryMode(contentWidth int) string { sourceStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("#6c7086")) - line := fmt.Sprintf("%s:%d %s", - instance.Host, - instance.Port, + line := fmt.Sprintf("%s %s", + instance.DisplayTarget(), sourceStyle.Render(fmt.Sprintf("(%s)", instance.Source.String())), ) // Wrap with zone for click detection diff --git a/internal/ui/components/password_dialog.go b/internal/ui/components/password_dialog.go index 0afd01b..fac1143 100644 --- a/internal/ui/components/password_dialog.go +++ b/internal/ui/components/password_dialog.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" zone "github.com/lrstanley/bubblezone" + "github.com/pgplex/pgtui/internal/models" "github.com/pgplex/pgtui/internal/ui/theme" ) @@ -36,6 +37,7 @@ type PasswordDialog struct { input textinput.Model host string port int + endpoint string database string user string } @@ -65,6 +67,7 @@ func NewPasswordDialog(th theme.Theme) *PasswordDialog { func (p *PasswordDialog) SetConnectionInfo(host string, port int, database, user string) { p.host = host p.port = port + p.endpoint = (models.ConnectionConfig{Host: host, Port: port}).Endpoint() p.database = database p.user = user p.Title = "Password Required" @@ -143,7 +146,7 @@ func (p *PasswordDialog) View() string { // Connection info (two lines) userLine := labelStyle.Render("User: ") + valueStyle.Render(p.user) - hostLine := labelStyle.Render("Host: ") + valueStyle.Render(fmt.Sprintf("%s:%d/%s", p.host, p.port, p.database)) + hostLine := labelStyle.Render("Host: ") + valueStyle.Render(fmt.Sprintf("%s/%s", p.endpoint, p.database)) lines = append(lines, lineStyle.Render(userLine)) lines = append(lines, lineStyle.Render(hostLine)) lines = append(lines, "")