From 6e4caa5ca68d23c04090cdd92b4f88f878c8e131 Mon Sep 17 00:00:00 2001 From: XYenon Date: Tue, 31 Mar 2026 11:54:53 +0800 Subject: [PATCH 01/11] feat: add unix socket discovery and connection support Amp-Thread-ID: https://ampcode.com/threads/T-019d41f5-fe46-7119-afb7-73548b6954ed Co-authored-by: Amp --- internal/app/app.go | 22 +-- internal/app/delegates/connection.go | 4 +- internal/db/discovery/config.go | 88 +++++++++++ internal/db/discovery/discovery.go | 18 ++- internal/db/discovery/unix_socket.go | 161 ++++++++++++++++++++ internal/db/discovery/unix_socket_test.go | 94 ++++++++++++ internal/models/connection.go | 55 +++++-- internal/ui/components/connection_dialog.go | 11 +- 8 files changed, 412 insertions(+), 41 deletions(-) create mode 100644 internal/db/discovery/config.go create mode 100644 internal/db/discovery/unix_socket.go create mode 100644 internal/db/discovery/unix_socket_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 205e8e2..89f21f5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3160,17 +3160,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 @@ -3362,15 +3352,7 @@ func (a *App) handleConnectionDialog(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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", - } + config = discovery.BuildConnectionConfig(*instance) } return a.performConnection(config) 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/db/discovery/config.go b/internal/db/discovery/config.go new file mode 100644 index 0000000..b3f4375 --- /dev/null +++ b/internal/db/discovery/config.go @@ -0,0 +1,88 @@ +package discovery + +import ( + "os" + "strings" + + "github.com/rebelice/lazypg/internal/models" +) + +// BuildConnectionConfig turns a discovered instance into a connection config. +func BuildConnectionConfig(instance models.DiscoveredInstance) models.ConnectionConfig { + switch instance.Source { + case models.SourceEnvironment: + if envConfig := GetEnvironmentConfig(); envConfig != nil && envConfig.Host == instance.Host && envConfig.Port == instance.Port { + return *envConfig + } + case models.SourcePgPass: + if pgpassConfig := buildPgPassConfig(instance.Host, instance.Port); pgpassConfig != nil { + return *pgpassConfig + } + } + + return buildDefaultConfig(instance) +} + +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 = defaultDatabase(user) + } + + return &models.ConnectionConfig{ + Host: host, + Port: port, + Database: database, + User: user, + Password: entry.Password, + SSLMode: "prefer", + } + } + + return nil +} + +func buildDefaultConfig(instance models.DiscoveredInstance) models.ConnectionConfig { + user := defaultUser() + + return models.ConnectionConfig{ + Host: instance.Host, + Port: instance.Port, + Database: defaultDatabase(user), + User: user, + SSLMode: "prefer", + } +} + +func defaultUser() string { + for _, key := range []string{"PGUSER", "USER", "USERNAME"} { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + } + + return "postgres" +} + +func defaultDatabase(user string) string { + if user != "" { + return user + } + + return "postgres" +} diff --git a/internal/db/discovery/discovery.go b/internal/db/discovery/discovery.go index 90001c3..4d6a6d2 100644 --- a/internal/db/discovery/discovery.go +++ b/internal/db/discovery/discovery.go @@ -29,11 +29,15 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc instances = append(instances, *envInstance) } - // 2. Scan localhost ports + // 2. Scan common Unix socket directories + unixSocketInstances := d.scanner.ScanUnixSockets(ctx) + instances = append(instances, unixSocketInstances...) + + // 3. Scan localhost ports localInstances := d.scanner.ScanLocalhost(ctx) instances = append(instances, localInstances...) - // 3. Parse .pgpass + // 4. Parse .pgpass pgpassInstances := GetDiscoveredInstances() instances = append(instances, pgpassInstances...) @@ -42,7 +46,15 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc // Sort by source priority sort.Slice(instances, func(i, j int) bool { - return instances[i].Source < instances[j].Source + if instances[i].Source != instances[j].Source { + return instances[i].Source < instances[j].Source + } + + if instances[i].Host != instances[j].Host { + return instances[i].Host < instances[j].Host + } + + return instances[i].Port < instances[j].Port }) return instances diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go new file mode 100644 index 0000000..d6792c6 --- /dev/null +++ b/internal/db/discovery/unix_socket.go @@ -0,0 +1,161 @@ +package discovery + +import ( + "context" + "fmt" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/rebelice/lazypg/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", +} + +// 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 := instance.DisplayTarget() + 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 { + 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) 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 { + dirs := make([]string, 0, len(defaultUnixSocketDirs)+1) + + if host := strings.TrimSpace(os.Getenv("PGHOST")); 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 + } + + if _, exists := seen[dir]; exists { + continue + } + + seen[dir] = struct{}{} + unique = append(unique, dir) + } + + return unique +} + +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 + } + + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return 0, false + } + + return port, true +} diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go new file mode 100644 index 0000000..6416aab --- /dev/null +++ b/internal/db/discovery/unix_socket_test.go @@ -0,0 +1,94 @@ +package discovery + +import ( + "context" + "net" + "path/filepath" + "runtime" + "testing" + + "github.com/rebelice/lazypg/internal/models" +) + +func TestScanUnixSocketDirs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix sockets are not supported on windows") + } + + tempDir := t.TempDir() + socketPath := filepath.Join(tempDir, ".s.PGSQL.6543") + + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen on unix socket: %v", err) + } + defer func() { + _ = listener.Close() + }() + + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + conn, err := listener.Accept() + if err == nil { + _ = conn.Close() + } + }() + + 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") + } + + <-acceptDone +} + +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 != "socket-user" { + t.Fatalf("expected database socket-user, 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) + } +} diff --git a/internal/models/connection.go b/internal/models/connection.go index 2fb5e29..f257164 100644 --- a/internal/models/connection.go +++ b/internal/models/connection.go @@ -1,6 +1,9 @@ package models import ( + "fmt" + "path/filepath" + "strings" "time" ) @@ -15,6 +18,16 @@ 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 formatConnectionTarget(c.Host, c.Port) +} + // Connection represents an active database connection type Connection struct { ID string @@ -44,6 +57,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 formatConnectionTarget(d.Host, d.Port) +} + // DiscoverySource indicates how an instance was discovered type DiscoverySource int @@ -77,17 +100,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 +125,15 @@ func (e *ConnectionHistoryEntry) ToConnectionConfig() ConnectionConfig { SSLMode: e.SSLMode, } } + +func isUnixSocketHost(host string) bool { + return strings.HasPrefix(host, "/") +} + +func formatConnectionTarget(host string, port int) string { + if isUnixSocketHost(host) { + return filepath.Join(host, fmt.Sprintf(".s.PGSQL.%d", port)) + } + + return fmt.Sprintf("%s:%d", host, port) +} 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 From ecdc5712cd280fe3fe3cbd3e7e272ee3392bba32 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 14:32:05 +0800 Subject: [PATCH 02/11] fix(discovery): address unix socket review feedback --- internal/db/discovery/config.go | 2 +- internal/db/discovery/environment.go | 2 +- internal/db/discovery/unix_socket.go | 79 +++++++++++++- internal/db/discovery/unix_socket_test.go | 119 +++++++++++++++++++++- 4 files changed, 193 insertions(+), 9 deletions(-) diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go index b3f4375..bb45e8f 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -63,7 +63,7 @@ func buildDefaultConfig(instance models.DiscoveredInstance) models.ConnectionCon return models.ConnectionConfig{ Host: instance.Host, Port: instance.Port, - Database: defaultDatabase(user), + Database: "postgres", User: user, SSLMode: "prefer", } diff --git a/internal/db/discovery/environment.go b/internal/db/discovery/environment.go index 35415b0..370ca1d 100644 --- a/internal/db/discovery/environment.go +++ b/internal/db/discovery/environment.go @@ -49,7 +49,7 @@ func GetEnvironmentConfig() *models.ConnectionConfig { host = "localhost" } if user == "" { - user = os.Getenv("USER") + user = defaultUser() } if database == "" { database = user diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go index d6792c6..ff904ac 100644 --- a/internal/db/discovery/unix_socket.go +++ b/internal/db/discovery/unix_socket.go @@ -25,6 +25,11 @@ var defaultUnixSocketDirs = []string{ "/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" { @@ -63,6 +68,10 @@ func (s *Scanner) ScanUnixSocketDirs(ctx context.Context, dirs []string) []model } 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 @@ -88,6 +97,23 @@ func (s *Scanner) scanUnixSocketDir(ctx context.Context, dir string) []models.Di return instances } +func (s *Scanner) scanKnownUnixSocketPorts(ctx context.Context, dir string) []models.DiscoveredInstance { + instances := make([]models.DiscoveredInstance, 0, len(DefaultPorts)) + + for _, port := range candidateUnixSocketPorts() { + 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, @@ -111,10 +137,13 @@ func (s *Scanner) scanUnixSocket(ctx context.Context, dir string, port int) mode } func candidateUnixSocketDirs() []string { - dirs := make([]string, 0, len(defaultUnixSocketDirs)+1) + dirs := make([]string, 0, len(defaultUnixSocketDirs)+len(strings.Split(os.Getenv("PGHOST"), ","))) - if host := strings.TrimSpace(os.Getenv("PGHOST")); strings.HasPrefix(host, "/") { - dirs = append(dirs, host) + for _, host := range strings.Split(os.Getenv("PGHOST"), ",") { + host = strings.TrimSpace(host) + if strings.HasPrefix(host, "/") { + dirs = append(dirs, host) + } } dirs = append(dirs, defaultUnixSocketDirs...) @@ -130,18 +159,58 @@ func uniqueSocketDirs(dirs []string) []string { if dir == "" { continue } + dir = filepath.Clean(dir) - if _, exists := seen[dir]; exists { + key := socketDirKey(dir) + if _, exists := seen[key]; exists { continue } - seen[dir] = struct{}{} + 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 portStr := strings.TrimSpace(os.Getenv("PGPORT")); portStr != "" { + port, err := strconv.Atoi(portStr) + if err == nil && port >= 1 && port <= 65535 { + ports = append([]int{port}, ports...) + } + } + + unique := ports[:0] + 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 +} + func postgresSocketPort(name string) (int, bool) { if !strings.HasPrefix(name, ".s.PGSQL.") { return 0, false diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index 6416aab..466551f 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -3,6 +3,7 @@ package discovery import ( "context" "net" + "os" "path/filepath" "runtime" "testing" @@ -79,8 +80,8 @@ func TestBuildConnectionConfigForSocketDefaults(t *testing.T) { if config.User != "socket-user" { t.Fatalf("expected user socket-user, got %q", config.User) } - if config.Database != "socket-user" { - t.Fatalf("expected database socket-user, got %q", config.Database) + 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) @@ -92,3 +93,117 @@ func TestBuildConnectionConfigForSocketDefaults(t *testing.T) { 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 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 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 + }) + + socketPath := filepath.Join(tempDir, ".s.PGSQL.6543") + listener, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("listen on unix socket: %v", err) + } + defer func() { + _ = listener.Close() + }() + + 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) + } +} From 09d6ff45aa2089ba28848e418c1b780cc662dacc Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 14:57:59 +0800 Subject: [PATCH 03/11] refactor(discovery): simplify unix socket changes --- internal/app/app.go | 49 +++++--------------- internal/db/discovery/config.go | 10 +---- internal/db/discovery/discovery.go | 12 ++--- internal/db/discovery/unix_socket.go | 54 ++++++++++++----------- internal/db/discovery/unix_socket_test.go | 53 +++++++++++----------- 5 files changed, 72 insertions(+), 106 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 89f21f5..9bbebe3 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3315,48 +3315,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 - } + } - config = discovery.BuildConnectionConfig(*instance) + 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 diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go index bb45e8f..575f82b 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -41,7 +41,7 @@ func buildPgPassConfig(host string, port int) *models.ConnectionConfig { database := entry.Database if database == "" || database == "*" { - database = defaultDatabase(user) + database = user } return &models.ConnectionConfig{ @@ -78,11 +78,3 @@ func defaultUser() string { return "postgres" } - -func defaultDatabase(user string) string { - if user != "" { - return user - } - - return "postgres" -} diff --git a/internal/db/discovery/discovery.go b/internal/db/discovery/discovery.go index 4d6a6d2..f3b67f2 100644 --- a/internal/db/discovery/discovery.go +++ b/internal/db/discovery/discovery.go @@ -3,7 +3,6 @@ package discovery import ( "context" "sort" - "strconv" "github.com/pgplex/pgtui/internal/models" ) @@ -30,16 +29,13 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc } // 2. Scan common Unix socket directories - unixSocketInstances := d.scanner.ScanUnixSockets(ctx) - instances = append(instances, unixSocketInstances...) + instances = append(instances, d.scanner.ScanUnixSockets(ctx)...) // 3. Scan localhost ports - localInstances := d.scanner.ScanLocalhost(ctx) - instances = append(instances, localInstances...) + instances = append(instances, d.scanner.ScanLocalhost(ctx)...) // 4. Parse .pgpass - pgpassInstances := GetDiscoveredInstances() - instances = append(instances, pgpassInstances...) + instances = append(instances, GetDiscoveredInstances()...) // Deduplicate instances = deduplicateInstances(instances) @@ -65,7 +61,7 @@ func deduplicateInstances(instances []models.DiscoveredInstance) []models.Discov seen := make(map[string]models.DiscoveredInstance) for _, instance := range instances { - key := instance.Host + ":" + strconv.Itoa(instance.Port) + key := instance.DisplayTarget() // Keep the one with higher priority source if existing, exists := seen[key]; !exists || instance.Source < existing.Source { diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go index ff904ac..6315693 100644 --- a/internal/db/discovery/unix_socket.go +++ b/internal/db/discovery/unix_socket.go @@ -98,9 +98,10 @@ func (s *Scanner) scanUnixSocketDir(ctx context.Context, dir string) []models.Di } func (s *Scanner) scanKnownUnixSocketPorts(ctx context.Context, dir string) []models.DiscoveredInstance { - instances := make([]models.DiscoveredInstance, 0, len(DefaultPorts)) + ports := candidateUnixSocketPorts() + instances := make([]models.DiscoveredInstance, 0, len(ports)) - for _, port := range candidateUnixSocketPorts() { + for _, port := range ports { if ctx.Err() != nil { break } @@ -137,9 +138,10 @@ func (s *Scanner) scanUnixSocket(ctx context.Context, dir string, port int) mode } func candidateUnixSocketDirs() []string { - dirs := make([]string, 0, len(defaultUnixSocketDirs)+len(strings.Split(os.Getenv("PGHOST"), ","))) + pgHosts := strings.Split(os.Getenv("PGHOST"), ",") + dirs := make([]string, 0, len(defaultUnixSocketDirs)+len(pgHosts)) - for _, host := range strings.Split(os.Getenv("PGHOST"), ",") { + for _, host := range pgHosts { host = strings.TrimSpace(host) if strings.HasPrefix(host, "/") { dirs = append(dirs, host) @@ -190,25 +192,11 @@ func isBroadUnixSocketDir(dir string) bool { func candidateUnixSocketPorts() []int { ports := append([]int(nil), DefaultPorts...) - if portStr := strings.TrimSpace(os.Getenv("PGPORT")); portStr != "" { - port, err := strconv.Atoi(portStr) - if err == nil && port >= 1 && port <= 65535 { - ports = append([]int{port}, ports...) - } + if port, ok := validPort(os.Getenv("PGPORT")); ok { + ports = append([]int{port}, ports...) } - unique := ports[:0] - 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 + return uniquePorts(ports) } func postgresSocketPort(name string) (int, bool) { @@ -221,10 +209,26 @@ func postgresSocketPort(name string) (int, bool) { return 0, false } - port, err := strconv.Atoi(portStr) - if err != nil || port < 1 || port > 65535 { - 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 := ports[:0] + 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 port, true + return unique } diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index 466551f..abbf379 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strconv" "testing" "github.com/rebelice/lazypg/internal/models" @@ -17,24 +18,7 @@ func TestScanUnixSocketDirs(t *testing.T) { } tempDir := t.TempDir() - socketPath := filepath.Join(tempDir, ".s.PGSQL.6543") - - listener, err := net.Listen("unix", socketPath) - if err != nil { - t.Fatalf("listen on unix socket: %v", err) - } - defer func() { - _ = listener.Close() - }() - - acceptDone := make(chan struct{}) - go func() { - defer close(acceptDone) - conn, err := listener.Accept() - if err == nil { - _ = conn.Close() - } - }() + acceptDone := listenPostgresSocket(t, tempDir, 6543) scanner := NewScanner() instances := scanner.ScanUnixSocketDirs(context.Background(), []string{tempDir}) @@ -183,14 +167,7 @@ func TestScanBroadUnixSocketDirChecksKnownPortsOnly(t *testing.T) { broadUnixSocketDirs = oldBroadDirs }) - socketPath := filepath.Join(tempDir, ".s.PGSQL.6543") - listener, err := net.Listen("unix", socketPath) - if err != nil { - t.Fatalf("listen on unix socket: %v", err) - } - defer func() { - _ = listener.Close() - }() + listenPostgresSocket(t, tempDir, 6543) scanner := NewScanner() instances := scanner.scanUnixSocketDir(context.Background(), tempDir) @@ -207,3 +184,27 @@ func TestCandidateUnixSocketPortsIncludesPGPort(t *testing.T) { 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) + } + 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 +} From e363a668ede5bfab0986e62a9f0f23ddf93779a6 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 15:12:59 +0800 Subject: [PATCH 04/11] fix(discovery): use current module imports --- internal/db/discovery/config.go | 2 +- internal/db/discovery/unix_socket.go | 2 +- internal/db/discovery/unix_socket_test.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go index 575f82b..bb637e5 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -4,7 +4,7 @@ import ( "os" "strings" - "github.com/rebelice/lazypg/internal/models" + "github.com/pgplex/pgtui/internal/models" ) // BuildConnectionConfig turns a discovered instance into a connection config. diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go index 6315693..2a649d8 100644 --- a/internal/db/discovery/unix_socket.go +++ b/internal/db/discovery/unix_socket.go @@ -11,7 +11,7 @@ import ( "strings" "time" - "github.com/rebelice/lazypg/internal/models" + "github.com/pgplex/pgtui/internal/models" ) var defaultUnixSocketDirs = []string{ diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index abbf379..72f0e99 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -9,7 +9,7 @@ import ( "strconv" "testing" - "github.com/rebelice/lazypg/internal/models" + "github.com/pgplex/pgtui/internal/models" ) func TestScanUnixSocketDirs(t *testing.T) { From 5a65edf8de7a3f22d7cd11bc9bd78d8e0ad368b5 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 15:44:04 +0800 Subject: [PATCH 05/11] fix(discovery): use stable keys for dedupe --- internal/db/discovery/discovery.go | 14 +++++++++++-- internal/db/discovery/unix_socket.go | 2 +- internal/db/discovery/unix_socket_test.go | 25 +++++++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/internal/db/discovery/discovery.go b/internal/db/discovery/discovery.go index f3b67f2..ad1fe8c 100644 --- a/internal/db/discovery/discovery.go +++ b/internal/db/discovery/discovery.go @@ -3,6 +3,7 @@ package discovery import ( "context" "sort" + "strconv" "github.com/pgplex/pgtui/internal/models" ) @@ -56,12 +57,12 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc return instances } -// deduplicateInstances removes duplicate host:port combinations +// deduplicateInstances removes duplicate connection targets. func deduplicateInstances(instances []models.DiscoveredInstance) []models.DiscoveredInstance { seen := make(map[string]models.DiscoveredInstance) for _, instance := range instances { - key := instance.DisplayTarget() + key := instanceKey(instance) // Keep the one with higher priority source if existing, exists := seen[key]; !exists || instance.Source < existing.Source { @@ -76,3 +77,12 @@ 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) +} diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go index 2a649d8..b488c77 100644 --- a/internal/db/discovery/unix_socket.go +++ b/internal/db/discovery/unix_socket.go @@ -54,7 +54,7 @@ func (s *Scanner) ScanUnixSocketDirs(ctx context.Context, dirs []string) []model } for _, instance := range s.scanUnixSocketDir(ctx, dir) { - key := instance.DisplayTarget() + key := instanceKey(instance) if _, exists := seen[key]; exists { continue } diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index 72f0e99..86ec70b 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -147,6 +147,31 @@ func TestUniqueSocketDirsNormalizesAndResolvesSymlinks(t *testing.T) { } } +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 TestScanBroadUnixSocketDirChecksKnownPortsOnly(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unix sockets are not supported on windows") From 9fdd61337317cbc960e88c4e0dfb176570507e60 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 19:28:52 +0800 Subject: [PATCH 06/11] fix(discovery): prefer credential sources when deduping --- internal/db/discovery/discovery.go | 21 ++++++++++++++++++++- internal/db/discovery/unix_socket_test.go | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/internal/db/discovery/discovery.go b/internal/db/discovery/discovery.go index ad1fe8c..073d938 100644 --- a/internal/db/discovery/discovery.go +++ b/internal/db/discovery/discovery.go @@ -65,7 +65,7 @@ func deduplicateInstances(instances []models.DiscoveredInstance) []models.Discov 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 } } @@ -86,3 +86,22 @@ func instanceKey(instance models.DiscoveredInstance) string { 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/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index 86ec70b..c0674a6 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -172,6 +172,21 @@ func TestInstanceKeyNormalizesSocketSymlinks(t *testing.T) { } } +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 TestScanBroadUnixSocketDirChecksKnownPortsOnly(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("unix sockets are not supported on windows") From 3bc50a71b77d15c6fa2b2a9b4bbd3d340f3a19d5 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 20:05:44 +0800 Subject: [PATCH 07/11] fix(discovery): align priority and endpoint formatting --- internal/app/app.go | 17 ++----- internal/connection_history/manager.go | 2 +- internal/connection_history/password_store.go | 3 +- internal/db/connection/manager.go | 2 +- internal/db/discovery/config.go | 2 +- internal/db/discovery/discovery.go | 10 +++-- internal/db/discovery/scanner.go | 4 +- internal/db/discovery/unix_socket_test.go | 44 +++++++++++++++++++ internal/models/connection.go | 20 +++++++-- internal/models/connection_test.go | 33 ++++++++++++++ internal/ui/components/password_dialog.go | 5 ++- 11 files changed, 115 insertions(+), 27 deletions(-) create mode 100644 internal/models/connection_test.go diff --git a/internal/app/app.go b/internal/app/app.go index 9bbebe3..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 { @@ -3608,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/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 index bb637e5..5eeab43 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -11,7 +11,7 @@ import ( func BuildConnectionConfig(instance models.DiscoveredInstance) models.ConnectionConfig { switch instance.Source { case models.SourceEnvironment: - if envConfig := GetEnvironmentConfig(); envConfig != nil && envConfig.Host == instance.Host && envConfig.Port == instance.Port { + if envConfig := GetEnvironmentConfig(); envConfig != nil { return *envConfig } case models.SourcePgPass: diff --git a/internal/db/discovery/discovery.go b/internal/db/discovery/discovery.go index 073d938..56ad7d8 100644 --- a/internal/db/discovery/discovery.go +++ b/internal/db/discovery/discovery.go @@ -42,9 +42,15 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc instances = deduplicateInstances(instances) // Sort by source priority + sortDiscoveredInstances(instances) + + return instances +} + +func sortDiscoveredInstances(instances []models.DiscoveredInstance) { sort.Slice(instances, func(i, j int) bool { if instances[i].Source != instances[j].Source { - return instances[i].Source < instances[j].Source + return discoverySourcePriority(instances[i].Source) < discoverySourcePriority(instances[j].Source) } if instances[i].Host != instances[j].Host { @@ -53,8 +59,6 @@ func (d *Discoverer) DiscoverAll(ctx context.Context) []models.DiscoveredInstanc return instances[i].Port < instances[j].Port }) - - return instances } // deduplicateInstances removes duplicate connection targets. 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_test.go b/internal/db/discovery/unix_socket_test.go index c0674a6..4bb79eb 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -103,6 +103,37 @@ func TestGetEnvironmentConfigUsesUsernameFallback(t *testing.T) { } } +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 raw environment host, got %q", config.Host) + } + if config.Database != "envdb" { + t.Fatalf("expected environment database, got %q", config.Database) + } + if config.User != "envuser" { + t.Fatalf("expected environment user, got %q", config.User) + } + if config.Password != "secret" { + t.Fatal("expected environment password") + } + if config.SSLMode != "require" { + t.Fatalf("expected environment sslmode, got %q", config.SSLMode) + } +} + func TestCandidateUnixSocketDirsSplitsPGHost(t *testing.T) { t.Setenv("PGHOST", "/custom/socket,localhost, /tmp ") @@ -187,6 +218,19 @@ func TestDeduplicateInstancesPrefersCredentialSources(t *testing.T) { } } +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") diff --git a/internal/models/connection.go b/internal/models/connection.go index f257164..d0e5664 100644 --- a/internal/models/connection.go +++ b/internal/models/connection.go @@ -2,7 +2,9 @@ package models import ( "fmt" + "net" "path/filepath" + "strconv" "strings" "time" ) @@ -25,7 +27,17 @@ func (c ConnectionConfig) UsesUnixSocket() bool { // DisplayTarget returns a user-friendly connection target. func (c ConnectionConfig) DisplayTarget() string { - return formatConnectionTarget(c.Host, c.Port) + 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 @@ -64,7 +76,7 @@ func (d DiscoveredInstance) UsesUnixSocket() bool { // DisplayTarget returns a user-friendly discovery target. func (d DiscoveredInstance) DisplayTarget() string { - return formatConnectionTarget(d.Host, d.Port) + return formatConnectionEndpoint(d.Host, d.Port) } // DiscoverySource indicates how an instance was discovered @@ -130,10 +142,10 @@ func isUnixSocketHost(host string) bool { return strings.HasPrefix(host, "/") } -func formatConnectionTarget(host string, port int) string { +func formatConnectionEndpoint(host string, port int) string { if isUnixSocketHost(host) { return filepath.Join(host, fmt.Sprintf(".s.PGSQL.%d", port)) } - return fmt.Sprintf("%s:%d", host, 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/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, "") From 664d95eeab2c89583d6a20122b2233e1224a2315 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 20:12:15 +0800 Subject: [PATCH 08/11] refactor(discovery): simplify port and config helpers --- internal/db/discovery/config.go | 4 +--- internal/db/discovery/environment.go | 13 ++++--------- internal/db/discovery/unix_socket.go | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go index 5eeab43..6fd6a95 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -58,13 +58,11 @@ func buildPgPassConfig(host string, port int) *models.ConnectionConfig { } func buildDefaultConfig(instance models.DiscoveredInstance) models.ConnectionConfig { - user := defaultUser() - return models.ConnectionConfig{ Host: instance.Host, Port: instance.Port, Database: "postgres", - User: user, + User: defaultUser(), SSLMode: "prefer", } } diff --git a/internal/db/discovery/environment.go b/internal/db/discovery/environment.go index 370ca1d..dbced03 100644 --- a/internal/db/discovery/environment.go +++ b/internal/db/discovery/environment.go @@ -2,7 +2,6 @@ package discovery import ( "os" - "strconv" "github.com/pgplex/pgtui/internal/models" ) @@ -17,10 +16,8 @@ func ParseEnvironment() *models.DiscoveredInstance { } 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{ @@ -56,10 +53,8 @@ func GetEnvironmentConfig() *models.ConnectionConfig { } 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 == "" { diff --git a/internal/db/discovery/unix_socket.go b/internal/db/discovery/unix_socket.go index b488c77..ff85160 100644 --- a/internal/db/discovery/unix_socket.go +++ b/internal/db/discovery/unix_socket.go @@ -218,7 +218,7 @@ func validPort(value string) (int, bool) { } func uniquePorts(ports []int) []int { - unique := ports[:0] + unique := make([]int, 0, len(ports)) seen := make(map[int]struct{}, len(ports)) for _, port := range ports { From 6dfbb2f5f751568545f38a26443964aac09a54bf Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 20:40:27 +0800 Subject: [PATCH 09/11] fix(discovery): avoid leaking env name and persisting env/pgpass passwords BuildConnectionConfig returned GetEnvironmentConfig() verbatim for the environment source, carrying Name="Environment" and the PGPASSWORD/ .pgpass password into the connection pipeline. - Clear Name on the env path so the connection ID and history entry fall back to ConnectionLabel() (e.g. user@host:port/db), matching the other discovered sources instead of producing duplicate "Environment" history entries and a fixed connection ID. - Clear Password on both the env and .pgpass paths. pgx still resolves the credential via libpq defaults (PGPASSWORD env, ~/.pgpass), so connections authenticate unchanged, but the password no longer gets persisted to the OS keyring on success. --- internal/db/discovery/config.go | 13 +++++++++++-- internal/db/discovery/unix_socket_test.go | 10 ++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go index 6fd6a95..ab87bf9 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -8,15 +8,24 @@ import ( ) // 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 { - return *envConfig + 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 + config := *pgpassConfig + config.Password = "" + return config } } diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index 4bb79eb..f51faf4 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -126,8 +126,14 @@ func TestBuildConnectionConfigForEnvironmentUsesEnvironmentConfig(t *testing.T) if config.User != "envuser" { t.Fatalf("expected environment user, got %q", config.User) } - if config.Password != "secret" { - t.Fatal("expected environment password") + // 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 environment sslmode, got %q", config.SSLMode) From d6e649db80d20d87de25ecd3dd030e1e39ced319 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 22:53:27 +0800 Subject: [PATCH 10/11] fix(discovery): trim whitespace from libpq env vars Strip leading/trailing spaces from PGHOST/PGPORT/PGDATABASE/PGUSER/ PGSSLMODE so connection strings stay valid. Leave PGPASSWORD untrimmed. --- internal/db/discovery/environment.go | 20 +++++++----- internal/db/discovery/unix_socket_test.go | 37 +++++++++++++++++------ 2 files changed, 41 insertions(+), 16 deletions(-) diff --git a/internal/db/discovery/environment.go b/internal/db/discovery/environment.go index dbced03..178c985 100644 --- a/internal/db/discovery/environment.go +++ b/internal/db/discovery/environment.go @@ -2,14 +2,15 @@ package discovery import ( "os" + "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 @@ -30,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 @@ -71,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/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index f51faf4..b7e950d 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -105,11 +105,11 @@ func TestGetEnvironmentConfigUsesUsernameFallback(t *testing.T) { func TestBuildConnectionConfigForEnvironmentUsesEnvironmentConfig(t *testing.T) { t.Setenv("PGHOST", " localhost ") - t.Setenv("PGPORT", "5432") - t.Setenv("PGDATABASE", "envdb") - t.Setenv("PGUSER", "envuser") + t.Setenv("PGPORT", " 5432 ") + t.Setenv("PGDATABASE", " envdb ") + t.Setenv("PGUSER", " envuser ") t.Setenv("PGPASSWORD", "secret") - t.Setenv("PGSSLMODE", "require") + t.Setenv("PGSSLMODE", " require ") config := BuildConnectionConfig(models.DiscoveredInstance{ Host: "localhost", @@ -117,14 +117,17 @@ func TestBuildConnectionConfigForEnvironmentUsesEnvironmentConfig(t *testing.T) Source: models.SourceEnvironment, }) - if config.Host != " localhost " { - t.Fatalf("expected raw environment host, got %q", config.Host) + 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 environment database, got %q", config.Database) + t.Fatalf("expected trimmed environment database, got %q", config.Database) } if config.User != "envuser" { - t.Fatalf("expected environment user, got %q", config.User) + 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 @@ -136,7 +139,23 @@ func TestBuildConnectionConfigForEnvironmentUsesEnvironmentConfig(t *testing.T) t.Fatalf("expected empty name to avoid leaking into connection ID/history, got %q", config.Name) } if config.SSLMode != "require" { - t.Fatalf("expected environment sslmode, got %q", config.SSLMode) + 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) } } From 3186ab7ddfce91d20608d67d4082354172e7ca17 Mon Sep 17 00:00:00 2001 From: XYenon Date: Mon, 13 Jul 2026 23:09:43 +0800 Subject: [PATCH 11/11] fix(discovery): harden socket test accept and omit pgpass password Bound listenPostgresSocket Accept with a deadline and a select timeout so a missed dial cannot hang tests. Stop filling Password in buildPgPassConfig; libpq reads ~/.pgpass and we intentionally keep secrets out of the keyring. --- internal/db/discovery/config.go | 8 ++++---- internal/db/discovery/unix_socket_test.go | 14 +++++++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/db/discovery/config.go b/internal/db/discovery/config.go index ab87bf9..9cbc96c 100644 --- a/internal/db/discovery/config.go +++ b/internal/db/discovery/config.go @@ -23,15 +23,16 @@ func BuildConnectionConfig(instance models.DiscoveredInstance) models.Connection } case models.SourcePgPass: if pgpassConfig := buildPgPassConfig(instance.Host, instance.Port); pgpassConfig != nil { - config := *pgpassConfig - config.Password = "" - return config + 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 { @@ -58,7 +59,6 @@ func buildPgPassConfig(host string, port int) *models.ConnectionConfig { Port: port, Database: database, User: user, - Password: entry.Password, SSLMode: "prefer", } } diff --git a/internal/db/discovery/unix_socket_test.go b/internal/db/discovery/unix_socket_test.go index b7e950d..025ee88 100644 --- a/internal/db/discovery/unix_socket_test.go +++ b/internal/db/discovery/unix_socket_test.go @@ -8,6 +8,7 @@ import ( "runtime" "strconv" "testing" + "time" "github.com/pgplex/pgtui/internal/models" ) @@ -41,7 +42,11 @@ func TestScanUnixSocketDirs(t *testing.T) { t.Fatal("expected discovered socket to be available") } - <-acceptDone + select { + case <-acceptDone: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for socket accept") + } } func TestBuildConnectionConfigForSocketDefaults(t *testing.T) { @@ -302,6 +307,13 @@ func listenPostgresSocket(t *testing.T, dir string, port int) <-chan struct{} { 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() })