diff --git a/CHANGELOG.md b/CHANGELOG.md index 1424ec1c6..86825658a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Cancelling a SQL Server connection now stops waiting right away instead of staying stuck until the attempt finished on its own. A connection attempt also gives up after the login timeout with a clear message rather than hanging, and for Windows Authentication the message points at the usual Kerberos causes (KDC unreachable, missing SPN, or clock skew). (#1889) - Fixed MySQL and MariaDB queries ending in ORDER BY that could show an empty result with no error. A failure part way through reading rows was treated as a normal end of results, so a real server error looked like a table with no rows. TablePro now shows the error the server reported. (#1884) - Fixed the query result row cap returning a single row when it was set to unlimited. (#1884) - Fixed SSH tunnels that could accept a database connection and then go quiet instead of forwarding it or failing, which showed up as MySQL and MariaDB connections timing out while reading the server greeting. A tunnel that cannot open its forwarding channel now gives up after 10 seconds, closes the connection, and logs the reason, instead of leaving the driver to wait out its own timeout with no explanation. (#1883) diff --git a/CLAUDE.md b/CLAUDE.md index 0c53c486e..3c34dcc2f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,7 +164,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. **Selection indices are display positions**: `GridSelectionState.indices` come from `NSTableView.selectedRowIndexes` and are display-row positions, not indices into `TableRows.rows`. They match array indices only when `displayIDs` (`valueFilteredIDs ?? sortedIDs`) is nil; a per-column value filter makes them diverge. Resolve any selected index through `DisplayRowMapping` (or `TableViewCoordinator.displayRow(at:)` / `tableRowsIndex(forDisplayRow:)`) before reading or mutating a row; never index `TableRows.rows` with a display position. The row details inspector shipped this bug (#1837). -**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369). +**Cancelling a connect does not stop the driver**: `Task.cancel()` is cooperative, so it cannot interrupt a driver blocked in a C call. A cancelled attempt keeps running and completes late. Two rules follow. First, a driver that blocks on connect must expose its own abort path and poll it (the PostgreSQL driver uses `PQconnectStart`/`PQconnectPoll` with an app-owned deadline and a cancel flag flipped from `withTaskCancellationHandler`; a blocking `PQconnectdb` cannot be cancelled at all). When the driver's C API has no pollable connect (FreeTDS db-lib's `dbopen`), the other valid shape is to resume the awaiting caller on cancel or an app-owned deadline through a resume-once continuation gate (`SingleResumeGate` / `runCancellableBlocking`), keep the blocking call on its own serial queue, and have the late-completing call tear down its own handle (the loser `dbclose`s the `dbproc`) instead of adopting it; a process-global set before the blocking call (e.g. `KRB5CCNAME` for Kerberos) is set and restored inside that queue block so its lifetime tracks the real completion, not the early return (#1889). Second, never assume the losing attempt is gone: every attempt validates its `ConnectionAttemptRegistry` generation before adopting a driver into `activeSessions` or tearing session state down, so a late attempt discards its own driver instead of clobbering the winner. Cancelling also drops the connection from `LastOpenConnections.json` (via `MainContentCoordinator.syncRecoveryList()`, activated windows only) so "Reopen Last Session" never replays a connect the user cancelled. This area shipped the same bug four times (#1185, #1358, #1369). **A split pane's `holdingPriority` must stay below 490**: AppKit applies a divider drag as a layout change at `dragThatCannotResizeWindow` (490). Any pane whose `holdingPriority` is at or above that outranks the drag, so its width constraint wins and the divider cannot move at all. `.defaultHigh` (750) freezes it outright, which shipped as three dead dividers (Users & Roles, Structure triggers, Server Dashboard). Use `.splitPaneHolding` (260, the value AppKit itself gives a sidebar item): high enough to outrank a `.defaultLow` (250) sibling so the pane holds its size when the window resizes, low enough that a drag still wins. `.defaultLow` is not the fix, since the pane then grows with the window instead of holding. (#1872) diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectCancellation.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectCancellation.swift new file mode 100644 index 000000000..63627130c --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectCancellation.swift @@ -0,0 +1,87 @@ +import Foundation + +public final class SingleResumeGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var outcome: Result? + private var settled = false + + public init() {} + + public func install(_ continuation: CheckedContinuation, alreadyCancelled: Bool) { + lock.lock() + if alreadyCancelled, !settled { + settled = true + lock.unlock() + continuation.resume(throwing: CancellationError()) + return + } + if let outcome { + lock.unlock() + continuation.resume(with: outcome) + return + } + self.continuation = continuation + lock.unlock() + } + + public func win(_ value: Value) -> Bool { + settle(.success(value)) + } + + public func fail(_ error: Error) { + _ = settle(.failure(error)) + } + + @discardableResult + private func settle(_ result: Result) -> Bool { + lock.lock() + if settled { + lock.unlock() + return false + } + settled = true + if let continuation { + self.continuation = nil + lock.unlock() + continuation.resume(with: result) + } else { + outcome = result + lock.unlock() + } + return true + } +} + +public func runCancellableBlocking( + on queue: DispatchQueue, + deadline: DispatchTimeInterval? = nil, + deadlineQueue: DispatchQueue = .global(qos: .userInitiated), + timeoutError: @escaping @Sendable () -> Error = { CancellationError() }, + work: @escaping @Sendable () throws -> T, + discardLateResult: @escaping @Sendable (T) -> Void = { _ in } +) async throws -> T { + let gate = SingleResumeGate() + if let deadline { + deadlineQueue.asyncAfter(deadline: .now() + deadline) { + gate.fail(timeoutError()) + } + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + gate.install(continuation, alreadyCancelled: Task.isCancelled) + queue.async { + do { + let value = try work() + if !gate.win(value) { + discardLateResult(value) + } + } catch { + gate.fail(error) + } + } + } + } onCancel: { + gate.fail(CancellationError()) + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 2a0392487..4b33148a9 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -11,6 +11,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var applicationName: String public var loginTimeoutSeconds: Int public var authMethod: MSSQLAuthMethod + public var kerberosCachePath: String? public static let defaultPort = 1433 public static let defaultSchema = "dbo" @@ -28,11 +29,13 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag, applicationName: String = MSSQLConnectionOptions.defaultApplicationName, loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds, - authMethod: MSSQLAuthMethod = .sqlServer + authMethod: MSSQLAuthMethod = .sqlServer, + kerberosCachePath: String? = nil ) { self.host = host self.port = port self.authMethod = authMethod + self.kerberosCachePath = kerberosCachePath switch authMethod { case .sqlServer: self.user = user diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift index 38c59a6d6..58c55d43d 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift @@ -26,6 +26,7 @@ public enum MSSQLCoreError: LocalizedError, Sendable { case cancelled case tlsHandshakeFailed(kind: MSSQLTLSFailureKind, serverMessage: String) case kerberosAuthFailed(kind: MSSQLKerberosFailureKind, serverMessage: String) + case connectionTimedOut(isKerberos: Bool) public var errorDescription: String? { switch self { @@ -41,6 +42,11 @@ public enum MSSQLCoreError: LocalizedError, Sendable { return String(format: String(localized: "TLS handshake failed: %@"), serverMessage) case .kerberosAuthFailed(_, let serverMessage): return String(format: String(localized: "Kerberos authentication failed: %@"), serverMessage) + case .connectionTimedOut(let isKerberos): + if isKerberos { + return String(localized: "Timed out completing Kerberos authentication. The Kerberos KDC (domain controller) may be unreachable, the server's SPN may be missing, or this Mac's clock may be off. Check your network to the domain, or use SQL Server Authentication.") + } + return String(localized: "Timed out connecting to the server. Check the host, port, and that the server is reachable and accepting connections.") } } } diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLConnectCancellationTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLConnectCancellationTests.swift new file mode 100644 index 000000000..be2abb5e9 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLConnectCancellationTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Testing +@testable import TableProMSSQLCore + +private final class FlagBox: @unchecked Sendable { + private let lock = NSLock() + private var flagged = false + var value: Bool { + lock.lock(); defer { lock.unlock() } + return flagged + } + func mark() { + lock.lock(); flagged = true; lock.unlock() + } +} + +private func pollUntil(_ condition: @Sendable () -> Bool, timeout: TimeInterval = 2) async { + let start = Date() + while !condition() { + if Date().timeIntervalSince(start) > timeout { return } + try? await Task.sleep(nanoseconds: 5_000_000) + } +} + +private struct TimeoutError: Error {} + +@Suite("Connect cancellation") +struct MSSQLConnectCancellationTests { + @Test("Work that completes normally returns its value") + func normalCompletion() async throws { + let queue = DispatchQueue(label: "test.normal") + let value = try await runCancellableBlocking(on: queue, work: { 42 }) + #expect(value == 42) + } + + @Test("Cancel returns promptly and the late result is discarded, not adopted") + func cancelDiscardsLateResult() async { + let queue = DispatchQueue(label: "test.cancel") + let workStarted = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + let discarded = FlagBox() + + let task = Task { + try await runCancellableBlocking( + on: queue, + work: { () -> Int in + workStarted.signal() + release.wait() + return 7 + }, + discardLateResult: { _ in discarded.mark() } + ) + } + + workStarted.wait() + task.cancel() + + let result = await task.result + if case .success = result { + Issue.record("expected the cancelled caller to throw") + } + #expect(!discarded.value) + + release.signal() + await pollUntil { discarded.value } + #expect(discarded.value) + } + + @Test("A deadline fails the caller and discards the late result") + func deadlineFires() async { + let queue = DispatchQueue(label: "test.deadline") + let release = DispatchSemaphore(value: 0) + let discarded = FlagBox() + + let result = await Task { + try await runCancellableBlocking( + on: queue, + deadline: .milliseconds(40), + timeoutError: { TimeoutError() }, + work: { () -> Int in + release.wait() + return 1 + }, + discardLateResult: { _ in discarded.mark() } + ) + }.result + + switch result { + case .failure(let error): + #expect(error is TimeoutError) + case .success: + Issue.record("expected the deadline to fire") + } + + release.signal() + await pollUntil { discarded.value } + #expect(discarded.value) + } + + @Test("Work that completes before any cancel adopts the result") + func winnerAdopts() async throws { + let queue = DispatchQueue(label: "test.winner") + let discarded = FlagBox() + let value = try await runCancellableBlocking( + on: queue, + work: { 99 }, + discardLateResult: { _ in discarded.mark() } + ) + #expect(value == 99) + #expect(!discarded.value) + } + + @Test("Racing fast work against immediate cancel never double-resumes") + func raceNeverDoubleResumes() async { + for _ in 0..<300 { + let queue = DispatchQueue(label: "test.race") + let discardCount = CountBox() + let task = Task { + try await runCancellableBlocking( + on: queue, + work: { 1 }, + discardLateResult: { _ in discardCount.increment() } + ) + } + task.cancel() + _ = await task.result + #expect(discardCount.value <= 1) + } + } +} + +private final class CountBox: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { + lock.lock(); defer { lock.unlock() } + return count + } + func increment() { + lock.lock(); count += 1; lock.unlock() + } +} diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index e1639f81e..6b5794692 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -10,6 +10,7 @@ // import CFreeTDS +import Darwin import Foundation import os import TableProMSSQLCore @@ -124,6 +125,10 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { private var _isConnected = false private var _isCancelled = false + private static let kerberosEnvLock = NSLock() + private static let deadlineQueue = DispatchQueue(label: "com.TablePro.freetds.connect-deadline", qos: .userInitiated) + private static let connectDeadlineMarginSeconds = 5 + var isConnected: Bool { lock.lock() defer { lock.unlock() } @@ -137,12 +142,36 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { } func connect() async throws { - try await freetdsDispatchAsync(on: queue) { [self] in - try self.connectSync() + let gate = SingleResumeGate() + let isKerberos = options.authMethod == .windows + let deadline = DispatchTimeInterval.seconds(options.loginTimeoutSeconds + Self.connectDeadlineMarginSeconds) + + Self.deadlineQueue.asyncAfter(deadline: .now() + deadline) { + gate.fail(MSSQLCoreError.connectionTimedOut(isKerberos: isKerberos)) + } + + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + gate.install(continuation, alreadyCancelled: Task.isCancelled) + queue.async { [self] in + do { + let proc = try openConnection() + if gate.win(()) { + adopt(proc) + } else { + teardown(proc) + } + } catch { + gate.fail(error) + } + } + } + } onCancel: { + gate.fail(CancellationError()) } } - private func connectSync() throws { + private func openConnection() throws -> UnsafeMutablePointer { guard let login = dblogin() else { throw MSSQLCoreError.connectionFailed("Failed to create login") } @@ -158,15 +187,11 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { _ = dbsetlname(login, parameter.value, parameter.field.dbsetName) } _ = dbsetlversion(login, UInt8(DBVERSION_74)) - - // dbsetlogintime is process-global; setting before dbopen bounds this call. Concurrent - // connectSync from another FreeTDSConnection would race, but the serial connect queue and - // the brief window (cleared at function exit) keeps the cost acceptable for interactive use. _ = dbsetlogintime(Int32(options.loginTimeoutSeconds)) freetdsClearError(for: nil) let serverName = "\(options.host):\(options.port)" - guard let proc = dbopen(login, serverName) else { + guard let proc = withKerberosEnvironmentIfNeeded({ dbopen(login, serverName) }) else { let detail = freetdsGetError(for: nil) let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail if let kind = MSSQLTLSClassifier.classifySSLError(detail) { @@ -177,15 +202,41 @@ nonisolated final class FreeTDSConnection: @unchecked Sendable { } throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") } + return proc + } + + private func withKerberosEnvironmentIfNeeded( + _ body: () -> UnsafeMutablePointer? + ) -> UnsafeMutablePointer? { + guard let cachePath = options.kerberosCachePath else { return body() } + Self.kerberosEnvLock.lock() + let previous = getenv("KRB5CCNAME").map { String(cString: $0) } + setenv("KRB5CCNAME", "FILE:\(cachePath)", 1) + defer { + if let previous { + setenv("KRB5CCNAME", previous, 1) + } else { + unsetenv("KRB5CCNAME") + } + Self.kerberosEnvLock.unlock() + try? FileManager.default.removeItem(atPath: cachePath) + } + return body() + } - self.dbproc = proc + private func adopt(_ proc: UnsafeMutablePointer) { lock.lock() + dbproc = proc _isConnected = true lock.unlock() - applyMaxTextSize(proc) } + private func teardown(_ proc: UnsafeMutablePointer) { + freetdsUnregister(proc) + _ = dbclose(proc) + } + private func applyMaxTextSize(_ proc: UnsafeMutablePointer) { guard dbcmd(proc, "SET TEXTSIZE \(Int32.max)") != FAIL, dbsqlexec(proc) != FAIL else { freetdsLogger.error("Failed to raise TEXTSIZE; large text columns may be truncated to the 2048-byte default") diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift deleted file mode 100644 index b9216d377..000000000 --- a/Plugins/MSSQLDriverPlugin/MSSQLKerberosConnectGate.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Darwin -import Foundation - -final class AsyncLock: @unchecked Sendable { - private let stateLock = NSLock() - private var isLocked = false - private var waiters: [CheckedContinuation] = [] - - func acquire() async { - if tryAcquireImmediately() { return } - await withCheckedContinuation { continuation in - enqueueOrResume(continuation) - } - } - - func release() { - stateLock.lock() - if waiters.isEmpty { - isLocked = false - stateLock.unlock() - } else { - let next = waiters.removeFirst() - stateLock.unlock() - next.resume() - } - } - - private func tryAcquireImmediately() -> Bool { - stateLock.lock() - defer { stateLock.unlock() } - if !isLocked { - isLocked = true - return true - } - return false - } - - private func enqueueOrResume(_ continuation: CheckedContinuation) { - stateLock.lock() - if !isLocked { - isLocked = true - stateLock.unlock() - continuation.resume() - } else { - waiters.append(continuation) - stateLock.unlock() - } - } -} - -final class MSSQLKerberosConnectGate: @unchecked Sendable { - static let shared = MSSQLKerberosConnectGate() - - private let lock = AsyncLock() - - func connect(_ conn: FreeTDSConnection, principal: String, password: String) async throws { - await lock.acquire() - defer { lock.release() } - - guard !principal.isEmpty, !password.isEmpty else { - try await conn.connect() - return - } - - let cache = try MSSQLKerberosCredentials.acquireTicket(principal: principal, password: password) - defer { cache.destroy() } - - let previous = getenv("KRB5CCNAME").map { String(cString: $0) } - setenv("KRB5CCNAME", cache.name, 1) - defer { - if let previous { - setenv("KRB5CCNAME", previous, 1) - } else { - unsetenv("KRB5CCNAME") - } - } - - try await conn.connect() - } -} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift b/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift index b4c21c1a2..f64644580 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLKerberosCredentials.swift @@ -2,21 +2,28 @@ import Foundation import GSS import TableProMSSQLCore -struct MSSQLKerberosCache { - let name: String - let filePath: String +enum MSSQLKerberosCredentials { + private static let acquisitionQueue = DispatchQueue( + label: "com.TablePro.mssql.kerberos-acquire", + qos: .userInitiated + ) - func destroy() { - try? FileManager.default.removeItem(atPath: filePath) + static func acquireTicket(principal: String, password: String, timeoutSeconds: Int) async throws -> String { + let cachePath = (NSTemporaryDirectory() as NSString) + .appendingPathComponent("tablepro-krb5-\(UUID().uuidString)") + return try await runCancellableBlocking( + on: acquisitionQueue, + deadline: .seconds(timeoutSeconds), + timeoutError: { MSSQLCoreError.connectionTimedOut(isKerberos: true) }, + work: { + try acquireTicketSync(principal: principal, password: password, cachePath: cachePath) + return cachePath + }, + discardLateResult: { path in try? FileManager.default.removeItem(atPath: path) } + ) } -} - -enum MSSQLKerberosCredentials { - static func acquireTicket(principal: String, password: String) throws -> MSSQLKerberosCache { - let fileName = "tablepro-krb5-\(UUID().uuidString)" - let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName) - let cacheName = "FILE:\(filePath)" + private static func acquireTicketSync(principal: String, password: String, cachePath: String) throws { let importedName = try importName(principal) defer { var releaseMinor: OM_uint32 = 0 @@ -26,7 +33,7 @@ enum MSSQLKerberosCredentials { let attributes = NSMutableDictionary() attributes[kGSSICPassword] = password - attributes[kGSSICKerberosCacheName] = cacheName + attributes[kGSSICKerberosCacheName] = "FILE:\(cachePath)" var cred: gss_cred_id_t? var errorRef: Unmanaged? @@ -41,7 +48,7 @@ enum MSSQLKerberosCredentials { guard status == 0 else { let message = errorRef?.takeRetainedValue().localizedDescription ?? String(localized: "Kerberos ticket request failed") - try? FileManager.default.removeItem(atPath: filePath) + try? FileManager.default.removeItem(atPath: cachePath) throw MSSQLCoreError.kerberosAuthFailed( kind: MSSQLKerberosClassifier.classify(message) ?? .wrongPassword, serverMessage: message @@ -52,8 +59,6 @@ enum MSSQLKerberosCredentials { var releaseMinor: OM_uint32 = 0 _ = gss_release_cred(&releaseMinor, &cred) } - - return MSSQLKerberosCache(name: cacheName, filePath: filePath) } private static func importName(_ principal: String) throws -> gss_name_t { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index f400e0d94..1df22f7b5 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -48,6 +48,8 @@ private extension MSSQLPluginError { self = .connectionFailed(String(format: String(localized: "TLS: %@"), serverMessage)) case let .kerberosAuthFailed(kind, serverMessage): self = .connectionFailed(MSSQLKerberosMessage.describe(kind: kind, serverMessage: serverMessage)) + case .connectionTimedOut: + self = .connectionFailed(coreError.localizedDescription) } } } @@ -263,19 +265,22 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func connect() async throws { let authMethod = MSSQLConnectionOptions.authMethod(from: config.additionalFields) - let options = MSSQLConnectionOptions( - host: config.host, - port: config.port, - user: config.username, - password: config.password, - database: config.database, - schema: _currentSchema, - encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode), - authMethod: authMethod - ) - let conn = FreeTDSConnection(options: options) + let conn: FreeTDSConnection do { - try await establishConnection(conn, authMethod: authMethod) + let kerberosCachePath = try await acquireKerberosTicketIfNeeded(authMethod: authMethod) + let options = MSSQLConnectionOptions( + host: config.host, + port: config.port, + user: config.username, + password: config.password, + database: config.database, + schema: _currentSchema, + encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode), + authMethod: authMethod, + kerberosCachePath: kerberosCachePath + ) + conn = FreeTDSConnection(options: options) + try await conn.connect() } catch let error as MSSQLCoreError { switch error { case let .tlsHandshakeFailed(kind, serverMessage): @@ -309,15 +314,17 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } - private func establishConnection(_ conn: FreeTDSConnection, authMethod: MSSQLAuthMethod) async throws { - guard authMethod == .windows else { - try await conn.connect() - return - } + private func acquireKerberosTicketIfNeeded(authMethod: MSSQLAuthMethod) async throws -> String? { + guard authMethod == .windows else { return nil } let principal = (config.additionalFields[MSSQLKerberosField.principal] ?? "") .trimmingCharacters(in: .whitespaces) let password = config.additionalFields[MSSQLKerberosField.password] ?? "" - try await MSSQLKerberosConnectGate.shared.connect(conn, principal: principal, password: password) + guard !principal.isEmpty, !password.isEmpty else { return nil } + return try await MSSQLKerberosCredentials.acquireTicket( + principal: principal, + password: password, + timeoutSeconds: MSSQLConnectionOptions.defaultLoginTimeoutSeconds + ) } private func executeInternal(_ query: String) async throws -> PluginQueryResult { diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 2de59da80..9506af62e 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -280,6 +280,8 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { return DatabaseError(message: "TLS handshake failed: \(serverMessage)") case .kerberosAuthFailed(_, let serverMessage): return DatabaseError(message: "Kerberos authentication failed: \(serverMessage)") + case .connectionTimedOut: + return DatabaseError(message: error.localizedDescription) case .queryFailed(let msg): return DatabaseError(message: msg) case .cancelled: