diff --git a/CHANGELOG.md b/CHANGELOG.md index b14228ab4..231656eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The username is now optional. Leaving it empty, or importing a connection URL that has no user in it, no longer fills in `root`. An empty username lets the database use its own default, the same as `psql` and `mysql` do: your Mac login name on MySQL/MariaDB and PostgreSQL, and `default` on ClickHouse. The `~/.pgpass` lookup now matches on that same user. - A failed or cancelled connection that uses a Cloudflare tunnel no longer leaves the `cloudflared` process running in the background. - Pinned results are no longer discarded by **Clear Results**, and a tab holding one is no longer reused to browse a different table. (#1855) - Quitting now warns about unsaved changes in any tab, not just the visible one. Unsaved edits to a `.sql` file, table structure changes, and pending truncates and deletes were all missed before. (#1854) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHouseCredentials.swift b/Plugins/ClickHouseDriverPlugin/ClickHouseCredentials.swift new file mode 100644 index 000000000..dbc933c39 --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHouseCredentials.swift @@ -0,0 +1,20 @@ +// +// ClickHouseCredentials.swift +// ClickHouseDriverPlugin +// + +import Foundation + +internal enum ClickHouseCredentials { + internal static let defaultUsername = "default" + + internal static func effectiveUsername(_ username: String) -> String { + username.isEmpty ? defaultUsername : username + } + + internal static func basicAuthorizationHeader(username: String, password: String) -> String? { + let credentials = "\(effectiveUsername(username)):\(password)" + guard let data = credentials.data(using: .utf8) else { return nil } + return "Basic \(data.base64EncodedString())" + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index a9839bc3a..6e4433bfc 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -615,9 +615,11 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { var request = URLRequest(url: url) request.httpMethod = "POST" - let credentials = "\(config.username):\(config.password)" - if let credData = credentials.data(using: .utf8) { - request.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization") + if let authorization = ClickHouseCredentials.basicAuthorizationHeader( + username: config.username, + password: config.password + ) { + request.setValue(authorization, forHTTPHeaderField: "Authorization") } request.httpBody = (query + " FORMAT JSONEachRow").data(using: .utf8) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift index 99006be07..6f091443b 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Http.swift @@ -161,9 +161,11 @@ extension ClickHousePluginDriver { var request = URLRequest(url: url) request.httpMethod = "POST" - let credentials = "\(config.username):\(config.password)" - if let credData = credentials.data(using: .utf8) { - request.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization") + if let authorization = ClickHouseCredentials.basicAuthorizationHeader( + username: config.username, + password: config.password + ) { + request.setValue(authorization, forHTTPHeaderField: "Authorization") } let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index fcab7b007..7dd1afa8e 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -491,6 +491,7 @@ 5A863000E00000000 /* Exceptions for "Plugins/ClickHouseDriverPlugin" folder in "TableProTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + ClickHouseCredentials.swift, ClickHouseTableOperations.swift, ); target = 5ABCC5A62F43856700EAF3FC /* TableProTests */; diff --git a/TablePro/Core/Utilities/Connection/PgpassReader.swift b/TablePro/Core/Utilities/Connection/PgpassReader.swift index f37c045fb..4584db694 100644 --- a/TablePro/Core/Utilities/Connection/PgpassReader.swift +++ b/TablePro/Core/Utilities/Connection/PgpassReader.swift @@ -27,6 +27,12 @@ enum PgpassReader { return posixPerms == 0o600 } + /// libpq resolves an unset user to the operating system login name before matching ~/.pgpass, + /// so a blank username field must resolve the same way here. + static func effectiveUsername(_ username: String) -> String { + username.isEmpty ? NSUserName() : username + } + /// Resolve a password from ~/.pgpass per PostgreSQL spec. /// Returns the password from the first matching entry, or nil if no match. /// Format: hostname:port:database:username:password diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 4f2c220c0..c51ada1c1 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -436,7 +436,7 @@ struct DatabaseConnection: Identifiable, Hashable { host: String = "localhost", port: Int = 3_306, database: String = "", - username: String = "root", + username: String = "", type: DatabaseType = .mysql, sshConfig: SSHConfiguration = SSHConfiguration(), sslConfig: SSLConfiguration = SSLConfiguration(), @@ -569,7 +569,7 @@ extension DatabaseConnection: Codable { host = try container.decodeIfPresent(String.self, forKey: .host) ?? "localhost" port = try container.decodeIfPresent(Int.self, forKey: .port) ?? 3_306 database = try container.decodeIfPresent(String.self, forKey: .database) ?? "" - username = try container.decodeIfPresent(String.self, forKey: .username) ?? "root" + username = try container.decodeIfPresent(String.self, forKey: .username) ?? "" type = try container.decodeIfPresent(DatabaseType.self, forKey: .type) ?? .mysql sshConfig = try container.decodeIfPresent(SSHConfiguration.self, forKey: .sshConfig) ?? SSHConfiguration() sslConfig = try container.decodeIfPresent(SSLConfiguration.self, forKey: .sslConfig) ?? SSLConfiguration() diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index ba6556f16..d2acdf715 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -225,13 +225,9 @@ final class ConnectionFormCoordinator { let sshConfig = ssh.state.buildSSHConfig() let sslConfig = ssl.buildConfig() - var finalHost = network.host.trimmingCharacters(in: .whitespaces).isEmpty - ? "localhost" : network.host - var finalPort = Int(network.port) ?? network.type.defaultPort - let trimmedUsername = auth.username.trimmingCharacters(in: .whitespaces) - let finalUsername = - trimmedUsername.isEmpty && services.pluginManager.requiresAuthentication(for: network.type) - ? "root" : trimmedUsername + var finalHost = network.resolvedHost + var finalPort = network.resolvedPort + let finalUsername = auth.resolvedUsername let finalId = connectionId ?? UUID() @@ -427,13 +423,9 @@ final class ConnectionFormCoordinator { let sshConfig = ssh.state.buildSSHConfig() let sslConfig = ssl.buildConfig() - var testHost = network.host.trimmingCharacters(in: .whitespaces).isEmpty - ? "localhost" : network.host - var testPort = Int(network.port) ?? network.type.defaultPort - let trimmedUsername = auth.username.trimmingCharacters(in: .whitespaces) - let finalUsername = - trimmedUsername.isEmpty && services.pluginManager.requiresAuthentication(for: network.type) - ? "root" : trimmedUsername + var testHost = network.resolvedHost + var testPort = network.resolvedPort + let finalUsername = auth.resolvedUsername var finalAdditionalFields: [String: String] = [:] network.write(into: &finalAdditionalFields) diff --git a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift index ceae225e2..dc5ee88ad 100644 --- a/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift +++ b/TablePro/Views/ConnectionForm/Panes/GeneralPaneView.swift @@ -158,8 +158,7 @@ struct GeneralPaneView: View { if connectionMode == .network { TextField( String(localized: "Username"), - text: $coordinator.auth.username, - prompt: Text("root") + text: $coordinator.auth.username ) } if !coordinator.auth.hidesPassword { diff --git a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift index ae320b021..2085177ec 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AuthPaneViewModel.swift @@ -40,6 +40,10 @@ final class AuthPaneViewModel { .filter { $0.section == .authentication } } + var resolvedUsername: String { + username.trimmingCharacters(in: .whitespaces) + } + var hidesBuiltInPassword: Bool { guard let type = coordinator?.value?.network.type else { return false } return PluginMetadataRegistry.shared.snapshot(forTypeId: type.pluginTypeId)? @@ -138,10 +142,11 @@ final class AuthPaneViewModel { pgpassStatus = .notChecked return } - let host = coordinator.network.host.isEmpty ? "localhost" : coordinator.network.host - let port = Int(coordinator.network.port) ?? coordinator.network.type.defaultPort - let database = coordinator.network.database - let username = self.username.isEmpty ? "root" : self.username - pgpassStatus = PgpassStatus.check(host: host, port: port, database: database, username: username) + pgpassStatus = PgpassStatus.check( + host: coordinator.network.resolvedHost, + port: coordinator.network.resolvedPort, + database: coordinator.network.database, + username: PgpassReader.effectiveUsername(resolvedUsername) + ) } } diff --git a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift index ae0b1bd81..34cbcd7b6 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/NetworkPaneViewModel.swift @@ -39,6 +39,14 @@ final class NetworkPaneViewModel { return port == 0 ? "" : String(port) } + var resolvedHost: String { + host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : host + } + + var resolvedPort: Int { + Int(port) ?? type.defaultPort + } + var supportsDatabaseField: Bool { let mode = connectionMode return mode == .fileBased diff --git a/TableProTests/Core/Utilities/ConnectionURLImportUsernameTests.swift b/TableProTests/Core/Utilities/ConnectionURLImportUsernameTests.swift new file mode 100644 index 000000000..f9db99efb --- /dev/null +++ b/TableProTests/Core/Utilities/ConnectionURLImportUsernameTests.swift @@ -0,0 +1,51 @@ +// +// ConnectionURLImportUsernameTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +@Suite("Connection URL Import Username") +@MainActor +struct ConnectionURLImportUsernameTests { + private func parse(_ urlString: String) throws -> ParsedConnectionURL { + guard case .success(let parsed) = ConnectionURLParser.parse(urlString) else { + throw ConnectionURLParseError.invalidURL + } + return parsed + } + + @Test("Importing a MySQL URL with no user info keeps the username empty") + func mysqlURLWithoutUserKeepsUsernameEmpty() throws { + let parsed = try parse("mysql://localhost:3306/shop") + let connection = TransientConnectionFactory.build(from: parsed) + #expect(connection.username.isEmpty) + } + + @Test("Importing a PostgreSQL URL with no user info keeps the username empty") + func postgresURLWithoutUserKeepsUsernameEmpty() throws { + let parsed = try parse("postgresql://db.example.com:5432/analytics") + let connection = TransientConnectionFactory.build(from: parsed) + #expect(connection.username.isEmpty) + } + + @Test("A URL with user info still imports that username") + func urlWithUserInfoKeepsUsername() throws { + let parsed = try parse("mysql://admin:secret@localhost:3306/shop") + let connection = TransientConnectionFactory.build(from: parsed) + #expect(connection.username == "admin") + } + + @Test("An imported connection with no username exports a URL with no user info") + func emptyUsernameRoundTripsThroughFormatter() throws { + let parsed = try parse("mysql://localhost:3306/shop") + let connection = TransientConnectionFactory.build(from: parsed) + let url = ConnectionURLFormatter.format(connection, password: "", sshPassword: nil) + #expect(!url.contains("@")) + #expect(url.contains("localhost")) + } +} diff --git a/TableProTests/Core/Utilities/PgpassReaderUsernameTests.swift b/TableProTests/Core/Utilities/PgpassReaderUsernameTests.swift new file mode 100644 index 000000000..69b4a5d02 --- /dev/null +++ b/TableProTests/Core/Utilities/PgpassReaderUsernameTests.swift @@ -0,0 +1,22 @@ +// +// PgpassReaderUsernameTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Pgpass Effective Username") +struct PgpassReaderUsernameTests { + @Test("A blank username matches ~/.pgpass as the operating system user") + func blankUsernameResolvesToOSUser() { + #expect(PgpassReader.effectiveUsername("") == NSUserName()) + } + + @Test("An explicit username is matched as given") + func explicitUsernameIsPreserved() { + #expect(PgpassReader.effectiveUsername("analytics") == "analytics") + } +} diff --git a/TableProTests/Models/Connection/DatabaseConnectionUsernameTests.swift b/TableProTests/Models/Connection/DatabaseConnectionUsernameTests.swift new file mode 100644 index 000000000..faf5aea26 --- /dev/null +++ b/TableProTests/Models/Connection/DatabaseConnectionUsernameTests.swift @@ -0,0 +1,59 @@ +// +// DatabaseConnectionUsernameTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("Database Connection Username") +struct DatabaseConnectionUsernameTests { + @Test("Username defaults to empty, never a fabricated account name") + func usernameDefaultsToEmpty() { + let connection = DatabaseConnection(name: "Local") + #expect(connection.username.isEmpty) + } + + @Test("Decoding a connection with no username key yields an empty username") + func decodingWithoutUsernameKeyYieldsEmpty() throws { + let json = """ + { + "id": "3F2504E0-4F89-11D3-9A0C-0305E82C3301", + "name": "No User", + "host": "localhost", + "port": 3306, + "type": "MySQL" + } + """ + let data = try #require(json.data(using: .utf8)) + let connection = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(connection.username.isEmpty) + } + + @Test("Decoding preserves an explicit username") + func decodingPreservesExplicitUsername() throws { + let json = """ + { + "id": "3F2504E0-4F89-11D3-9A0C-0305E82C3302", + "name": "With User", + "host": "localhost", + "port": 3306, + "username": "admin", + "type": "MySQL" + } + """ + let data = try #require(json.data(using: .utf8)) + let connection = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(connection.username == "admin") + } + + @Test("An empty username round-trips through encoding") + func emptyUsernameRoundTrips() throws { + let connection = DatabaseConnection(name: "Local", username: "") + let data = try JSONEncoder().encode(connection) + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: data) + #expect(decoded.username.isEmpty) + } +} diff --git a/TableProTests/Plugins/ClickHouseCredentialsTests.swift b/TableProTests/Plugins/ClickHouseCredentialsTests.swift new file mode 100644 index 000000000..1c2a53a1e --- /dev/null +++ b/TableProTests/Plugins/ClickHouseCredentialsTests.swift @@ -0,0 +1,36 @@ +// +// ClickHouseCredentialsTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("ClickHouse Credentials") +struct ClickHouseCredentialsTests { + @Test("A blank username resolves to the ClickHouse default user") + func blankUsernameResolvesToDefaultUser() { + #expect(ClickHouseCredentials.effectiveUsername("") == "default") + } + + @Test("An explicit username is used as given") + func explicitUsernameIsPreserved() { + #expect(ClickHouseCredentials.effectiveUsername("analytics") == "analytics") + } + + @Test("Basic authorization for a blank username authenticates as default") + func basicAuthorizationForBlankUsername() throws { + let header = try #require(ClickHouseCredentials.basicAuthorizationHeader(username: "", password: "secret")) + let encoded = header.replacingOccurrences(of: "Basic ", with: "") + let data = try #require(Data(base64Encoded: encoded)) + #expect(String(data: data, encoding: .utf8) == "default:secret") + } + + @Test("Basic authorization keeps an explicit username") + func basicAuthorizationForExplicitUsername() throws { + let header = try #require(ClickHouseCredentials.basicAuthorizationHeader(username: "analytics", password: "")) + let encoded = header.replacingOccurrences(of: "Basic ", with: "") + let data = try #require(Data(base64Encoded: encoded)) + #expect(String(data: data, encoding: .utf8) == "analytics:") + } +} diff --git a/docs/databases/overview.mdx b/docs/databases/overview.mdx index cc2b20242..feea9a677 100644 --- a/docs/databases/overview.mdx +++ b/docs/databases/overview.mdx @@ -213,7 +213,7 @@ A red triangle on a sidebar item marks panes with missing required fields. | **Host** | Server address. Defaults to `localhost` | | **Port** | Server port. Pre-filled per database type | | **Database** | Default database. Optional for MySQL/MariaDB; leave empty for service-level access | -| **Username** | Database username. Defaults to `root` (MySQL), `postgres` (PostgreSQL) | +| **Username** | Database username. Optional: leave it empty and the database uses its own default, which is your Mac login name for MySQL/MariaDB and PostgreSQL, and `default` for ClickHouse | | **Password** | Stored in Keychain | | **Prompt for password** | Skip saving the password. TablePro asks for it on every connect | | **Use Password File** | PostgreSQL only. Reads credentials from `~/.pgpass` |