From f752b996b9a4f230091ec0166c9ed17e8f3bb082 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:07:09 +0700 Subject: [PATCH 001/151] fix(ios): test the SSH tunnel against real OpenSSH servers and name its failures --- .../KeyboardInteractivePromptKind.swift | 26 + .../TableProSSHTransport/SSHAuthMethods.swift | 23 + .../SSHChannelFreePump.swift | 51 ++ .../SSHChannelRelay.swift | 18 +- .../SSHNegotiationFailure.swift | 52 ++ .../SSHPrivateKeyTraits.swift | 75 +++ .../KeyboardInteractivePromptKindTests.swift | 37 ++ .../SSHAuthMethodsTests.swift | 79 +++ .../SSHChannelFreePumpTests.swift | 106 ++++ .../SSHChannelRelayTests.swift | 80 ++- .../SSHNegotiationFailureTests.swift | 86 ++++ .../SSHPrivateKeyTraitsTests.swift | 136 ++++++ .../KeyboardInteractiveAuthenticator.swift | 51 +- .../TablePro/Core/SSH/LibSSH2ChannelIO.swift | 45 +- .../TableProMobile/Helpers/AppError.swift | 4 + .../Helpers/ErrorClassifier+SSHTunnel.swift | 65 +++ .../TableProMobile/Localizable.xcstrings | 87 ++++ .../SSH/LibSSH2Authentication.swift | 300 ++++++++++++ .../SSH/LibSSH2HandshakeFailure.swift | 85 ++++ .../TableProMobile/SSH/SSHTunnel.swift | 60 ++- .../TableProMobile/SSH/SSHTunnelError.swift | 88 ++++ .../TableProMobile/SSH/SSHTunnelFactory.swift | 4 +- .../TableProMobile/SSH/SSHTunnelStages.swift | 50 +- .../SSH/LibSSH2AuthenticationTests.swift | 457 ++++++++++++++++++ .../SSH/LibSSH2ChannelIOTests.swift | 56 +++ .../SSH/SSHTunnelFactoryTests.swift | 2 +- .../SSHTunnelErrorClassifierTests.swift | 120 +++++ .../KeyboardInteractiveContextTests.swift | 1 + apple/scripts/lib/openssh-fixture.sh | 42 ++ .../scripts/probes/check-ssh-tunnel-matrix.sh | 220 +++++++++ .../ssh-tunnel-matrix/build-ios-harness.sh | 165 +++++++ .../ssh-tunnel-matrix/ios-harness.swift | 374 ++++++++++++++ apple/scripts/sftp-test-server.sh | 30 +- apple/scripts/ssh-test-servers.sh | 353 ++++++++++++++ apple/scripts/ssh-test-servers/common.conf | 6 + .../ssh-test-servers/kbdint-password.conf | 5 + apple/scripts/ssh-test-servers/mac-only.conf | 4 + apple/scripts/ssh-test-servers/make-sk-key.py | 70 +++ .../ssh-test-servers/max-auth-tries.conf | 2 + apple/scripts/ssh-test-servers/pam-sshd-totp | 12 + .../ssh-test-servers/password-totp.conf | 6 + apple/scripts/ssh-test-servers/pq-only.conf | 3 + .../ssh-test-servers/publickey-totp.conf | 6 + .../ssh-test-servers/rsa-sha2-256.conf | 2 + .../ssh-test-servers/rsa-sha2-512.conf | 2 + .../scripts/ssh-test-servers/totp.Dockerfile | 7 + apple/scripts/ssh-test-servers/totp.conf | 6 + 47 files changed, 3417 insertions(+), 142 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift create mode 100644 apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift create mode 100644 apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift create mode 100644 apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift create mode 100644 apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift create mode 100644 apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift create mode 100644 apple/scripts/lib/openssh-fixture.sh create mode 100755 apple/scripts/probes/check-ssh-tunnel-matrix.sh create mode 100755 apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh create mode 100644 apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift create mode 100755 apple/scripts/ssh-test-servers.sh create mode 100644 apple/scripts/ssh-test-servers/common.conf create mode 100644 apple/scripts/ssh-test-servers/kbdint-password.conf create mode 100644 apple/scripts/ssh-test-servers/mac-only.conf create mode 100755 apple/scripts/ssh-test-servers/make-sk-key.py create mode 100644 apple/scripts/ssh-test-servers/max-auth-tries.conf create mode 100644 apple/scripts/ssh-test-servers/pam-sshd-totp create mode 100644 apple/scripts/ssh-test-servers/password-totp.conf create mode 100644 apple/scripts/ssh-test-servers/pq-only.conf create mode 100644 apple/scripts/ssh-test-servers/publickey-totp.conf create mode 100644 apple/scripts/ssh-test-servers/rsa-sha2-256.conf create mode 100644 apple/scripts/ssh-test-servers/rsa-sha2-512.conf create mode 100644 apple/scripts/ssh-test-servers/totp.Dockerfile create mode 100644 apple/scripts/ssh-test-servers/totp.conf diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift new file mode 100644 index 0000000000..a4eb0f18ad --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/KeyboardInteractivePromptKind.swift @@ -0,0 +1,26 @@ +// +// KeyboardInteractivePromptKind.swift +// TableProSSHTransport +// + +import Foundation + +public enum KeyboardInteractivePromptKind: Equatable, Sendable { + case password + case totp + case unmatched + + // The code words go first: "One-time password: " asks for a code, not the SSH password. + public init(promptText: String) { + let lower = promptText.lowercased() + if Self.codeKeywords.contains(where: { lower.contains($0) }) { + self = .totp + } else if lower.contains("password") { + self = .password + } else { + self = .unmatched + } + } + + private static let codeKeywords = ["verification", "code", "otp", "token", "totp", "2fa", "one-time", "factor"] +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift new file mode 100644 index 0000000000..88abd6f4d1 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHAuthMethods.swift @@ -0,0 +1,23 @@ +// +// SSHAuthMethods.swift +// TableProSSHTransport +// + +import Foundation + +public enum SSHAuthMethods { + public static func parse(_ list: String?) -> [String] { + guard let list else { return [] } + return list + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + } + + // libssh2 never reads the partial-success flag, so an accepted method that needs another step + // shows only as that method leaving the server's list. + public static func remainingAfterPartialSuccess(of method: String, before: [String], after: [String]) -> [String]? { + guard before.contains(method), !after.isEmpty, !after.contains(method) else { return nil } + return after + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift new file mode 100644 index 0000000000..90f224b426 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelFreePump.swift @@ -0,0 +1,51 @@ +// +// SSHChannelFreePump.swift +// TableProSSHTransport +// + +import Foundation + +public enum SSHChannelFreeAttempt: Equatable, Sendable { + case finished + case wouldBlock(RelayDirections) +} + +public enum SSHChannelFreeOutcome: Equatable, Sendable { + case freed + case stopped + case timedOut +} + +// libssh2 keeps a CLOSE it could not send as the session's next packet and turns every other send +// away until the same free sends it, so a free never stops after a fixed number of tries: it runs +// until it finishes, the tunnel stops, or the deadline says the session is stuck. +public struct SSHChannelFreePump { + public let attemptFree: () -> SSHChannelFreeAttempt + public let isActive: () -> Bool + public let deadline: Date + public let waitForTransport: (RelayDirections) -> Void + public var now: () -> Date + + public init( + attemptFree: @escaping () -> SSHChannelFreeAttempt, + isActive: @escaping () -> Bool, + deadline: Date, + waitForTransport: @escaping (RelayDirections) -> Void, + now: @escaping () -> Date = Date.init + ) { + self.attemptFree = attemptFree + self.isActive = isActive + self.deadline = deadline + self.waitForTransport = waitForTransport + self.now = now + } + + public func run() -> SSHChannelFreeOutcome { + while true { + guard case .wouldBlock(let directions) = attemptFree() else { return .freed } + guard isActive() else { return .stopped } + guard now() < deadline else { return .timedOut } + waitForTransport(directions) + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift index e67d0da8e3..ea3e7cc078 100644 --- a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHChannelRelay.swift @@ -20,12 +20,15 @@ public enum ChannelReadResult: Equatable, Sendable { case bytes(Int) case wouldBlock case closed + // The session is gone, not only this channel: the relay ends .transportHangup so the owner marks the tunnel dead now, not at the next keep-alive. + case transportFailed } public enum ChannelWriteResult: Equatable, Sendable { case bytes(Int) case wouldBlock case closed + case transportFailed } public protocol SSHChannelIO { @@ -41,13 +44,7 @@ public enum RelayTermination: Equatable, Sendable { case cancelled } -/// Bidirectional relay between a local socket fd and an SSH channel. -/// Polls the local fd and the SSH transport fd, draining buffered data before -/// tearing down on hangup so the last bytes are not dropped. Hangup and error -/// on either fd terminate the loop instead of spinning on a permanently -/// poll-ready, closed fd. Because libssh2 reports EAGAIN rather than a channel -/// close when only the transport dies, a transport that polls readable but is -/// at EOF is detected directly so a half-closed transport cannot spin. +// A hangup drains buffered data before the loop ends, so the last bytes reach the client. public struct SSHChannelRelay { public let localFD: Int32 public let transportFD: Int32 @@ -164,6 +161,8 @@ public struct SSHChannelRelay { return .drained case .closed: return .channelClosed + case .transportFailed: + return .transportHangup } } return .cappedWithMoreQueued @@ -172,7 +171,8 @@ public struct SSHChannelRelay { private func sendToLocal(_ buffer: UnsafeMutablePointer, count: Int) -> Bool { var totalSent = 0 while totalSent < count { - let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, 0) + // Per send, not SO_NOSIGPIPE: setsockopt fails with EINVAL once a client has already closed, and the send then raises SIGPIPE. + let sent = send(localFD, buffer.advanced(by: totalSent), count - totalSent, MSG_NOSIGNAL) if sent <= 0 { return false } totalSent += sent } @@ -196,6 +196,8 @@ public struct SSHChannelRelay { } case .closed: return .channelClosed + case .transportFailed: + return .transportHangup } } return .drained diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift new file mode 100644 index 0000000000..2e9fb8e9ac --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHNegotiationFailure.swift @@ -0,0 +1,52 @@ +// +// SSHNegotiationFailure.swift +// TableProSSHTransport +// + +import Foundation + +public struct SSHNegotiationFailure: Equatable, Sendable { + public enum Stage: Equatable, Sendable { + case keyExchangeOrHostKey + case cipher + case mac + case undetermined + } + + public let stage: Stage + public let serverSoftware: String? + public let supported: [String] + public let supportedHostKeys: [String] + + public init(stage: Stage, serverSoftware: String?, supported: [String], supportedHostKeys: [String]) { + self.stage = stage + self.serverSoftware = serverSoftware + self.supported = supported + self.supportedHostKeys = supportedHostKeys + } + + // libssh2 records a key exchange only once one with a usable host key agrees, so a nil one cannot tell the two apart. + public static func stage(kex: String?, cipher: String?, mac: String?) -> Stage { + if kex == nil { return .keyExchangeOrHostKey } + if cipher == nil { return .cipher } + if mac == nil { return .mac } + return .undetermined + } + + public static func serverSoftware(banner: String?) -> String? { + guard let line = banner?.trimmingCharacters(in: .whitespacesAndNewlines), line.hasPrefix("SSH-") else { + return nil + } + let fields = line.split(separator: "-", maxSplits: 2, omittingEmptySubsequences: false) + guard fields.count == 3 else { return nil } + let software = fields[2].prefix { !$0.isWhitespace } + return software.isEmpty ? nil : String(software) + } + + public static func clientMethods(_ names: [String]) -> [String] { + names.filter { !pseudoMethods.contains($0) } + } + + // Extension negotiation and strict key exchange ride in the key exchange list but no server can pick them. + private static let pseudoMethods: Set = ["ext-info-c", "kex-strict-c-v00@openssh.com"] +} diff --git a/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift new file mode 100644 index 0000000000..52d3d5e659 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProSSHTransport/SSHPrivateKeyTraits.swift @@ -0,0 +1,75 @@ +// +// SSHPrivateKeyTraits.swift +// TableProSSHTransport +// + +import Foundation + +// openssh-key-v1 keeps the cipher name and the public key in the clear, so both read without the passphrase. +public struct SSHPrivateKeyTraits: Equatable, Sendable { + public let keyType: String? + public let isEncrypted: Bool + + public var isSecurityKey: Bool { + keyType?.hasPrefix("sk-") ?? false + } + + public init?(openSSH: String) { + guard let begin = openSSH.range(of: Self.beginMarker), + let end = openSSH.range(of: Self.endMarker, range: begin.upperBound.. String? { + guard reader.field() != nil, + reader.field() != nil, + let keyCount = reader.uint32(), keyCount > 0, + let publicKey = reader.field() + else { return nil } + var blob = SSHWireReader(Array(publicKey)) + return blob.string() + } +} + +private struct SSHWireReader { + private let data: [UInt8] + private var offset = 0 + + init(_ data: [UInt8]) { + self.data = data + } + + mutating func bytes(_ count: Int) -> ArraySlice? { + guard count >= 0, count <= data.count - offset else { return nil } + defer { offset += count } + return data[offset.. Int? { + bytes(4)?.reduce(0) { ($0 << 8) | Int($1) } + } + + mutating func field() -> ArraySlice? { + guard let length = uint32() else { return nil } + return bytes(length) + } + + mutating func string() -> String? { + field().flatMap { String(bytes: $0, encoding: .utf8) } + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift new file mode 100644 index 0000000000..93f0e5b84f --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/KeyboardInteractivePromptKindTests.swift @@ -0,0 +1,37 @@ +// +// KeyboardInteractivePromptKindTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct KeyboardInteractivePromptKindTests { + @Test("A password prompt is the SSH password", arguments: ["Password: ", "PASSWORD:", "(tablepro@127.0.0.1) Password: "]) + func passwordPrompt(text: String) { + #expect(KeyboardInteractivePromptKind(promptText: text) == .password) + } + + @Test( + "A prompt naming a code is a TOTP prompt", + arguments: [ + "Verification code: ", "Enter OTP: ", "Token: ", "TOTP: ", "2FA: ", "One-time code: ", "Second factor: ", + ] + ) + func codePrompt(text: String) { + #expect(KeyboardInteractivePromptKind(promptText: text) == .totp) + } + + @Test("One-time password asks for a code, not the SSH password") + func oneTimePasswordIsACode() { + #expect(KeyboardInteractivePromptKind(promptText: "One-time password: ") == .totp) + #expect(KeyboardInteractivePromptKind(promptText: "Password and verification code: ") == .totp) + } + + @Test("A prompt with no known word is unmatched", arguments: ["Enter your PIN: ", "Response: ", ""]) + func unmatchedPrompt(text: String) { + #expect(KeyboardInteractivePromptKind(promptText: text) == .unmatched) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift new file mode 100644 index 0000000000..6f16acfcf5 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHAuthMethodsTests.swift @@ -0,0 +1,79 @@ +// +// SSHAuthMethodsTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHAuthMethodsTests { + @Test("The server's list splits on commas in its order") + func parsesTheList() { + #expect(SSHAuthMethods.parse("publickey,password,keyboard-interactive") == [ + "publickey", "password", "keyboard-interactive", + ]) + } + + @Test("No list, an empty one, or stray separators give no methods") + func parsesNothing() { + #expect(SSHAuthMethods.parse(nil).isEmpty) + #expect(SSHAuthMethods.parse("").isEmpty) + #expect(SSHAuthMethods.parse(",").isEmpty) + } + + @Test("Blank entries and spaces are dropped") + func dropsBlankEntries() { + #expect(SSHAuthMethods.parse("publickey,, password ") == ["publickey", "password"]) + } + + @Test("A password accepted with a second step leaves only the next method") + func passwordThenCode() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "password", + before: ["password"], + after: ["keyboard-interactive"] + ) + + #expect(remaining == ["keyboard-interactive"]) + } + + @Test("A key accepted with a second step returns every method still on offer") + func keyThenOthers() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "publickey", + before: ["publickey"], + after: ["password", "keyboard-interactive"] + ) + + #expect(remaining == ["password", "keyboard-interactive"]) + } + + @Test("A rejected method stays on the list, so it is not partial success") + func rejectedMethodStaysListed() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "password", + before: ["publickey", "password"], + after: ["publickey", "password"] + ) + + #expect(remaining == nil) + } + + @Test("An unreadable list after the failure, as after a disconnect, is not partial success") + func emptyListAfter() { + #expect(SSHAuthMethods.remainingAfterPartialSuccess(of: "password", before: ["password"], after: []) == nil) + } + + @Test("A method the server never offered cannot have partly succeeded") + func methodNotOfferedBefore() { + let remaining = SSHAuthMethods.remainingAfterPartialSuccess( + of: "keyboard-interactive", + before: ["password"], + after: ["publickey"] + ) + + #expect(remaining == nil) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift new file mode 100644 index 0000000000..7a09507085 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelFreePumpTests.swift @@ -0,0 +1,106 @@ +// +// SSHChannelFreePumpTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHChannelFreePumpTests { + @Test("A free that finishes at once does not wait") + func freesWithoutWaiting() { + let free = ScriptedFree([.finished]) + let waits = WaitLog() + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { true }, + deadline: Date().addingTimeInterval(60), + waitForTransport: waits.record + ).run() + + #expect(outcome == .freed) + #expect(free.attempts == 1) + #expect(waits.directions.isEmpty) + } + + @Test("A free that would block runs again after each wait, past any fixed number of tries") + func retriesUntilFreed() { + let free = ScriptedFree(Array(repeating: .wouldBlock(.outbound), count: 25) + [.wouldBlock(.inbound), .finished]) + let waits = WaitLog() + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { true }, + deadline: Date().addingTimeInterval(60), + waitForTransport: waits.record + ).run() + + #expect(outcome == .freed) + #expect(free.attempts == 27) + #expect(waits.directions.count == 26) + #expect(waits.directions.last == .inbound) + } + + @Test("A tunnel that stops ends the free, which the session free then finishes") + func stopsWithTheTunnel() { + let free = ScriptedFree([.wouldBlock(.outbound), .wouldBlock(.outbound)]) + var active = true + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { active }, + deadline: Date().addingTimeInterval(60), + waitForTransport: { _ in active = false } + ).run() + + #expect(outcome == .stopped) + #expect(free.attempts == 2) + } + + @Test("A free still blocked at the deadline times out") + func timesOutAtTheDeadline() { + let free = ScriptedFree([]) + let start = Date(timeIntervalSince1970: 0) + var reads = 0 + + let outcome = SSHChannelFreePump( + attemptFree: free.next, + isActive: { true }, + deadline: start.addingTimeInterval(5), + waitForTransport: { _ in }, + now: { + defer { reads += 1 } + return start.addingTimeInterval(Double(reads)) + } + ).run() + + #expect(outcome == .timedOut) + #expect(free.attempts == 6) + } +} + +private final class ScriptedFree { + private var script: [SSHChannelFreeAttempt] + private(set) var attempts = 0 + + // Once the script runs out, every attempt would block. + init(_ script: [SSHChannelFreeAttempt]) { + self.script = script + } + + func next() -> SSHChannelFreeAttempt { + attempts += 1 + return script.isEmpty ? .wouldBlock(.inbound) : script.removeFirst() + } +} + +private final class WaitLog { + private(set) var directions: [RelayDirections] = [] + + func record(_ directions: RelayDirections) { + self.directions.append(directions) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift index ae0db540f8..435839ce5e 100644 --- a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHChannelRelayTests.swift @@ -96,6 +96,78 @@ struct SSHChannelRelayTests { #expect(result == .channelClosed) } + @Test("A transport failure on read ends as a transport hangup, not a channel close") + func readTransportFailureIsHangup() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + var dummy: UInt8 = 1 + _ = Darwin.send(transport.b, &dummy, 1, 0) + + let result = runRelay( + localFD: local.a, + transportFD: transport.a, + io: FakeChannelIO(fallback: .transportFailed) + ) + + #expect(result == .transportHangup) + } + + @Test("A transport failure on read after the SSH server hung up ends as a transport hangup") + func readTransportFailureAfterHangupIsHangup() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + transport.closeB() + + let result = runRelay( + localFD: local.a, + transportFD: transport.a, + io: FakeChannelIO(fallback: .transportFailed) + ) + + #expect(result == .transportHangup) + } + + @Test("A transport failure on write ends as a transport hangup, not a channel close") + func writeTransportFailureIsHangup() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + let payload = Data("world".utf8) + payload.withUnsafeBytes { raw in + _ = Darwin.send(local.b, raw.baseAddress, raw.count, 0) + } + + let result = runRelay( + localFD: local.a, + transportFD: transport.a, + io: FakeChannelIO(fallback: .wouldBlock, writeResult: .transportFailed) + ) + + #expect(result == .transportHangup) + } + + @Test("Channel data for a client that already closed ends the relay as a local close") + func channelDataForClosedClientIsLocalClose() { + let local = SocketPair() + let transport = SocketPair() + defer { local.close(); transport.close() } + + local.closeB() + var dummy: UInt8 = 1 + _ = Darwin.send(transport.b, &dummy, 1, 0) + + let io = FakeChannelIO(actions: [.data(Data("rows".utf8))], fallback: .wouldBlock) + let result = runRelay(localFD: local.a, transportFD: transport.a, io: io) + + #expect(result == .localClosed) + #expect(io.readCount == 1) + } + @Test("Channel data is forwarded to the local socket") func forwardsChannelData() { let local = SocketPair() @@ -449,18 +521,21 @@ internal final class FakeChannelIO: SSHChannelIO, @unchecked Sendable { case data(Data) case wouldBlock case closed + case transportFailed } private let lock = NSLock() private var actions: [Action] private let fallback: Action + private let writeResult: ChannelWriteResult? private var writtenBuffer = Data() private var reads = 0 private var delivered = 0 - init(actions: [Action] = [], fallback: Action = .wouldBlock) { + init(actions: [Action] = [], fallback: Action = .wouldBlock, writeResult: ChannelWriteResult? = nil) { self.actions = actions self.fallback = fallback + self.writeResult = writeResult } var written: Data { @@ -502,12 +577,15 @@ internal final class FakeChannelIO: SSHChannelIO, @unchecked Sendable { return .wouldBlock case .closed: return .closed + case .transportFailed: + return .transportFailed } } func write(_ buffer: UnsafePointer, count: Int) -> ChannelWriteResult { lock.lock() defer { lock.unlock() } + if let writeResult { return writeResult } buffer.withMemoryRebound(to: UInt8.self, capacity: count) { source in writtenBuffer.append(contentsOf: UnsafeBufferPointer(start: source, count: count)) } diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift new file mode 100644 index 0000000000..2fb4109aa0 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHNegotiationFailureTests.swift @@ -0,0 +1,86 @@ +// +// SSHNegotiationFailureTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHNegotiationFailureTests { + @Test("No agreed key exchange is the key exchange or host key stage, whatever else is missing") + func noKeyExchange() { + #expect(SSHNegotiationFailure.stage(kex: nil, cipher: nil, mac: nil) == .keyExchangeOrHostKey) + #expect(SSHNegotiationFailure.stage(kex: nil, cipher: "aes128-ctr", mac: "hmac-sha2-256") == .keyExchangeOrHostKey) + } + + @Test("An agreed key exchange with no cipher is the cipher stage") + func noCipher() { + #expect(SSHNegotiationFailure.stage(kex: "curve25519-sha256", cipher: nil, mac: nil) == .cipher) + } + + @Test("A MAC-only server agrees key exchange and cipher, then fails the MAC") + func noMac() { + let stage = SSHNegotiationFailure.stage(kex: "curve25519-sha256", cipher: "aes128-ctr", mac: nil) + #expect(stage == .mac) + } + + @Test("Every method agreed leaves the stage undetermined") + func everythingAgreed() { + let stage = SSHNegotiationFailure.stage(kex: "curve25519-sha256", cipher: "aes128-ctr", mac: "hmac-sha2-256") + #expect(stage == .undetermined) + } + + @Test( + "The server software is the banner's software version", + arguments: [ + ("SSH-2.0-OpenSSH_10.3", "OpenSSH_10.3"), + ("SSH-2.0-OpenSSH_9.9p2 Debian-1", "OpenSSH_9.9p2"), + ("SSH-2.0-OpenSSH_9.6p1-hpn\r\n", "OpenSSH_9.6p1-hpn"), + ("SSH-1.99-dropbear_2024.86", "dropbear_2024.86"), + ] + ) + func serverSoftware(banner: String, software: String) { + #expect(SSHNegotiationFailure.serverSoftware(banner: banner) == software) + } + + @Test("A missing or malformed banner has no server software", arguments: [nil, "", "SSH-2.0-", "SSH-2.0", "HTTP/1.1 400 Bad Request"]) + func noServerSoftware(banner: String?) { + #expect(SSHNegotiationFailure.serverSoftware(banner: banner) == nil) + } + + @Test("TablePro's list drops the pseudo-methods and keeps libssh2's order") + func clientMethodsDropPseudoMethods() { + let libssh2 = [ + "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", + "diffie-hellman-group14-sha256", "ext-info-c", "kex-strict-c-v00@openssh.com", + ] + + #expect(SSHNegotiationFailure.clientMethods(libssh2) == [ + "curve25519-sha256", "curve25519-sha256@libssh.org", "ecdh-sha2-nistp256", "diffie-hellman-group14-sha256", + ]) + } + + @Test("A host key or cipher list passes through unchanged") + func clientMethodsKeepRealMethods() { + let hostKeys = ["ssh-ed25519", "ecdsa-sha2-nistp256", "rsa-sha2-512", "rsa-sha2-256"] + + #expect(SSHNegotiationFailure.clientMethods(hostKeys) == hostKeys) + } + + @Test("The failure carries what the app shows") + func carriesItsFields() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_10.3", + supported: ["curve25519-sha256"], + supportedHostKeys: ["ssh-ed25519"] + ) + + #expect(failure.stage == .keyExchangeOrHostKey) + #expect(failure.serverSoftware == "OpenSSH_10.3") + #expect(failure.supported == ["curve25519-sha256"]) + #expect(failure.supportedHostKeys == ["ssh-ed25519"]) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift new file mode 100644 index 0000000000..5f78327bb2 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSSHTransportTests/SSHPrivateKeyTraitsTests.swift @@ -0,0 +1,136 @@ +// +// SSHPrivateKeyTraitsTests.swift +// TableProSSHTransportTests +// + +import Foundation +import Testing + +@testable import TableProSSHTransport + +struct SSHPrivateKeyTraitsTests { + @Test("A plain key reads its type and is not encrypted", arguments: ["ssh-ed25519", "ssh-rsa", "ecdsa-sha2-nistp256"]) + func plainKey(keyType: String) throws { + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armored(cipher: "none", keyType: keyType))) + + #expect(traits.keyType == keyType) + #expect(!traits.isEncrypted) + #expect(!traits.isSecurityKey) + } + + @Test("An encrypted key still reads its type", arguments: ["aes256-ctr", "aes256-gcm@openssh.com", "chacha20-poly1305@openssh.com"]) + func encryptedKey(cipher: String) throws { + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armored(cipher: cipher, keyType: "ssh-ed25519"))) + + #expect(traits.keyType == "ssh-ed25519") + #expect(traits.isEncrypted) + } + + @Test("A FIDO key is a security key", arguments: ["sk-ssh-ed25519@openssh.com", "sk-ecdsa-sha2-nistp256@openssh.com"]) + func securityKey(keyType: String) throws { + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armored(cipher: "none", keyType: keyType))) + + #expect(traits.isSecurityKey) + #expect(traits.keyType == keyType) + } + + @Test("CRLF line endings and text around the block still parse") + func toleratesLayout() throws { + let armored = OpenSSHKeyFile.armored(cipher: "aes256-ctr", keyType: "ssh-rsa") + .replacingOccurrences(of: "\n", with: "\r\n") + let traits = try #require(SSHPrivateKeyTraits(openSSH: " \nComment above\n" + armored + "\n\n")) + + #expect(traits.keyType == "ssh-rsa") + #expect(traits.isEncrypted) + } + + @Test("A key whose public section is cut short keeps its cipher but has no type") + func truncatedAfterCipher() throws { + let cut = Array(OpenSSHKeyFile.container(cipher: "aes256-ctr", keyType: "ssh-ed25519").prefix(40)) + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(cut))) + + #expect(traits.isEncrypted) + #expect(traits.keyType == nil) + #expect(!traits.isSecurityKey) + } + + @Test("A container that lists no keys has no type") + func noKeys() throws { + let empty = OpenSSHKeyFile.container(cipher: "none", keyType: "ssh-ed25519", keyCount: 0) + let traits = try #require(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(empty))) + + #expect(traits.keyType == nil) + } + + @Test("A length past the end of the data is not read") + func oversizedLength() throws { + var bytes = OpenSSHKeyFile.magic + bytes += OpenSSHKeyFile.uint32(10) + bytes += Array("none".utf8) + + #expect(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(bytes)) == nil) + } + + @Test( + "Text that is not an openssh-key-v1 key gives no traits", + arguments: [ + "", + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA user@host", + "-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAK\n-----END RSA PRIVATE KEY-----\n", + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE\n", + "-----BEGIN OPENSSH PRIVATE KEY-----\n!!!not base64!!!\n-----END OPENSSH PRIVATE KEY-----\n", + ] + ) + func notAnOpenSSHKey(text: String) { + #expect(SSHPrivateKeyTraits(openSSH: text) == nil) + } + + @Test("A body without the openssh-key-v1 magic gives no traits") + func wrongMagic() { + let bytes = Array("openssh-key-v2\0".utf8) + OpenSSHKeyFile.field(Array("none".utf8)) + + #expect(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(bytes)) == nil) + } + + @Test("An empty cipher name gives no traits") + func emptyCipher() { + let bytes = OpenSSHKeyFile.magic + OpenSSHKeyFile.field([]) + + #expect(SSHPrivateKeyTraits(openSSH: OpenSSHKeyFile.armor(bytes)) == nil) + } +} + +private enum OpenSSHKeyFile { + static let magic = Array("openssh-key-v1\0".utf8) + + static func armored(cipher: String, keyType: String) -> String { + armor(container(cipher: cipher, keyType: keyType)) + } + + static func container(cipher: String, keyType: String, keyCount: UInt32 = 1) -> [UInt8] { + let encrypted = cipher != "none" + let kdfOptions = encrypted ? field([UInt8](repeating: 7, count: 16)) + uint32(16) : [] + let publicKey = field(Array(keyType.utf8)) + field([UInt8](repeating: 1, count: 32)) + var bytes = magic + bytes += field(Array(cipher.utf8)) + bytes += field(Array((encrypted ? "bcrypt" : "none").utf8)) + bytes += field(kdfOptions) + bytes += uint32(keyCount) + bytes += field(publicKey) + bytes += field([UInt8](repeating: 9, count: 64)) + return bytes + } + + static func armor(_ bytes: [UInt8]) -> String { + let body = Data(bytes).base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed]) + return "-----BEGIN OPENSSH PRIVATE KEY-----\n\(body)\n-----END OPENSSH PRIVATE KEY-----\n" + } + + static func field(_ bytes: [UInt8]) -> [UInt8] { + uint32(UInt32(bytes.count)) + bytes + } + + static func uint32(_ value: UInt32) -> [UInt8] { + [UInt8((value >> 24) & 0xFF), UInt8((value >> 16) & 0xFF), UInt8((value >> 8) & 0xFF), UInt8(value & 0xFF)] + } +} diff --git a/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift b/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift index 5ff2c4f63c..779859b6f3 100644 --- a/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift +++ b/apple/TablePro/Core/SSH/Auth/KeyboardInteractiveAuthenticator.swift @@ -8,25 +8,10 @@ import os import CLibSSH2 import TableProSSHConfig +import TableProSSHTransport -/// How a keyboard-interactive prompt should be answered without asking the user. -/// -/// The classification is a fast-path hint only: it decides when a pre-known credential (the SSH -/// password, an auto-generated TOTP code) can answer a prompt. Anything not claimed here defers to -/// the interactive prompt, so a weak keyword match is a missed shortcut, not a wrong answer. -internal enum KBDINTPromptType: Equatable { - case password - case totp - case unmatched -} - -/// Context reached from the C callback through the libssh2 session abstract pointer. -/// -/// TOTP codes and user prompts are resolved lazily inside the callback (not upfront) so that a -/// generated code is still valid when PAM checks it, and so the server can re-prompt within one -/// session (PAM defaults to 3 attempts) with each retry driving a fresh code or dialog. The C -/// callback can't throw across the libssh2 boundary, so failures and cancellation are recorded here -/// and surface after `libssh2_userauth_keyboard_interactive_ex` returns. +// Codes and prompts resolve inside the callback, so a code is still valid when PAM checks it and each re-prompt gets a fresh one. +// The C callback cannot throw, so errors and cancellation wait here until libssh2 returns. internal final class KeyboardInteractiveContext { let password: String? let totpProvider: (any TOTPProvider)? @@ -47,9 +32,7 @@ internal final class KeyboardInteractiveContext { self.promptProvider = promptProvider } - /// What the rejection was about, named by whichever answer actually went to the server. A - /// server that issued no prompt at all never took a credential from this method, so the - /// failure says nothing about the user's own: it says keyboard-interactive was not on offer. + // A server that sent no prompt never took a credential, so the method was not on offer. var failureReason: AuthFailureReason { if interactiveAttemptCount > 0 { return .keyboardInteractive } if totpAttemptCount > 0 { return .verificationCode } @@ -68,9 +51,7 @@ internal final class KeyboardInteractiveContext { } } - /// Resolve one response per prompt. Known prompts are answered from the password / TOTP - /// fast path; the rest go to the interactive prompt in a single dialog. Every index is filled - /// (empty string on cancel) so libssh2 never frees an unset response. + // Every slot gets a string, empty on cancel, since libssh2 frees each response. func responses(name: String, instruction: String, prompts: [KeyboardInteractivePrompt]) -> [String] { var results = [String?](repeating: nil, count: prompts.count) var pendingIndices: [Int] = [] @@ -117,10 +98,7 @@ internal final class KeyboardInteractiveContext { } } -/// C-compatible callback for libssh2 keyboard-interactive authentication. -/// -/// libssh2 invokes this synchronously for each challenge. Responses are allocated with `strdup` -/// because libssh2 `free`s them, and every slot is filled even on cancel. +// strdup because libssh2 frees each response. private let kbdintCallback: @convention(c) ( UnsafePointer?, Int32, UnsafePointer?, Int32, @@ -225,20 +203,7 @@ internal struct KeyboardInteractiveAuthenticator: SSHAuthenticator { Self.logger.info("Keyboard-interactive authentication succeeded") } - static func classify(_ promptText: String) -> KBDINTPromptType { - let lower = promptText.lowercased() - - if lower.contains("verification") || lower.contains("code") || - lower.contains("otp") || lower.contains("token") || - lower.contains("totp") || lower.contains("2fa") || - lower.contains("one-time") || lower.contains("factor") { - return .totp - } - - if lower.contains("password") { - return .password - } - - return .unmatched + static func classify(_ promptText: String) -> KeyboardInteractivePromptKind { + KeyboardInteractivePromptKind(promptText: promptText) } } diff --git a/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift b/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift index 3282fa973e..5d6111c364 100644 --- a/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift +++ b/apple/TablePro/Core/SSH/LibSSH2ChannelIO.swift @@ -12,28 +12,49 @@ import Foundation import CLibSSH2 import TableProSSHTransport -/// Routes channel reads/writes through the serial `sessionQueue` because libssh2 -/// is not thread-safe per session. Maps libssh2 return codes to the transport -/// agnostic results consumed by `SSHChannelRelay`. +// Every call goes through the serial sessionQueue: libssh2 is not thread-safe per session. nonisolated internal struct LibSSH2ChannelIO: SSHChannelIO { let channel: OpaquePointer let session: OpaquePointer let sessionQueue: DispatchQueue + // libssh2 hands a transport error (-43 on EOF, -13 on DISCONNECT, -12, a failed re-key) to every channel call; only these end just one channel. + private static let channelScopedErrors: Set = [ + Int(LIBSSH2_ERROR_CHANNEL_OUTOFORDER), + Int(LIBSSH2_ERROR_CHANNEL_FAILURE), + Int(LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED), + Int(LIBSSH2_ERROR_CHANNEL_UNKNOWN), + Int(LIBSSH2_ERROR_CHANNEL_WINDOW_EXCEEDED), + Int(LIBSSH2_ERROR_CHANNEL_PACKET_EXCEEDED), + Int(LIBSSH2_ERROR_CHANNEL_CLOSED), + Int(LIBSSH2_ERROR_CHANNEL_EOF_SENT), + Int(LIBSSH2_ERROR_CHANNEL_WINDOW_FULL), + ] + + static func readResult(_ returnCode: Int) -> ChannelReadResult { + if returnCode > 0 { return .bytes(returnCode) } + if returnCode == 0 { return .closed } + if returnCode == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } + return channelScopedErrors.contains(returnCode) ? .closed : .transportFailed + } + + static func writeResult(_ returnCode: Int) -> ChannelWriteResult { + if returnCode > 0 { return .bytes(returnCode) } + if returnCode == 0 { return .closed } + if returnCode == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } + return channelScopedErrors.contains(returnCode) ? .closed : .transportFailed + } + func read(into buffer: UnsafeMutablePointer, count: Int) -> ChannelReadResult { let result = sessionQueue.sync { Int(tablepro_libssh2_channel_read(channel, buffer, count)) } - if result > 0 { return .bytes(result) } - if result == 0 { return .closed } - if sessionQueue.sync(execute: { libssh2_channel_eof(channel) }) != 0 { return .closed } - if result == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } - return .closed + if result == Int(LIBSSH2_ERROR_EAGAIN), sessionQueue.sync(execute: { libssh2_channel_eof(channel) }) != 0 { + return .closed + } + return Self.readResult(result) } func write(_ buffer: UnsafePointer, count: Int) -> ChannelWriteResult { - let result = sessionQueue.sync { Int(tablepro_libssh2_channel_write(channel, buffer, count)) } - if result > 0 { return .bytes(result) } - if result == Int(LIBSSH2_ERROR_EAGAIN) { return .wouldBlock } - return .closed + Self.writeResult(sessionQueue.sync { Int(tablepro_libssh2_channel_write(channel, buffer, count)) }) } func blockDirections() -> RelayDirections { diff --git a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift index f1ec448ded..925f3bd363 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -114,6 +114,10 @@ nonisolated enum ErrorClassifier { return connectionFailure(connectionError) } + if let tunnelError = error as? SSHTunnelError, let classified = sshTunnelFailure(tunnelError) { + return classified + } + if let redisError = error as? RedisError, let classified = redisFailure(redisError, context: context) { return classified } diff --git a/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift b/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift new file mode 100644 index 0000000000..6c45644b49 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Helpers/ErrorClassifier+SSHTunnel.swift @@ -0,0 +1,65 @@ +import Foundation + +// nil sends the error on to the text rules, which still give timeouts the Local Network recovery. +nonisolated extension ErrorClassifier { + static func sshTunnelFailure(_ error: SSHTunnelError) -> AppError? { + switch error { + case .negotiationFailed: + return AppError( + category: .ssh, + title: String(localized: "SSH Tunnel Failed"), + message: error.localizedDescription, + recovery: String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports."), + underlying: error + ) + case .authMethodUnsupported: + return signInUnavailable( + error, + recovery: String(localized: "Open this connection in TablePro on the Mac, or edit it and choose Password or Private Key.") + ) + case .securityKeyUnsupported: + return signInUnavailable( + error, + recovery: String(localized: "Open this connection in TablePro on the Mac, or edit it and choose a private key that is not on a security key.") + ) + case .additionalFactorRequired, .promptUnanswerable: + return signInUnavailable( + error, + recovery: String(localized: "Open this connection in TablePro on the Mac, which can answer this sign-in step.") + ) + case .passphraseRequired: + return passphraseFailure( + error, + recovery: String(localized: "Edit the connection and enter the key's passphrase under Private Key.") + ) + case .passphraseIncorrect: + return passphraseFailure( + error, + recovery: String(localized: "Edit the connection and check the key's passphrase under Private Key.") + ) + case .connectionFailed, .handshakeFailed, .authenticationFailed, .noAvailablePort, .channelOpenFailed, + .hostKeyRejected, .hostKeyUnverified, .jumpHostsUnsupported, .tunnelClosed: + return nil + } + } + + private static func signInUnavailable(_ error: SSHTunnelError, recovery: String) -> AppError { + AppError( + category: .config, + title: String(localized: "SSH Sign-In Unavailable"), + message: error.localizedDescription, + recovery: recovery, + underlying: error + ) + } + + private static func passphraseFailure(_ error: SSHTunnelError, recovery: String) -> AppError { + AppError( + category: .auth, + title: String(localized: "SSH Authentication Failed"), + message: error.localizedDescription, + recovery: recovery, + underlying: error + ) + } +} diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..1d726592c5 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -2324,6 +2324,9 @@ "Asia and Australia" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Ask the SSH server's administrator to turn on a method TablePro supports." : { + }, "Atom" : { "extractionState" : "manual", @@ -6388,6 +6391,9 @@ } } } + }, + "Edit the connection and check the key's passphrase under Private Key." : { + }, "Edit the connection and choose the database file again." : { "localizations" : { @@ -6416,6 +6422,9 @@ } } } + }, + "Edit the connection and enter the key's passphrase under Private Key." : { + }, "Empty String" : { "localizations" : { @@ -9428,6 +9437,9 @@ } } } + }, + "It supports these host key types: %@." : { + }, "Its subgroups are deleted too. Their connections move to Ungrouped." : { "localizations" : { @@ -9611,6 +9623,9 @@ }, "Key Not Found" : { + }, + "Keyboard Interactive" : { + }, "Keychain Warning" : { "localizations" : { @@ -12251,6 +12266,15 @@ }, "Open it on the Mac, or change it there to connect by host and port." : { + }, + "Open this connection in TablePro on the Mac, or edit it and choose Password or Private Key." : { + + }, + "Open this connection in TablePro on the Mac, or edit it and choose a private key that is not on a security key." : { + + }, + "Open this connection in TablePro on the Mac, which can answer this sign-in step." : { + }, "Open Sample Database" : { "localizations" : { @@ -16247,6 +16271,12 @@ } } } + }, + "SSH Agent" : { + + }, + "SSH Authentication Failed" : { + }, "Square" : { "extractionState" : "manual", @@ -16421,6 +16451,9 @@ } } } + }, + "SSH Sign-In Unavailable" : { + }, "SSH Tunnel" : { "localizations" : { @@ -17652,6 +17685,18 @@ } } } + }, + "TablePro on iPhone and iPad cannot use the SSH authentication method “%@”." : { + + }, + "TablePro supports these MAC algorithms: %@." : { + + }, + "TablePro supports these ciphers: %@." : { + + }, + "TablePro supports these key exchange methods: %@." : { + }, "TablePro Is Locked" : { "localizations" : { @@ -17680,6 +17725,18 @@ } } } + }, + "TablePro and the SSH server could not agree on how to encrypt the connection." : { + + }, + "TablePro and the SSH server have no MAC algorithm in common." : { + + }, + "TablePro and the SSH server have no cipher in common." : { + + }, + "TablePro and the SSH server have no key exchange or host key method in common." : { + }, "TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted." : { "extractionState" : "stale", @@ -18909,6 +18966,9 @@ } } } + }, + "The passphrase for this SSH private key is incorrect." : { + }, "The Oracle connection closed while the statement was running. Run it again." : { "extractionState" : "manual", @@ -19049,6 +19109,21 @@ } } } + }, + "The SSH server accepted the first sign-in step and asks for another (%@), which TablePro on iPhone and iPad cannot complete." : { + + }, + "The SSH server asked “%@”, which TablePro on iPhone and iPad cannot answer." : { + + }, + "The SSH server did not answer in time." : { + + }, + "The SSH server does not accept a password. It accepts %@." : { + + }, + "The SSH server does not accept a private key. It accepts %@." : { + }, "The password is longer than the 128 bytes FreeTDS takes." : { @@ -19250,6 +19325,9 @@ } } } + }, + "The server runs %@." : { + }, "The server sent an unexpected message and the connection was reset. Run the query again." : { "extractionState" : "manual", @@ -19408,6 +19486,9 @@ } } } + }, + "The SSH server offers no sign-in method." : { + }, "The SSH tunnel connected but could not forward to the database port." : { "localizations" : { @@ -20195,6 +20276,12 @@ "This Oracle server requires Native Network Encryption but did not offer it on this connection. Ask the DBA to enable it, or use TLS instead by setting an SSL mode." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "This SSH private key belongs to a security key (FIDO), which TablePro on iPhone and iPad cannot sign with." : { + + }, + "This SSH private key is encrypted, and no passphrase is saved for it." : { + }, "This query will modify data. Are you sure you want to continue?" : { "localizations" : { diff --git a/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift b/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift new file mode 100644 index 0000000000..cde06da2aa --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/LibSSH2Authentication.swift @@ -0,0 +1,300 @@ +import CLibSSH2 +import Foundation +import os +import TableProSSHTransport + +nonisolated protocol SSHUserAuthSession { + func methodList() -> String? + func isAuthenticated() -> Bool + func lastError() -> String? + func password(_ password: String) -> Int32 + func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 + func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 + func publicKeyFromMemory(_ privateKey: String, passphrase: String?) -> Int32 +} + +nonisolated final class KeyboardInteractiveAnswers { + private let password: String + private(set) var unansweredPrompt: String? + + init(password: String) { + self.password = password + } + + // A code or an unknown question gets an empty answer, never the password. + func answer(_ prompt: String) -> String { + if KeyboardInteractivePromptKind(promptText: prompt) == .password { + return password + } + let question = prompt.trimmingCharacters(in: .whitespacesAndNewlines) + if unansweredPrompt == nil, !question.isEmpty { + unansweredPrompt = question + } + return "" + } +} + +nonisolated struct LibSSH2Authentication { + nonisolated enum Key: Equatable, Sendable { + case file(path: String, publicKeyPath: String?, contents: String?, publicKeyIsCertificate: Bool = false) + case memory(String) + + static func readFile(at path: String) -> Key { + let publicKeyPath = path + ".pub" + let publicKey = try? String(contentsOfFile: publicKeyPath, encoding: .utf8) + return .file( + path: path, + publicKeyPath: FileManager.default.fileExists(atPath: publicKeyPath) ? publicKeyPath : nil, + contents: try? String(contentsOfFile: path, encoding: .utf8), + publicKeyIsCertificate: publicKey.map(isCertificate) ?? false + ) + } + + static func isCertificate(_ publicKeyLine: String) -> Bool { + let keyType = publicKeyLine.split(whereSeparator: \.isWhitespace).first ?? "" + return keyType.hasSuffix("-cert-v01@openssh.com") + } + + var contents: String? { + switch self { + case .file(_, _, let contents, _): return contents + case .memory(let contents): return contents + } + } + } + + nonisolated private enum Offer { + case authenticated + case methods([String]) + case unavailable + } + + nonisolated private struct Failure { + let detail: String + let methodsAfter: [String] + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "SSHAuthentication") + private static let passwordMethod = "password" + private static let keyboardInteractiveMethod = "keyboard-interactive" + private static let publicKeyMethod = "publickey" + private static let localKeyFailures: Set = [LIBSSH2_ERROR_FILE, LIBSSH2_ERROR_KEYFILE_AUTH_FAILED] + + let session: any SSHUserAuthSession + + init(session: any SSHUserAuthSession) { + self.session = session + } + + init(session: OpaquePointer, username: String) { + self.init(session: LibSSH2UserAuthSession(session: session, username: username)) + } + + func password(_ password: String) throws { + guard let offered = try methodsOnOffer() else { return } + var available = offered + var passwordFailure: String? + + if offered.contains(Self.passwordMethod) { + let rc = session.password(password) + if rc == 0 { return } + guard let failure = try readFailure(of: Self.passwordMethod, rc: rc, offered: offered) else { return } + passwordFailure = failure.detail + available = failure.methodsAfter + } + + // A PAM server can take the password over keyboard-interactive instead. + guard available.contains(Self.keyboardInteractiveMethod) else { + throw SSHTunnelError.authenticationFailed(passwordFailure ?? Self.notOffered( + String(localized: "The SSH server does not accept a password. It accepts %@."), + offered: offered + )) + } + + let answers = KeyboardInteractiveAnswers(password: password) + let rc = session.keyboardInteractive(answers) + if rc == 0 { return } + guard let failure = try readFailure(of: Self.keyboardInteractiveMethod, rc: rc, offered: available) else { return } + if let prompt = answers.unansweredPrompt { + throw SSHTunnelError.promptUnanswerable(prompt) + } + throw SSHTunnelError.authenticationFailed(passwordFailure ?? failure.detail) + } + + func publicKey(_ key: Key, passphrase: String?) throws { + let passphrase = passphrase.flatMap { $0.isEmpty ? nil : $0 } + let traits = Self.traits(of: key.contents) + if traits.isSecurityKey { throw SSHTunnelError.securityKeyUnsupported } + if traits.isEncrypted, passphrase == nil { throw SSHTunnelError.passphraseRequired } + + guard let offered = try methodsOnOffer() else { return } + guard offered.contains(Self.publicKeyMethod) else { + throw SSHTunnelError.authenticationFailed(Self.notOffered( + String(localized: "The SSH server does not accept a private key. It accepts %@."), + offered: offered + )) + } + + let rc: Int32 + switch key { + case .file(let path, let publicKeyPath, _, let publicKeyIsCertificate): + // Without the .pub, libssh2 decrypts the key before it sends anything, so a wrong + // passphrase fails locally and -19 is only ever the server's refusal. A certificate + // still goes: the server may trust its CA and not the bare key. + rc = session.publicKeyFromFile( + privateKeyPath: path, + publicKeyPath: traits.isEncrypted && !publicKeyIsCertificate ? nil : publicKeyPath, + passphrase: passphrase + ) + case .memory(let contents): + rc = session.publicKeyFromMemory(contents, passphrase: passphrase) + } + if rc == 0 { return } + + // These come from reading the key before anything reaches the server, so the list is + // unchanged and reading it again would only cost a try, since OpenSSH counts a second "none". + if Self.localKeyFailures.contains(rc) { + if traits.isEncrypted { throw SSHTunnelError.passphraseIncorrect } + throw SSHTunnelError.authenticationFailed(session.lastError() ?? "publickey authentication failed (error \(rc))") + } + guard let failure = try readFailure(of: Self.publicKeyMethod, rc: rc, offered: offered) else { return } + throw SSHTunnelError.authenticationFailed(failure.detail) + } + + // nil when the server accepted "none" and the session is already signed in. + private func methodsOnOffer() throws -> [String]? { + switch offer() { + case .authenticated: + return nil + case .methods(let methods): + Self.logger.debug("SSH server offers \(methods.joined(separator: ","), privacy: .public)") + return methods + case .unavailable: + throw SSHTunnelError.authenticationFailed(session.lastError() ?? "No authentication methods received") + } + } + + private func offer() -> Offer { + if let list = session.methodList() { + return .methods(SSHAuthMethods.parse(list)) + } + return session.isAuthenticated() ? .authenticated : .unavailable + } + + // The last error goes first: re-reading the list overwrites it. libssh2 never reads the + // partial-success flag, so an accepted first step shows only as its method leaving the list. + // nil when the session turned out to be signed in. + private func readFailure(of method: String, rc: Int32, offered: [String]) throws -> Failure? { + let detail = session.lastError() ?? "\(method) authentication failed (error \(rc))" + Self.logger.debug("SSH \(method, privacy: .public) authentication failed (\(rc)): \(detail, privacy: .public)") + + let after: [String] + switch offer() { + case .authenticated: return nil + case .methods(let methods): after = methods + case .unavailable: after = [] + } + + if let remaining = SSHAuthMethods.remainingAfterPartialSuccess(of: method, before: offered, after: after) { + throw SSHTunnelError.additionalFactorRequired(remaining) + } + return Failure(detail: detail, methodsAfter: after) + } + + private static func traits(of contents: String?) -> (isEncrypted: Bool, isSecurityKey: Bool) { + guard let contents else { return (false, false) } + if let traits = SSHPrivateKeyTraits(openSSH: contents) { + return (traits.isEncrypted, traits.isSecurityKey) + } + return (PEMDocument.inspect(contents).usesPassphrase, false) + } + + private static func notOffered(_ format: String, offered: [String]) -> String { + guard !offered.isEmpty else { + return String(localized: "The SSH server offers no sign-in method.") + } + return String(format: format, offered.formatted(.list(type: .or))) + } +} + +nonisolated struct LibSSH2UserAuthSession: SSHUserAuthSession { + let session: OpaquePointer + let username: String + + func methodList() -> String? { + guard let list = libssh2_userauth_list(session, username, UInt32(username.utf8.count)) else { return nil } + return String(cString: list) + } + + func isAuthenticated() -> Bool { + libssh2_userauth_authenticated(session) != 0 + } + + func lastError() -> String? { + var message: UnsafeMutablePointer? + var length: Int32 = 0 + libssh2_session_last_error(session, &message, &length, 0) + guard let message, length > 0 else { return nil } + return String(cString: message) + } + + func password(_ password: String) -> Int32 { + libssh2_userauth_password_ex( + session, + username, + UInt32(username.utf8.count), + password, + UInt32(password.utf8.count), + nil + ) + } + + func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 { + let abstract: UnsafeMutablePointer = libssh2_session_abstract(session) + let previous = abstract.pointee + abstract.pointee = Unmanaged.passUnretained(answers).toOpaque() + defer { abstract.pointee = previous } + + return withExtendedLifetime(answers) { + libssh2_userauth_keyboard_interactive_ex(session, username, UInt32(username.utf8.count)) { _, _, _, _, count, prompts, responses, slot in + guard count > 0, let prompts, let responses, let context = slot?.pointee else { return } + let answers = Unmanaged.fromOpaque(context).takeUnretainedValue() + for index in 0 ..< Int(count) { + // libssh2 frees each response, so it has to come from the C heap. + let response = strdup(answers.answer(LibSSH2UserAuthSession.text(of: prompts[index]))) + responses[index].text = response + responses[index].length = response.map { UInt32(strlen($0)) } ?? 0 + } + } + } + } + + func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 { + libssh2_userauth_publickey_fromfile_ex( + session, + username, + UInt32(username.utf8.count), + publicKeyPath, + privateKeyPath, + passphrase + ) + } + + func publicKeyFromMemory(_ privateKey: String, passphrase: String?) -> Int32 { + privateKey.withCString { keyPointer in + libssh2_userauth_publickey_frommemory( + session, + username, + username.utf8.count, + nil, 0, + keyPointer, privateKey.utf8.count, + passphrase + ) + } + } + + private static func text(of prompt: LIBSSH2_USERAUTH_KBDINT_PROMPT) -> String { + guard let text = prompt.text, prompt.length > 0 else { return "" } + return String(bytes: UnsafeBufferPointer(start: text, count: prompt.length), encoding: .utf8) ?? "" + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift b/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift new file mode 100644 index 0000000000..723789426f --- /dev/null +++ b/apple/TableProMobile/TableProMobile/SSH/LibSSH2HandshakeFailure.swift @@ -0,0 +1,85 @@ +import CLibSSH2 +import Foundation +import TableProSSHTransport + +// Runs on the failed session before libssh2_session_free, which drops what the negotiation agreed. +nonisolated enum LibSSH2HandshakeFailure { + static func error(session: OpaquePointer, rc: Int32) -> SSHTunnelError { + // libssh2_session_methods overwrites the last error when a method is missing, so it is read first. + let detail = lastError(session) + + switch rc { + case LIBSSH2_ERROR_KEX_FAILURE: + return .negotiationFailed(negotiation(session)) + case LIBSSH2_ERROR_TIMEOUT: + // libssh2 says "Timed out waiting on socket", which reads as blocked Local Network access, + // but the TCP connect already succeeded. + return .handshakeFailed(String(localized: "The SSH server did not answer in time.")) + default: + return .handshakeFailed(detail ?? "Handshake failed (error \(rc))") + } + } + + private static func negotiation(_ session: OpaquePointer) -> SSHNegotiationFailure { + let stage = SSHNegotiationFailure.stage( + kex: method(session, LIBSSH2_METHOD_KEX), + cipher: agreed(session, LIBSSH2_METHOD_CRYPT_CS, LIBSSH2_METHOD_CRYPT_SC), + mac: agreed(session, LIBSSH2_METHOD_MAC_CS, LIBSSH2_METHOD_MAC_SC) + ) + + var supported: [String] = [] + var supportedHostKeys: [String] = [] + switch stage { + case .keyExchangeOrHostKey: + supported = SSHNegotiationFailure.clientMethods(algorithms(session, LIBSSH2_METHOD_KEX)) + supportedHostKeys = algorithms(session, LIBSSH2_METHOD_HOSTKEY) + case .cipher: + supported = algorithms(session, LIBSSH2_METHOD_CRYPT_CS) + case .mac: + supported = algorithms(session, LIBSSH2_METHOD_MAC_CS) + case .undetermined: + break + } + + return SSHNegotiationFailure( + stage: stage, + serverSoftware: SSHNegotiationFailure.serverSoftware(banner: banner(session)), + supported: supported, + supportedHostKeys: supportedHostKeys + ) + } + + // libssh2 agrees each direction on its own, so one side alone is not an agreement. + private static func agreed(_ session: OpaquePointer, _ clientToServer: Int32, _ serverToClient: Int32) -> String? { + guard let outgoing = method(session, clientToServer), method(session, serverToClient) != nil else { + return nil + } + return outgoing + } + + private static func method(_ session: OpaquePointer, _ type: Int32) -> String? { + guard let name = libssh2_session_methods(session, type) else { return nil } + return String(cString: name) + } + + private static func algorithms(_ session: OpaquePointer, _ type: Int32) -> [String] { + var names: UnsafeMutablePointer?>? + let count = libssh2_session_supported_algs(session, type, &names) + guard count > 0, let names else { return [] } + defer { libssh2_free(session, names) } + return (0 ..< Int(count)).compactMap { index in names[index].map { String(cString: $0) } } + } + + private static func banner(_ session: OpaquePointer) -> String? { + guard let banner = libssh2_session_banner_get(session) else { return nil } + return String(cString: banner) + } + + private static func lastError(_ session: OpaquePointer) -> String? { + var message: UnsafeMutablePointer? + var length: Int32 = 0 + libssh2_session_last_error(session, &message, &length, 0) + guard let message, length > 0 else { return nil } + return String(cString: message) + } +} diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift index afc225b59a..3d9629e821 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnel.swift @@ -53,7 +53,11 @@ nonisolated final class SSHTunnel: @unchecked Sendable { /// Held strictly below every iOS driver's connect timeout so the reason reaches the log /// before the driver reports its own timeout, which names no cause. static let channelOpenDeadlineSeconds: TimeInterval = 6 - private static let channelOpenPollTimeoutMs: Int32 = 5_000 + private static let channelOpenPollTimeoutMs: Int32 = 100 + + private static let channelFreePollTimeoutMs: Int32 = 100 + // The keep-alive's own limit: a CLOSE still unsent by then holds every other send on the session. + private static let channelFreeDeadlineSeconds: TimeInterval = 30 /// How long the accept loop waits per poll before rechecking the latch. Small enough that /// noticing a client the kernel already accepted costs a slim part of the margin above. @@ -153,15 +157,9 @@ nonisolated final class SSHTunnel: @unchecked Sendable { performTeardown() } - /// Breaks every blocking wait first, then frees the session and the descriptors only once - /// every task that could still be polling them has exited. `shutdown` unblocks a poll at - /// once without releasing the descriptor number, which another thread would otherwise be - /// free to receive from the kernel and poll by mistake. `shutdown` does not wake a poll on a - /// listening socket on Darwin, so the accept loop ends on the latch instead, inside one - /// `acceptPollTimeoutMs`, and its descriptor closes once it has. - /// - /// The relays are waited on after the accept loop, not alongside it, because the accept loop - /// is the only thing that starts one: once it has ended, the group can only empty. + // shutdown wakes every poll on the transport but keeps the number, so no other socket can take it mid-poll. + // Darwin's shutdown does not wake a poll on a listening socket, so the accept loop ends on the latch instead. + // The relays are awaited after the accept loop, the only thing that starts one, so the group can only empty. private func performTeardown() { forwardingTask?.cancel() keepAliveTask?.cancel() @@ -184,12 +182,13 @@ nonisolated final class SSHTunnel: @unchecked Sendable { clientRelays.notify(queue: relayQueue) { continuation.resume() } } + // Free before close: a closed number can go to the next tunnel's socket, and this session's DISCONNECT would land in it. sessionQueue.sync { - Darwin.close(listenFD) - Darwin.close(socketFD) libssh2_session_set_blocking(session, 1) tablepro_libssh2_session_disconnect(session, "Closing tunnel") libssh2_session_free(session) + Darwin.close(socketFD) + Darwin.close(listenFD) } Self.logger.info("Tunnel closed (local port \(localPort))") @@ -248,11 +247,14 @@ nonisolated final class SSHTunnel: @unchecked Sendable { deadline: acceptedAt.addingTimeInterval(Self.channelOpenDeadlineSeconds), pollForReadiness: { [weak self] directions in guard let self else { return false } - return pollReady( + // Another open or relay can read this channel's reply into libssh2's queue and leave the socket quiet, + // so a quiet wait is a retry, and only the deadline ends the open. + _ = pollReady( fd: self.socketFD, directions: directions, timeoutMs: Self.channelOpenPollTimeoutMs ) + return true } ) @@ -302,14 +304,38 @@ nonisolated final class SSHTunnel: @unchecked Sendable { Darwin.close(clientFD) guard isRunning else { return } - sessionQueue.sync { - libssh2_channel_close(channel) - libssh2_channel_free(channel) - } + freeChannel(channel) if termination == .transportHangup { Self.logger.info("SSH transport hung up, marking tunnel dead on port \(self.localPort)") markDead() } } + + // Another relay can read the CLOSE reply off the shared socket and leave it quiet, so free runs again after every wait. + private func freeChannel(_ channel: OpaquePointer) { + let session = self.session + let sessionQueue = self.sessionQueue + let socketFD = self.socketFD + let outcome = SSHChannelFreePump( + attemptFree: { + sessionQueue.sync { + guard libssh2_channel_free(channel) == LIBSSH2_ERROR_EAGAIN else { return .finished } + return .wouldBlock(RelayDirections(libssh2BlockDirections: libssh2_session_block_directions(session))) + } + }, + isActive: { [weak self] in self?.isRunning ?? false }, + deadline: Date().addingTimeInterval(Self.channelFreeDeadlineSeconds), + waitForTransport: { directions in + _ = pollReady(fd: socketFD, directions: directions, timeoutMs: Self.channelFreePollTimeoutMs) + } + ).run() + + if outcome == .timedOut { + Self.logger.warning( + "Channel on port \(self.localPort) did not close within \(Int(Self.channelFreeDeadlineSeconds))s, marking tunnel dead" + ) + markDead() + } + } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift index 5c935c1caa..1f2b6e6f48 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelError.swift @@ -1,4 +1,6 @@ import Foundation +import TableProModels +import TableProSSHTransport nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case connectionFailed(String) @@ -10,6 +12,13 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { case hostKeyUnverified(String) case jumpHostsUnsupported case tunnelClosed + case negotiationFailed(SSHNegotiationFailure) + case authMethodUnsupported(SSHConfiguration.SSHAuthMethod) + case securityKeyUnsupported + case passphraseRequired + case passphraseIncorrect + case additionalFactorRequired([String]) + case promptUnanswerable(String) var errorDescription: String? { switch self { @@ -26,6 +35,85 @@ nonisolated enum SSHTunnelError: Error, LocalizedError, Equatable, Sendable { cannot dial. Open it in TablePro on the Mac. """) case .tunnelClosed: return "SSH tunnel is closed" + case .negotiationFailed(let failure): + return Self.describe(failure) + case .authMethodUnsupported(let method): + return String( + format: String(localized: "TablePro on iPhone and iPad cannot use the SSH authentication method “%@”."), + Self.name(of: method) + ) + case .securityKeyUnsupported: + return String( + localized: "This SSH private key belongs to a security key (FIDO), which TablePro on iPhone and iPad cannot sign with." + ) + case .passphraseRequired: + return String(localized: "This SSH private key is encrypted, and no passphrase is saved for it.") + case .passphraseIncorrect: + return String(localized: "The passphrase for this SSH private key is incorrect.") + case .additionalFactorRequired(let methods): + return String( + format: String( + localized: "The SSH server accepted the first sign-in step and asks for another (%@), which TablePro on iPhone and iPad cannot complete." + ), + methods.formatted(.list(type: .or)) + ) + case .promptUnanswerable(let prompt): + return String( + format: String(localized: "The SSH server asked “%@”, which TablePro on iPhone and iPad cannot answer."), + prompt + ) } } + + private static func name(of method: SSHConfiguration.SSHAuthMethod) -> String { + switch method { + case .password: return String(localized: "Password") + case .privateKey: return String(localized: "Private Key") + case .sshAgent: return String(localized: "SSH Agent") + case .keyboardInteractive: return String(localized: "Keyboard Interactive") + case .none: return String(localized: "None") + } + } + + private static func describe(_ failure: SSHNegotiationFailure) -> String { + var sentences: [String] = [] + + switch failure.stage { + case .keyExchangeOrHostKey: + sentences.append(String(localized: "TablePro and the SSH server have no key exchange or host key method in common.")) + case .cipher: + sentences.append(String(localized: "TablePro and the SSH server have no cipher in common.")) + case .mac: + sentences.append(String(localized: "TablePro and the SSH server have no MAC algorithm in common.")) + case .undetermined: + sentences.append(String(localized: "TablePro and the SSH server could not agree on how to encrypt the connection.")) + } + + if let software = failure.serverSoftware { + sentences.append(String(format: String(localized: "The server runs %@."), software)) + } + + if !failure.supported.isEmpty { + let list = failure.supported.formatted(.list(type: .and)) + switch failure.stage { + case .keyExchangeOrHostKey: + sentences.append(String(format: String(localized: "TablePro supports these key exchange methods: %@."), list)) + case .cipher: + sentences.append(String(format: String(localized: "TablePro supports these ciphers: %@."), list)) + case .mac: + sentences.append(String(format: String(localized: "TablePro supports these MAC algorithms: %@."), list)) + case .undetermined: + break + } + } + + if !failure.supportedHostKeys.isEmpty { + sentences.append(String( + format: String(localized: "It supports these host key types: %@."), + failure.supportedHostKeys.formatted(.list(type: .and)) + )) + } + + return sentences.joined(separator: " ") + } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift index 7454a1ae7f..7d44a6f794 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelFactory.swift @@ -95,9 +95,7 @@ nonisolated enum SSHTunnelFactory { try await stages.authenticateNone(username: config.username) default: - throw SSHTunnelError.authenticationFailed( - "Auth method \(config.authMethod.rawValue) not supported on iOS" - ) + throw SSHTunnelError.authMethodUnsupported(config.authMethod) } } } diff --git a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift index e64bb78190..5baf1ace39 100644 --- a/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift +++ b/apple/TableProMobile/TableProMobile/SSH/SSHTunnelStages.swift @@ -121,55 +121,24 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl } func authenticatePassword(username: String, password: String) async throws { - let handle = try currentSession() - try await authenticate(as: username, label: "Password") { - libssh2_userauth_password_ex( - handle.pointer, - username, - UInt32(username.utf8.count), - password, - UInt32(password.utf8.count), - nil - ) - } + try await authenticate(as: username, label: "Password") { try $0.password(password) } } func authenticatePublicKey(username: String, keyPath: String, passphrase: String?) async throws { - let handle = try currentSession() let expandedPath = (keyPath as NSString).expandingTildeInPath guard FileManager.default.fileExists(atPath: expandedPath) else { throw SSHTunnelError.authenticationFailed("Private key not found at \(keyPath)") } - let publicKeyPath = expandedPath + ".pub" - let publicKeyPathOrNil: String? = FileManager.default.fileExists(atPath: publicKeyPath) ? publicKeyPath : nil - try await authenticate(as: username, label: "Public key") { - libssh2_userauth_publickey_fromfile_ex( - handle.pointer, - username, - UInt32(username.utf8.count), - publicKeyPathOrNil, - expandedPath, - passphrase - ) + try $0.publicKey(.readFile(at: expandedPath), passphrase: passphrase) } } func authenticatePublicKeyFromMemory(username: String, keyContent: String, passphrase: String?) async throws { - let handle = try currentSession() try await authenticate(as: username, label: "In-memory key") { - keyContent.withCString { keyPointer in - libssh2_userauth_publickey_frommemory( - handle.pointer, - username, - username.utf8.count, - nil, 0, - keyPointer, keyContent.utf8.count, - passphrase - ) - } + try $0.publicKey(.memory(keyContent), passphrase: passphrase) } } @@ -257,16 +226,12 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl private func authenticate( as username: String, label: String, - _ attempt: @escaping @Sendable () -> Int32 + _ attempt: @escaping @Sendable (LibSSH2Authentication) throws -> Void ) async throws { + let handle = try currentSession() try await runCancellableBlocking( on: sessionQueue, - work: { - let rc = attempt() - guard rc == 0 else { - throw SSHTunnelError.authenticationFailed("\(label) authentication failed (error \(rc))") - } - } + work: { try attempt(LibSSH2Authentication(session: handle.pointer, username: username)) } ) Self.logger.debug("\(label, privacy: .public) authentication successful for \(username, privacy: .private)") } @@ -281,8 +246,9 @@ nonisolated final class LibSSH2TunnelStages: SSHTunnelStages, @unchecked Sendabl let rc = libssh2_session_handshake(session, socketFD) guard rc == 0 else { + let failure = LibSSH2HandshakeFailure.error(session: session, rc: rc) libssh2_session_free(session) - throw SSHTunnelError.handshakeFailed("Handshake failed (error \(rc))") + throw failure } return session diff --git a/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift new file mode 100644 index 0000000000..565c3a08a9 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2AuthenticationTests.swift @@ -0,0 +1,457 @@ +import Foundation +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct LibSSH2AuthenticationTests { + // libssh2's return codes, named here because the test target does not import CLibSSH2. + static let rcFile: Int32 = -16 + static let rcAuthenticationFailed: Int32 = -18 + static let rcPublicKeyUnverified: Int32 = -19 + static let rcKeyFileAuthFailed: Int32 = -48 + + static let serverText = "Username/PublicKey combination invalid" + static let securityKey = openSSHKey(type: "sk-ssh-ed25519@openssh.com", encrypted: false) + static let encryptedPEM = """ + -----BEGIN RSA PRIVATE KEY----- + Proc-Type: 4,ENCRYPTED + DEK-Info: AES-128-CBC,00000000000000000000000000000000 + + AAAA + -----END RSA PRIVATE KEY----- + """ + + static let securityKeys: [LibSSH2Authentication.Key] = [ + .memory(securityKey), + .file(path: "/keys/id_ed25519_sk", publicKeyPath: "/keys/id_ed25519_sk.pub", contents: securityKey), + ] + + static let encryptedKeys = [openSSHKey(encrypted: true), encryptedPEM] + + static let keyFailures: [KeyFailureRow] = [ + KeyFailureRow(encrypted: true, rc: rcFile, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: true, rc: rcKeyFileAuthFailed, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: true, rc: rcPublicKeyUnverified, expected: .authenticationFailed(serverText)), + KeyFailureRow(encrypted: true, rc: rcAuthenticationFailed, expected: .authenticationFailed(serverText)), + KeyFailureRow(encrypted: false, rc: rcFile, expected: .authenticationFailed(serverText)), + KeyFailureRow(encrypted: false, rc: rcPublicKeyUnverified, expected: .authenticationFailed(serverText)), + ] + + static let localKeyFailures: [KeyFailureRow] = [ + KeyFailureRow(encrypted: true, rc: rcFile, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: true, rc: rcKeyFileAuthFailed, expected: .passphraseIncorrect), + KeyFailureRow(encrypted: false, rc: rcFile, expected: .authenticationFailed(serverText)), + ] + + @Test("A NULL method list on a signed-in session is success", arguments: [true, false]) + func nullListWhileSignedIn(withKey: Bool) throws { + let session = ScriptedAuthSession(lists: [nil], authenticated: true) + let authentication = LibSSH2Authentication(session: session) + + if withKey { + try authentication.publicKey(.memory(Self.openSSHKey(encrypted: false)), passphrase: nil) + } else { + try authentication.password("secret") + } + + #expect(session.calls == [.methodList, .isAuthenticated]) + } + + @Test("A NULL method list on a session that is not signed in fails with libssh2's text") + func nullListWithoutSignIn() { + let session = ScriptedAuthSession(lists: [nil], listError: "Failed getting response") + + #expect(throws: SSHTunnelError.authenticationFailed("Failed getting response")) { + try LibSSH2Authentication(session: session).password("secret") + } + } + + @Test("A password the server accepts signs in") + func passwordAccepted() throws { + let session = ScriptedAuthSession(lists: ["publickey,password"]) + + try LibSSH2Authentication(session: session).password("secret") + + #expect(session.calls == [.methodList, .password]) + } + + @Test("The password's failure text is read before the method list is read again") + func lastErrorBeforeTheListAgain() { + let session = ScriptedAuthSession( + lists: ["password", nil], + listError: "Failed getting response", + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)") + ) + + #expect(throws: SSHTunnelError.authenticationFailed("Authentication failed (username/password)")) { + try LibSSH2Authentication(session: session).password("secret") + } + #expect(session.calls == [.methodList, .password, .lastError, .methodList, .isAuthenticated]) + } + + @Test("A password accepted as the first of two steps names the next step") + func passwordPartialSuccess() { + let session = ScriptedAuthSession( + lists: ["password", "keyboard-interactive"], + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)") + ) + + #expect(throws: SSHTunnelError.additionalFactorRequired(["keyboard-interactive"])) { + try LibSSH2Authentication(session: session).password("secret") + } + #expect(!session.calls.contains(.keyboardInteractive)) + } + + @Test("A refused password is tried again over keyboard-interactive") + func passwordRetriedOverKeyboardInteractive() throws { + let methods = "publickey,password,keyboard-interactive" + let session = ScriptedAuthSession( + lists: [methods, methods], + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)"), + prompts: ["Password: "] + ) + + try LibSSH2Authentication(session: session).password("secret") + + #expect(session.sentAnswers == ["secret"]) + } + + @Test("A server without the password method gets the password over keyboard-interactive") + func passwordOverKeyboardInteractiveOnly() throws { + let session = ScriptedAuthSession(lists: ["publickey,keyboard-interactive"], prompts: ["Password: "]) + + try LibSSH2Authentication(session: session).password("secret") + + #expect(session.calls == [.methodList, .keyboardInteractive]) + #expect(session.sentAnswers == ["secret"]) + } + + @Test("A code prompt never gets the password and is named") + func codePromptIsUnanswerable() { + let session = ScriptedAuthSession( + lists: ["keyboard-interactive", "keyboard-interactive"], + prompts: ["Verification code: "], + keyboardInteractive: .failing(Self.rcAuthenticationFailed, "Authentication failed (keyboard-interactive)") + ) + + #expect(throws: SSHTunnelError.promptUnanswerable("Verification code:")) { + try LibSSH2Authentication(session: session).password("secret") + } + #expect(session.sentAnswers == [""]) + } + + @Test("A refused password keeps its own text when keyboard-interactive asks nothing") + func passwordTextOutlivesAnEmptyKeyboardInteractive() { + let methods = "password,keyboard-interactive" + let session = ScriptedAuthSession( + lists: [methods, methods, methods], + password: .failing(Self.rcAuthenticationFailed, "Authentication failed (username/password)"), + keyboardInteractive: .failing(Self.rcAuthenticationFailed, "Authentication failed (keyboard-interactive)") + ) + + #expect(throws: SSHTunnelError.authenticationFailed("Authentication failed (username/password)")) { + try LibSSH2Authentication(session: session).password("secret") + } + } + + @Test("A server that takes neither a password nor keyboard-interactive names what it takes") + func passwordNotOffered() { + let session = ScriptedAuthSession(lists: ["publickey"]) + + let error = #expect(throws: SSHTunnelError.self) { + try LibSSH2Authentication(session: session).password("secret") + } + + guard case .authenticationFailed(let message)? = error else { + Issue.record("expected authenticationFailed, got \(String(describing: error))") + return + } + #expect(message.contains("publickey")) + #expect(session.calls == [.methodList]) + } + + @Test("A password prompt gets the password", arguments: ["Password: ", "Password for deploy@db.internal: "]) + func passwordPromptGetsThePassword(prompt: String) { + let answers = KeyboardInteractiveAnswers(password: "secret") + + #expect(answers.answer(prompt) == "secret") + #expect(answers.unansweredPrompt == nil) + } + + @Test("A code or unknown prompt gets an empty answer", arguments: ["Verification code: ", "One-time password: ", "Enter PIN: "]) + func otherPromptsGetNothing(prompt: String) { + let answers = KeyboardInteractiveAnswers(password: "secret") + + #expect(answers.answer(prompt).isEmpty) + #expect(answers.unansweredPrompt == prompt.trimmingCharacters(in: .whitespaces)) + } + + @Test("A security key is named before anything reaches the server", arguments: LibSSH2AuthenticationTests.securityKeys) + func securityKeyIsNamed(key: LibSSH2Authentication.Key) { + let session = ScriptedAuthSession(lists: ["publickey"]) + + #expect(throws: SSHTunnelError.securityKeyUnsupported) { + try LibSSH2Authentication(session: session).publicKey(key, passphrase: "hunter2") + } + #expect(session.calls.isEmpty) + } + + @Test("An encrypted key with no passphrase is named before anything reaches the server", arguments: LibSSH2AuthenticationTests.encryptedKeys) + func encryptedKeyNeedsAPassphrase(text: String) { + let session = ScriptedAuthSession(lists: ["publickey"]) + + #expect(throws: SSHTunnelError.passphraseRequired) { + try LibSSH2Authentication(session: session).publicKey(.memory(text), passphrase: "") + } + #expect(session.calls.isEmpty) + } + + @Test("A key failure is named by the key and libssh2's return code", arguments: LibSSH2AuthenticationTests.keyFailures) + func keyFailure(row: KeyFailureRow) { + let session = ScriptedAuthSession(lists: ["publickey", "publickey"], key: .failing(row.rc, Self.serverText)) + + #expect(throws: row.expected) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(encrypted: row.encrypted)), + passphrase: "hunter2" + ) + } + } + + @Test("An encrypted key accepted as the first of two steps names the next step, not the passphrase") + func keyPartialSuccessComesFirst() { + let session = ScriptedAuthSession( + lists: ["publickey", "keyboard-interactive"], + key: .failing(Self.rcPublicKeyUnverified, Self.serverText) + ) + + #expect(throws: SSHTunnelError.additionalFactorRequired(["keyboard-interactive"])) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(encrypted: true)), + passphrase: "hunter2" + ) + } + #expect(session.calls == [.methodList, .keyMemory, .lastError, .methodList]) + } + + @Test("A key that fails to read never asks the server for its methods again", arguments: LibSSH2AuthenticationTests.localKeyFailures) + func localKeyFailureSkipsTheList(row: KeyFailureRow) { + let session = ScriptedAuthSession(lists: ["publickey", "keyboard-interactive"], key: .failing(row.rc, Self.serverText)) + + #expect(throws: row.expected) { + try LibSSH2Authentication(session: session).publicKey( + .memory(Self.openSSHKey(encrypted: row.encrypted)), + passphrase: "hunter2" + ) + } + #expect(!session.calls.dropFirst().contains(.methodList)) + } + + @Test("An encrypted key file goes without its .pub and a plain one keeps it", arguments: [true, false]) + func encryptedKeyFileDropsThePublicKey(encrypted: Bool) throws { + let session = ScriptedAuthSession(lists: ["publickey"]) + let key = LibSSH2Authentication.Key.file( + path: "/keys/id_ed25519", + publicKeyPath: "/keys/id_ed25519.pub", + contents: Self.openSSHKey(encrypted: encrypted) + ) + + try LibSSH2Authentication(session: session).publicKey(key, passphrase: "hunter2") + + #expect(session.calls.last == .keyFile(publicKeyPath: encrypted ? nil : "/keys/id_ed25519.pub")) + } + + @Test("An encrypted key file keeps a .pub that holds its certificate") + func encryptedKeyFileKeepsItsCertificate() throws { + let session = ScriptedAuthSession(lists: ["publickey"]) + let key = LibSSH2Authentication.Key.file( + path: "/keys/id_ed25519", + publicKeyPath: "/keys/id_ed25519.pub", + contents: Self.openSSHKey(encrypted: true), + publicKeyIsCertificate: true + ) + + try LibSSH2Authentication(session: session).publicKey(key, passphrase: "hunter2") + + #expect(session.calls.last == .keyFile(publicKeyPath: "/keys/id_ed25519.pub")) + } + + @Test("A key file reads whether its .pub holds a certificate", arguments: [ + ("ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQ= user\n", true), + ("rsa-sha2-512-cert-v01@openssh.com AAAA", true), + ("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 user\n", false), + ]) + func keyFileReadsItsCertificate(publicKeyLine: String, isCertificate: Bool) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let path = directory.appendingPathComponent("id_ed25519").path + try Self.openSSHKey(encrypted: true).write(toFile: path, atomically: true, encoding: .utf8) + try publicKeyLine.write(toFile: path + ".pub", atomically: true, encoding: .utf8) + + let key = LibSSH2Authentication.Key.readFile(at: path) + + #expect(key == .file( + path: path, + publicKeyPath: path + ".pub", + contents: Self.openSSHKey(encrypted: true), + publicKeyIsCertificate: isCertificate + )) + } + + @Test("A server that takes no key names what it takes") + func keyNotOffered() { + let session = ScriptedAuthSession(lists: ["password,keyboard-interactive"]) + + let error = #expect(throws: SSHTunnelError.self) { + try LibSSH2Authentication(session: session).publicKey(.memory(Self.openSSHKey(encrypted: false)), passphrase: nil) + } + + guard case .authenticationFailed(let message)? = error else { + Issue.record("expected authenticationFailed, got \(String(describing: error))") + return + } + #expect(message.contains("keyboard-interactive")) + #expect(session.calls == [.methodList]) + } + + // An openssh-key-v1 header: cipher, KDF name, KDF options, key count, then the public key blob. + static func openSSHKey(type: String = "ssh-ed25519", encrypted: Bool) -> String { + let publicKey = Data([field(Data(type.utf8)), field(Data(repeating: 7, count: 32))].joined()) + let body = Data([ + Data("openssh-key-v1\0".utf8), + field(Data((encrypted ? "aes256-ctr" : "none").utf8)), + field(Data((encrypted ? "bcrypt" : "none").utf8)), + field(Data()), + uint32(1), + field(publicKey), + field(Data(repeating: 0, count: 64)), + ].joined()) + return """ + -----BEGIN OPENSSH PRIVATE KEY----- + \(body.base64EncodedString(options: .lineLength64Characters)) + -----END OPENSSH PRIVATE KEY----- + """ + } + + private static func field(_ bytes: Data) -> Data { + uint32(UInt32(bytes.count)) + bytes + } + + private static func uint32(_ value: UInt32) -> Data { + withUnsafeBytes(of: value.bigEndian) { Data($0) } + } +} + +nonisolated struct KeyFailureRow: Sendable, CustomTestStringConvertible { + let encrypted: Bool + let rc: Int32 + let expected: SSHTunnelError + + var testDescription: String { + "\(encrypted ? "encrypted" : "plain") key, rc \(rc)" + } +} + +// Stands in for a libssh2 session. libssh2 keeps one last error per session, and a failed +// list read overwrites it, so this one does too. +nonisolated final class ScriptedAuthSession: SSHUserAuthSession { + nonisolated struct Reply: Sendable { + static let accepted = Reply(rc: 0, error: nil) + + let rc: Int32 + let error: String? + + static func failing(_ rc: Int32, _ error: String) -> Reply { + Reply(rc: rc, error: error) + } + } + + nonisolated enum Call: Equatable { + case methodList + case isAuthenticated + case lastError + case password + case keyboardInteractive + case keyFile(publicKeyPath: String?) + case keyMemory + } + + private var lists: [String?] + private let authenticated: Bool + private let listError: String? + private let passwordReply: Reply + private let prompts: [String] + private let keyboardInteractiveReply: Reply + private let keyReply: Reply + private var currentError: String? + + private(set) var calls: [Call] = [] + private(set) var sentAnswers: [String] = [] + + init( + lists: [String?], + authenticated: Bool = false, + listError: String? = nil, + password: Reply = .accepted, + prompts: [String] = [], + keyboardInteractive: Reply = .accepted, + key: Reply = .accepted + ) { + self.lists = lists + self.authenticated = authenticated + self.listError = listError + self.passwordReply = password + self.prompts = prompts + self.keyboardInteractiveReply = keyboardInteractive + self.keyReply = key + } + + func methodList() -> String? { + calls.append(.methodList) + let list = lists.isEmpty ? nil : lists.removeFirst() + if list == nil, let listError { + currentError = listError + } + return list + } + + func isAuthenticated() -> Bool { + calls.append(.isAuthenticated) + return authenticated + } + + func lastError() -> String? { + calls.append(.lastError) + return currentError + } + + func password(_ password: String) -> Int32 { + calls.append(.password) + return reply(passwordReply) + } + + func keyboardInteractive(_ answers: KeyboardInteractiveAnswers) -> Int32 { + calls.append(.keyboardInteractive) + sentAnswers += prompts.map { answers.answer($0) } + return reply(keyboardInteractiveReply) + } + + func publicKeyFromFile(privateKeyPath: String, publicKeyPath: String?, passphrase: String?) -> Int32 { + calls.append(.keyFile(publicKeyPath: publicKeyPath)) + return reply(keyReply) + } + + func publicKeyFromMemory(_ privateKey: String, passphrase: String?) -> Int32 { + calls.append(.keyMemory) + return reply(keyReply) + } + + private func reply(_ reply: Reply) -> Int32 { + if reply.rc != 0 { + currentError = reply.error + } + return reply.rc + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift new file mode 100644 index 0000000000..ea1e1ccfbc --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSH/LibSSH2ChannelIOTests.swift @@ -0,0 +1,56 @@ +// +// LibSSH2ChannelIOTests.swift +// TableProMobileTests +// +// Measured: a stopped sshd makes libssh2_channel_read return -43 at once; read as a channel close, the tunnel lived on until the keep-alive failed, about 37 s later. +// + +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct LibSSH2ChannelIOTests { + // OUTOFORDER, FAILURE, REQUEST_DENIED, UNKNOWN, WINDOW_EXCEEDED, PACKET_EXCEEDED, CLOSED, EOF_SENT, WINDOW_FULL + static let channelScopedCodes = [-20, -21, -22, -23, -24, -25, -26, -27, -47] + + // SOCKET_RECV, SOCKET_SEND, SOCKET_DISCONNECT, DECRYPT, OUT_OF_BOUNDARY, KEX_FAILURE, KEY_EXCHANGE_FAILURE, ALLOC, PROTO, SOCKET_NONE, INVALID_MAC + static let transportCodes = [-43, -7, -13, -12, -41, -5, -8, -6, -14, -1, -4] + + @Test("A channel-scoped read error closes only the channel", arguments: LibSSH2ChannelIOTests.channelScopedCodes) + func channelScopedReadErrorCloses(code: Int) { + #expect(LibSSH2ChannelIO.readResult(code) == .closed) + } + + @Test("A channel-scoped write error closes only the channel", arguments: LibSSH2ChannelIOTests.channelScopedCodes) + func channelScopedWriteErrorCloses(code: Int) { + #expect(LibSSH2ChannelIO.writeResult(code) == .closed) + } + + @Test("A transport read error fails the transport", arguments: LibSSH2ChannelIOTests.transportCodes) + func transportReadErrorFails(code: Int) { + #expect(LibSSH2ChannelIO.readResult(code) == .transportFailed) + } + + @Test("A transport write error fails the transport", arguments: LibSSH2ChannelIOTests.transportCodes) + func transportWriteErrorFails(code: Int) { + #expect(LibSSH2ChannelIO.writeResult(code) == .transportFailed) + } + + @Test("EAGAIN would block on read and write") + func eagainWouldBlock() { + #expect(LibSSH2ChannelIO.readResult(-37) == .wouldBlock) + #expect(LibSSH2ChannelIO.writeResult(-37) == .wouldBlock) + } + + @Test("A positive count is the bytes moved") + func positiveCountIsBytes() { + #expect(LibSSH2ChannelIO.readResult(512) == .bytes(512)) + #expect(LibSSH2ChannelIO.writeResult(1) == .bytes(1)) + } + + @Test("A zero-byte read is a closed channel") + func zeroReadIsClosed() { + #expect(LibSSH2ChannelIO.readResult(0) == .closed) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift index 5beb44eca1..f0725dd7bd 100644 --- a/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift +++ b/apple/TableProMobile/TableProMobileTests/SSH/SSHTunnelFactoryTests.swift @@ -72,7 +72,7 @@ struct SSHTunnelFactoryTests { func unsupportedAuthMethodDiscards() async { let stages = SpyTunnelStages() - await #expect(throws: SSHTunnelError.self) { + await #expect(throws: SSHTunnelError.authMethodUnsupported(.sshAgent)) { try await Self.create(stages: stages, authMethod: .sshAgent) } diff --git a/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift b/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift new file mode 100644 index 0000000000..34b21bc9a4 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/SSHTunnelErrorClassifierTests.swift @@ -0,0 +1,120 @@ +import Foundation +import TableProModels +import TableProSSHTransport +import Testing + +@testable import TableProMobile + +struct SSHTunnelErrorClassifierTests { + private static let context = ErrorContext(operation: "connect", host: "db.example.com", sshEnabled: true) + + static let fallThrough: [SSHTunnelError] = [ + .connectionFailed("Connection timed out"), + .handshakeFailed("Failed getting banner"), + .authenticationFailed("Authentication failed (username/password)"), + .noAvailablePort, + .channelOpenFailed("Channel open failure"), + .hostKeyRejected("The server's host key was not trusted."), + .hostKeyUnverified("The server's host key could not be checked."), + .jumpHostsUnsupported, + .tunnelClosed, + ] + + @Test("A key exchange failure gets the negotiation recovery, not the credentials one") + func keyExchangeFailureIsNotACredentialsProblem() { + let failure = SSHNegotiationFailure( + stage: .keyExchangeOrHostKey, + serverSoftware: "OpenSSH_10.3", + supported: ["curve25519-sha256", "ecdh-sha2-nistp256"], + supportedHostKeys: ["ssh-ed25519", "rsa-sha2-512"] + ) + + let error = ErrorClassifier.classify(SSHTunnelError.negotiationFailed(failure), context: Self.context) + + #expect(error.category == .ssh) + #expect(error.recovery == String(localized: "Ask the SSH server's administrator to turn on a method TablePro supports.")) + #expect(error.recovery != String(localized: "Check your SSH username, password, or private key.")) + #expect(error.message.contains("OpenSSH_10.3")) + #expect(error.message.contains("curve25519-sha256")) + #expect(error.message.contains("rsa-sha2-512")) + } + + @Test("An SSH connect that timed out keeps the Local Network recovery") + func connectTimeoutKeepsTheLocalNetworkRecovery() { + let error = ErrorClassifier.classify(SSHTunnelError.connectionFailed("Connection timed out"), context: Self.context) + + #expect(error.category == .network) + #expect(error.recovery == String( + localized: "Local Network access may be blocked. Open Settings > Privacy & Security > Local Network and turn TablePro on." + )) + } + + @Test("A rejected host key keeps its title") + func rejectedHostKeyKeepsItsTitle() { + let error = ErrorClassifier.classify( + SSHTunnelError.hostKeyRejected(String(localized: "The server's host key was not trusted.")), + context: Self.context + ) + + #expect(error.category == .system) + #expect(error.title == String(localized: "Error")) + } + + @Test("Only the named cases are classified by case", arguments: SSHTunnelErrorClassifierTests.fallThrough) + func olderCasesFallThrough(error: SSHTunnelError) { + #expect(ErrorClassifier.sshTunnelFailure(error) == nil) + } + + @Test("An auth method iOS cannot use is named and points to the Mac") + func unsupportedMethodPointsToTheMac() { + let error = ErrorClassifier.classify( + SSHTunnelError.authMethodUnsupported(.keyboardInteractive), + context: Self.context + ) + + #expect(error.category == .config) + #expect(error.title == String(localized: "SSH Sign-In Unavailable")) + #expect(error.message.contains(String(localized: "Keyboard Interactive"))) + #expect(error.recovery == String( + localized: "Open this connection in TablePro on the Mac, or edit it and choose Password or Private Key." + )) + } + + @Test("A security key points to the Mac") + func securityKeyPointsToTheMac() { + let error = ErrorClassifier.classify(SSHTunnelError.securityKeyUnsupported, context: Self.context) + + #expect(error.category == .config) + #expect(error.recovery == String( + localized: "Open this connection in TablePro on the Mac, or edit it and choose a private key that is not on a security key." + )) + } + + @Test( + "A second sign-in step is not read as a wrong credential", + arguments: [SSHTunnelError.additionalFactorRequired(["keyboard-interactive"]), .promptUnanswerable("Verification code:")] + ) + func secondStepPointsToTheMac(error tunnelError: SSHTunnelError) { + let error = ErrorClassifier.classify(tunnelError, context: Self.context) + + #expect(error.category == .config) + #expect(error.title == String(localized: "SSH Sign-In Unavailable")) + #expect(error.recovery == String(localized: "Open this connection in TablePro on the Mac, which can answer this sign-in step.")) + } + + @Test("A missing passphrase names the key's passphrase") + func missingPassphrase() { + let error = ErrorClassifier.classify(SSHTunnelError.passphraseRequired, context: Self.context) + + #expect(error.category == .auth) + #expect(error.recovery == String(localized: "Edit the connection and enter the key's passphrase under Private Key.")) + } + + @Test("A wrong passphrase names the key's passphrase") + func wrongPassphrase() { + let error = ErrorClassifier.classify(SSHTunnelError.passphraseIncorrect, context: Self.context) + + #expect(error.category == .auth) + #expect(error.recovery == String(localized: "Edit the connection and check the key's passphrase under Private Key.")) + } +} diff --git a/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift b/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift index bbfc52d10a..082ba81b74 100644 --- a/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift +++ b/apple/TableProTests/Core/SSH/Auth/KeyboardInteractiveContextTests.swift @@ -11,6 +11,7 @@ import Foundation import TableProPluginKit @testable import TableProSSHConfig +import TableProSSHTransport import Testing @testable import TablePro diff --git a/apple/scripts/lib/openssh-fixture.sh b/apple/scripts/lib/openssh-fixture.sh new file mode 100644 index 0000000000..d601cd81c8 --- /dev/null +++ b/apple/scripts/lib/openssh-fixture.sh @@ -0,0 +1,42 @@ +# The pinned OpenSSH server images the SSH probes run against, and the readiness check they share. +# +# Sourced, never executed. A script in apple/scripts picks this up with: +# +# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/openssh-fixture.sh" +# +# Every function here starts with openssh_, because scripts/ci/check-lib-sourcing.py claims a +# library's function names for every script under apple/scripts. +# +# shellcheck shell=bash +# shellcheck disable=SC2034 # the image names are read by the scripts that source this + +[ -n "${TABLEPRO_LIB_OPENSSH_FIXTURE_SOURCED:-}" ] && return 0 +TABLEPRO_LIB_OPENSSH_FIXTURE_SOURCED=1 + +# Tag and digest, so a re-pushed tag cannot change the server under a probe. The 10.3 tag is the +# one linux/crates/test-fixtures/src/fixture_image.rs runs. Both images run sshd as the login user +# and only that user can sign in. +OPENSSH_FIXTURE_IMAGE_10="linuxserver/openssh-server:version-10.3_p1-r1@sha256:0f535b0faa7448511b23528165e9fbd4f1fe563b861b3dcaa5ddb0ddbfe2254c" +OPENSSH_FIXTURE_IMAGE_9="linuxserver/openssh-server:version-9.9_p2-r0@sha256:edbbd662675be4f6a06c76c24f785adf68d7c69156152280842788e85d152a44" +OPENSSH_FIXTURE_REDIS_IMAGE="redis:8.8.3-alpine@sha256:0b2b77d3ea5078274795e3177cdbdada8b96316684a38911d528534ed679b5ec" + +# Prints the server's identification line. An open port proves nothing: Docker's port proxy +# accepts a connection before sshd listens and then closes it, and libssh2 fails that connection +# with -13, "Failed getting banner". +openssh_banner() { + local host="$1" port="$2" line + line="$( (exec 3<> "/dev/tcp/$host/$port" && IFS= read -r -t 3 reply <&3 && printf '%s' "$reply") 2> /dev/null)" || return 1 + line="${line%$'\r'}" + [[ "$line" == SSH-2.0-* ]] || return 1 + printf '%s\n' "$line" +} + +openssh_wait_for_banner() { + local host="$1" port="$2" seconds="${3:-60}" deadline + deadline=$((SECONDS + seconds)) + while ((SECONDS < deadline)); do + openssh_banner "$host" "$port" > /dev/null && return 0 + sleep 0.25 + done + return 1 +} diff --git a/apple/scripts/probes/check-ssh-tunnel-matrix.sh b/apple/scripts/probes/check-ssh-tunnel-matrix.sh new file mode 100755 index 0000000000..ea0e3d9788 --- /dev/null +++ b/apple/scripts/probes/check-ssh-tunnel-matrix.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +# +# Opens the iOS app's SSH tunnel against real OpenSSH servers and sends a Redis PING through it, +# once per cell: server version, key type and format, key source, auth method, key exchange, many +# clients and many tunnels, and a server that dies mid-session. A cell passes when it connects or +# ends with the error it names; anything else fails the run. +# +# The tunnel is the app's own code (TableProMobile/TableProMobile/SSH) built for the simulator +# against the libssh2 the app ships (ssh-tunnel-matrix/build-ios-harness.sh), and every cell runs in +# the simulator through `xcrun simctl spawn -s`, which needs no booted device. The servers come from +# scripts/ssh-test-servers.sh. It is a probe, not a test, because no CI job has Docker. +# +# Usage: +# scripts/probes/check-ssh-tunnel-matrix.sh # every cell, then remove the servers +# scripts/probes/check-ssh-tunnel-matrix.sh --keep # leave the servers running afterwards +# scripts/probes/check-ssh-tunnel-matrix.sh [--keep] pq # only the cells whose name holds "pq" +# +# TABLEPRO_SIMULATOR_UDID picks the simulator (default: a shut-down iPhone on the newest iOS), and +# TABLEPRO_SSH_MATRIX_BUILD the build directory, which later runs reuse. +# +# Exits 1 when a cell misses, 2 when the harness, the simulator or the servers cannot start. + +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +HERE="$ROOT/scripts/probes/ssh-tunnel-matrix" +SERVERS="$ROOT/scripts/ssh-test-servers.sh" +BUILD="${TABLEPRO_SSH_MATRIX_BUILD:-${TMPDIR:-/tmp}/tablepro-ssh-matrix-$(printf '%s' "$ROOT" | shasum | cut -c1-8)}" +KEEP=0 +FILTER="" +for argument in "$@"; do + case "$argument" in + --keep) KEEP=1 ;; + -*) + awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" + exit 2 + ;; + *) FILTER="$argument" ;; + esac +done + +WORK="$(mktemp -d)" +cleanup() { + rm -rf "$WORK" + [ "$KEEP" = 1 ] || "$SERVERS" down > /dev/null 2>&1 || true +} +trap cleanup EXIT + +setup_failure() { + echo "SETUP $*" >&2 + exit 2 +} + +pick_simulator() { + xcrun simctl list devices available --json 2> /dev/null | python3 -I -c ' +import json +import sys + +candidates = [] +for runtime, devices in json.load(sys.stdin)["devices"].items(): + name = runtime.rsplit(".", 1)[-1].split("-") + if name[0] != "iOS": + continue + version = [int(part) for part in name[1:] if part.isdigit()] + for device in devices: + if device.get("isAvailable") and device["name"].startswith("iPhone"): + candidates.append((device["state"] != "Shutdown", [-part for part in version], device["udid"])) +candidates.sort() +print(candidates[0][2] if candidates else "") +' +} + +HARNESS="$("$HERE/build-ios-harness.sh" "$BUILD")" || exit 2 +UDID="${TABLEPRO_SIMULATOR_UDID:-$(pick_simulator)}" +[ -n "$UDID" ] || setup_failure "no iPhone simulator; add an iOS runtime in Xcode, Settings, Components" +SELFTEST="$(xcrun simctl spawn -s "$UDID" "$HARNESS" --mode selftest 2>&1)" +[[ "$SELFTEST" == *"HARNESS ok"* ]] || setup_failure "the simulator did not run the harness: $SELFTEST" + +"$SERVERS" up || setup_failure "the SSH test servers did not start" +eval "$("$SERVERS" env)" || setup_failure "could not read the SSH test servers' settings" +echo "Harness $HARNESS on simulator $UDID" +echo "" + +RAN=0 +PASSED=0 +MISSED=0 + +selected() { + [ -z "$FILTER" ] || [[ "$1" == *"$FILTER"* ]] +} + +report() { + local name="$1" expected="$2" result="$3" verdict="$4" + RAN=$((RAN + 1)) + if [ "$verdict" = "pass" ]; then + PASSED=$((PASSED + 1)) + printf 'PASS %-38s %s\n' "$name" "$result" + else + MISSED=$((MISSED + 1)) + printf 'MISS %-38s expected %s\n %-38s got %s\n' "$name" "$expected" "" "$result" + fi +} + +cell() { + local name="$1" expected="$2" port="$3" output result verdict="miss" + shift 3 + selected "$name" || return 0 + output="$(xcrun simctl spawn -s "$UDID" "$HARNESS" --known-hosts "$WORK" --port "$port" \ + --user "$SSH_TEST_USER" --target "$SSH_TEST_TARGET" "$@" 2>&1)" + result="$(printf '%s\n' "$output" | grep -E '^(CONNECTED|ERROR)' | tail -1)" + [ -n "$result" ] || result="no result line: $(printf '%s' "$output" | tail -3 | tr '\n' ' ')" + # shellcheck disable=SC2053 # matched as a glob on purpose + [[ "$result" == $expected ]] && verdict="pass" + report "$name" "$expected" "$result" "$verdict" +} + +PASSWORD=(--auth password --password "$SSH_TEST_PASSWORD") +CONNECTED="CONNECTED" +KEX="ERROR negotiationFailed(*Stage.keyExchangeOrHostKey*" +MAC="ERROR negotiationFailed(*Stage.mac,*" +SECOND_FACTOR="ERROR additionalFactorRequired(*keyboard-interactive*" + +# A key pasted into the app arrives in memory; a key file may or may not have its .pub beside it. +key_cells() { + local server="$1" port="$2" key + for key in ed25519 ed25519-pass rsa rsa-pass ecdsa ecdsa-pass rsa-pem rsa-pem-pass ecdsa-pem ecdsa-pem-pass; do + local passphrase=() + [[ "$key" == *-pass ]] && passphrase=(--passphrase "$SSH_TEST_KEY_PASSPHRASE") + cell "$server/$key/file" "$CONNECTED" "$port" --auth key --key-file "$SSH_TEST_KEYS/$key" \ + ${passphrase[@]+"${passphrase[@]}"} + cell "$server/$key/file-without-pub" "$CONNECTED" "$port" --auth key --key-file "$SSH_TEST_KEYS_NOPUB/$key" \ + ${passphrase[@]+"${passphrase[@]}"} + cell "$server/$key/memory" "$CONNECTED" "$port" --auth key --key-memory "$SSH_TEST_KEYS/$key" \ + ${passphrase[@]+"${passphrase[@]}"} + done +} + +cell v10/password "$CONNECTED" "$SSH_TEST_PORT_V10" "${PASSWORD[@]}" +cell v9/password "$CONNECTED" "$SSH_TEST_PORT_V9" "${PASSWORD[@]}" +key_cells v10 "$SSH_TEST_PORT_V10" +key_cells v9 "$SSH_TEST_PORT_V9" +for server in rsa256 rsa512; do + port_variable="SSH_TEST_PORT_$(echo "$server" | tr '[:lower:]' '[:upper:]')" + cell "$server/rsa/file" "$CONNECTED" "${!port_variable}" --auth key --key-file "$SSH_TEST_KEYS/rsa" + cell "$server/rsa/memory" "$CONNECTED" "${!port_variable}" --auth key --key-memory "$SSH_TEST_KEYS/rsa" +done +cell kbdpass/password "$CONNECTED" "$SSH_TEST_PORT_KBDPASS" "${PASSWORD[@]}" + +cell pq10/password "$KEX" "$SSH_TEST_PORT_PQ10" "${PASSWORD[@]}" +cell pq9/password "$KEX" "$SSH_TEST_PORT_PQ9" "${PASSWORD[@]}" +cell mac10/password "$MAC" "$SSH_TEST_PORT_MAC10" "${PASSWORD[@]}" + +for source in file memory; do + flag="--key-$source" + cell "v10/sk-ed25519/$source" "ERROR securityKeyUnsupported |*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/sk-ed25519" + for key in ed25519-pass rsa-pem-pass; do + cell "v10/$key/no-passphrase/$source" "ERROR passphraseRequired |*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/$key" + cell "v10/$key/wrong-passphrase/$source" "ERROR passphraseIncorrect |*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/$key" --passphrase "not-$SSH_TEST_KEY_PASSPHRASE" + done + cell "v10/unlisted-key/$source" "ERROR authenticationFailed(*" "$SSH_TEST_PORT_V10" \ + --auth key "$flag" "$SSH_TEST_KEYS/stray-pass" --passphrase "$SSH_TEST_KEY_PASSPHRASE" + cell "keytotp/ed25519-pass/$source" "$SECOND_FACTOR" "$SSH_TEST_PORT_KEYTOTP" \ + --auth key "$flag" "$SSH_TEST_KEYS/ed25519-pass" --passphrase "$SSH_TEST_KEY_PASSPHRASE" +done +cell keytotp/ed25519/file "$SECOND_FACTOR" "$SSH_TEST_PORT_KEYTOTP" --auth key --key-file "$SSH_TEST_KEYS/ed25519" +cell passtotp/password "$SECOND_FACTOR" "$SSH_TEST_PORT_PASSTOTP" "${PASSWORD[@]}" +cell totp/password "ERROR promptUnanswerable(*" "$SSH_TEST_PORT_TOTP" "${PASSWORD[@]}" +cell maxauth/agent "ERROR authMethodUnsupported(*sshAgent)*" "$SSH_TEST_PORT_MAXAUTH" --auth agent +cell totp/keyboard-interactive "ERROR authMethodUnsupported(*keyboardInteractive)*" "$SSH_TEST_PORT_TOTP" \ + --auth kbdint --password "$SSH_TEST_PASSWORD" +# hop2 is reachable only from hop1, under its network alias. +cell two-hops/password "ERROR jumpHostsUnsupported |*" "${SSH_TEST_HOP2##*:}" --host "${SSH_TEST_HOP2%:*}" \ + --jump "$SSH_TEST_HOST:$SSH_TEST_PORT_HOP1" "${PASSWORD[@]}" + +cell v10/30-clients-one-tunnel "CONNECTED clients=30" "$SSH_TEST_PORT_V10" --mode clients --count 30 "${PASSWORD[@]}" +cell v10/10-tunnels-one-process "CONNECTED tunnels=10" "$SSH_TEST_PORT_V10" --mode tunnels --count 10 "${PASSWORD[@]}" + +# docker kill drops the server mid-session. A tunnel relaying a client should notice at once from the +# transport; an idle one only through keep-alive (sent every 10s, failing on the second miss after +# 30s). Neither may spin while it waits. +loss_cell() { + local name="loss/docker-kill" expected="LOSS relaying<=3 idle<=75 cpu<1" + local marker="$WORK/loss-marker" output="$WORK/loss.out" pid result verdict + selected "$name" || return 0 + xcrun simctl spawn -s "$UDID" "$HARNESS" --mode loss --marker "$marker" --wait 75 --deadline 180 \ + --known-hosts "$WORK" --port "$SSH_TEST_PORT_LOSS" --user "$SSH_TEST_USER" \ + --target "$SSH_TEST_TARGET" "${PASSWORD[@]}" > "$output" 2>&1 & + pid=$! + while kill -0 "$pid" 2> /dev/null && ! grep -q '^READY' "$output"; do + sleep 0.2 + done + if grep -q '^READY' "$output"; then + "$SERVERS" kill loss > /dev/null + touch "$marker" + fi + wait "$pid" + result="$(grep -E '^(LOSS|ERROR)' "$output" | tail -1)" + [ -n "$result" ] || result="no result line: $(tail -3 "$output" | tr '\n' ' ')" + verdict="$(printf '%s\n' "$result" | awk ' + /^LOSS / { + for (i = 2; i <= NF; i++) { split($i, pair, "="); value[pair[1]] = pair[2] } + fast = value["relaying"] != "none" && value["relaying"] + 0 <= 3 + noticed = value["idle"] != "none" && value["idle"] + 0 <= 75 + print (fast && noticed && value["cpu"] + 0 < 1) ? "pass" : "miss" + exit + } + { print "miss"; exit } + ')" + "$SERVERS" start loss > /dev/null || echo "warning: the loss server did not come back" >&2 + report "$name" "$expected" "$result" "$verdict" +} +loss_cell + +echo "" +[ "$RAN" -gt 0 ] || setup_failure "no cell name holds \"$FILTER\"" +echo "$PASSED of $RAN cells as expected." +[ "$MISSED" -eq 0 ] || exit 1 diff --git a/apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh b/apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh new file mode 100755 index 0000000000..70602e0a0c --- /dev/null +++ b/apple/scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# +# Builds the harness scripts/probes/check-ssh-tunnel-matrix.sh runs in the iOS simulator: +# ios-harness.swift compiled into one module with the iOS app's SSH tunnel sources, linked against +# the simulator slices of libssh2 and OpenSSL the app ships. +# +# The TableProCore modules those sources import come first, with the dependency order and source +# lists `swift package describe` reads from the manifest, so a new file or dependency there needs no +# edit here. A module whose sources have not changed since the last build is reused. +# +# Usage: scripts/probes/ssh-tunnel-matrix/build-ios-harness.sh +# +# Prints the harness path. Exits 2 when it cannot build, with the first compiler error. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +MOBILE="$ROOT/TableProMobile" +APP="$MOBILE/TableProMobile" +SLICE="ios-arm64-simulator" +LIBSSH2="$ROOT/Libs/ios/LibSSH2.xcframework/$SLICE" +LIBSSL="$ROOT/Libs/ios/OpenSSL-SSL.xcframework/$SLICE/libssl.a" +LIBCRYPTO="$ROOT/Libs/ios/OpenSSL-Crypto.xcframework/$SLICE/libcrypto.a" + +setup_failure() { + echo "build-ios-harness: $*" >&2 + exit 2 +} + +compile_failure() { + echo "build-ios-harness: $(grep -m1 'error:' "$1" || tail -1 "$1")" >&2 + echo "build-ios-harness: full log in $1" >&2 + exit 2 +} + +[ $# -eq 1 ] || setup_failure "usage: build-ios-harness.sh " +BUILD="${1%/}" +OUT="$BUILD/modules" +mkdir -p "$OUT" + +for required in "$LIBSSH2/libssh2.a" "$LIBSSH2/Headers/libssh2.h" "$LIBSSL" "$LIBCRYPTO"; do + [ -e "$required" ] || setup_failure "missing $required; run scripts/download-libs.sh" +done + +IOS_VERSION="$(awk ' + /^ deploymentTarget:/ { inTarget = 1; next } + inTarget && /^ iOS:/ { gsub(/[" ]/, "", $2); print $2; exit } + inTarget && /^[^ ]/ { exit } +' "$MOBILE/project.yml")" +[ -n "$IOS_VERSION" ] || setup_failure "no deploymentTarget iOS in TableProMobile/project.yml" +TARGET="arm64-apple-ios$IOS_VERSION-simulator" +SDK="$(xcrun --sdk iphonesimulator --show-sdk-path 2> /dev/null)" || setup_failure "no iphonesimulator SDK" +COMPILER="$(xcrun --sdk iphonesimulator swiftc --version 2> /dev/null)" + +# IOSSSHProvider is left out: it needs AppContainerPaths, which the tunnel does not. +APP_SOURCES=() +for source in "$APP"/SSH/*.swift; do + [ "$(basename "$source")" = "IOSSSHProvider.swift" ] || APP_SOURCES+=("$source") +done +APP_SOURCES+=( + "$APP/Platform/LocalNetworkPermission.swift" + "$APP/Platform/ConnectionSecretKind.swift" + "$APP/Security/PEMDocument.swift" +) +SHARED=0 +while IFS= read -r shared; do + APP_SOURCES+=("$MOBILE/$shared") + SHARED=$((SHARED + 1)) +done < <(sed -n 's|^[[:space:]]*- \(\.\./TablePro/Core/SSH/[^[:space:]]*\.swift\)[[:space:]]*$|\1|p' "$MOBILE/project.yml") +[ "$SHARED" -gt 0 ] || setup_failure "TableProMobile/project.yml lists no file from TablePro/Core/SSH" +APP_SOURCES+=("$HERE/ios-harness.swift") +for source in "${APP_SOURCES[@]}"; do + [ -f "$source" ] || setup_failure "missing $source" +done + +IMPORTS=() +while IFS= read -r module; do + IMPORTS+=("$module") +done < <(sed -n -E 's/^(@testable |@_exported |public |internal |package )?import ([A-Za-z0-9_]+).*/\2/p' "${APP_SOURCES[@]}" | sort -u) + +swift package --package-path "$ROOT/Packages/TableProCore" --scratch-path "$BUILD/describe" \ + describe --type json > "$BUILD/describe.json" 2> "$BUILD/describe.log" || + setup_failure "swift package describe failed: $(grep -m1 'error:' "$BUILD/describe.log" || tail -1 "$BUILD/describe.log")" + +# Writes .sources and prints the modules in build order, dependencies first. +IFS= read -r -d '' CLOSURE << 'PY' || true +import json +import pathlib +import sys + +describe = json.loads(pathlib.Path(sys.argv[1]).read_text()) +out = pathlib.Path(sys.argv[2]) +package = pathlib.Path(describe["path"]) +targets = {target["name"]: target for target in describe["targets"]} +order = [] +seen = set() + + +def visit(name): + if name in seen: + return + seen.add(name) + target = targets[name] + for dependency in target.get("target_dependencies", []): + visit(dependency) + if target.get("product_dependencies"): + sys.exit(f"{name} depends on another package, which this build cannot compile") + if target.get("module_type") != "SwiftTarget" or target.get("resources"): + sys.exit(f"{name} is not a plain Swift target, which this build cannot compile") + base = package / target["path"] + sources = "".join(f"{base / source}\n" for source in sorted(target["sources"])) + (out / f"{name}.sources").write_text(sources) + order.append(name) + + +for name in sys.argv[3:]: + if name in targets: + visit(name) +print("\n".join(order)) +PY +MODULES="$(python3 -I -c "$CLOSURE" "$BUILD/describe.json" "$OUT" TableProDatabase TableProSSHTransport "${IMPORTS[@]}")" || + setup_failure "could not read the TableProCore targets from $BUILD/describe.json" + +# Each stamp folds in the one before it, so a changed module rebuilds every module after it. +LIBRARIES=() +STAMP="" +while IFS= read -r module; do + [ -n "$module" ] || continue + SOURCES=() + while IFS= read -r source; do + SOURCES+=("$source") + done < "$OUT/$module.sources" + STAMP="$( + printf '%s\n%s\n%s\n' "$COMPILER" "$TARGET" "$STAMP" + stat -f '%N %m %z' "${SOURCES[@]}" + )" + STAMP="$(printf '%s' "$STAMP" | shasum -a 256 | cut -c1-16)" + LIBRARIES+=("$OUT/lib$module.a") + if [ -f "$OUT/lib$module.a" ] && [ "$(cat "$OUT/$module.stamp" 2> /dev/null)" = "$STAMP" ]; then + continue + fi + echo "compiling $module (${#SOURCES[@]} files)" >&2 + rm -f "$OUT/$module.stamp" + xcrun --sdk iphonesimulator swiftc -sdk "$SDK" -target "$TARGET" -swift-version 6 -Onone \ + -parse-as-library -module-name "$module" -package-name TableProCore -DSWIFT_PACKAGE -I "$OUT" \ + -emit-module -emit-module-path "$OUT/$module.swiftmodule" \ + -emit-library -static -o "$OUT/lib$module.a" \ + "${SOURCES[@]}" > "$OUT/$module.log" 2>&1 || compile_failure "$OUT/$module.log" + echo "$STAMP" > "$OUT/$module.stamp" +done <<< "$MODULES" + +# The app target's own settings in TableProMobile/project.yml: SWIFT_DEFAULT_ACTOR_ISOLATION, +# SWIFT_APPROACHABLE_CONCURRENCY (the two concurrency features) and MemberImportVisibility. +echo "compiling the harness (${#APP_SOURCES[@]} files)" >&2 +xcrun --sdk iphonesimulator swiftc -sdk "$SDK" -target "$TARGET" -swift-version 6 -Onone \ + -parse-as-library -module-name TableProMobile -default-isolation MainActor \ + -enable-upcoming-feature NonisolatedNonsendingByDefault \ + -enable-upcoming-feature InferIsolatedConformances \ + -enable-upcoming-feature MemberImportVisibility \ + -I "$OUT" -I "$APP/CBridges/CLibSSH2" -Xcc "-I$LIBSSH2/Headers" \ + "${APP_SOURCES[@]}" "${LIBRARIES[@]}" "$LIBSSH2/libssh2.a" "$LIBSSL" "$LIBCRYPTO" -lz \ + -o "$BUILD/ios-harness" > "$BUILD/harness.log" 2>&1 || compile_failure "$BUILD/harness.log" + +echo "$BUILD/ios-harness" diff --git a/apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift b/apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift new file mode 100644 index 0000000000..15085b7802 --- /dev/null +++ b/apple/scripts/probes/ssh-tunnel-matrix/ios-harness.swift @@ -0,0 +1,374 @@ +import Darwin +import Foundation +import TableProDatabase +import TableProModels + +// Compiled into one module with the app's tunnel sources (build-ios-harness.sh) to reach the internal +// SSHTunnelFactory. It ends through _exit: exit() runs OPENSSL_cleanup while a tunnel's teardown can +// still be inside libssh2, which crashed about one run in 40. + +nonisolated struct HarnessFailure: Error, CustomStringConvertible { + let description: String +} + +nonisolated struct HarnessOptions: Sendable { + var mode = "connect" + var host = "127.0.0.1" + var port = 22 + var user = "tablepro" + var auth = "password" + var password: String? + var keyFile: String? + var keyMemory: String? + var passphrase: String? + var jumps: [String] = [] + var target = "redis:6379" + var count = 1 + var marker: String? + var waitSeconds = 75 + var deadlineSeconds = 60 + var knownHostsDirectory = NSTemporaryDirectory() + + init(arguments: [String]) throws { + var index = 0 + while index < arguments.count { + let flag = arguments[index] + guard index + 1 < arguments.count else { throw HarnessFailure(description: "\(flag) needs a value") } + let value = arguments[index + 1] + index += 2 + switch flag { + case "--mode": mode = value + case "--host": host = value + case "--port": port = try Self.number(value, flag) + case "--user": user = value + case "--auth": auth = value + case "--password": password = value + case "--key-file": keyFile = value + case "--key-memory": keyMemory = value + case "--passphrase": passphrase = value + case "--jump": jumps.append(value) + case "--target": target = value + case "--count": count = try Self.number(value, flag) + case "--marker": marker = value + case "--wait": waitSeconds = try Self.number(value, flag) + case "--deadline": deadlineSeconds = try Self.number(value, flag) + case "--known-hosts": knownHostsDirectory = value + default: throw HarnessFailure(description: "unknown option \(flag)") + } + } + } + + static func endpoint(_ text: String) throws -> (host: String, port: Int) { + guard let colon = text.lastIndex(of: ":"), let port = Int(text[text.index(after: colon)...]) else { + throw HarnessFailure(description: "expected host:port, got \(text)") + } + return (String(text[.. Int { + guard let value = Int(text), value > 0 else { throw HarnessFailure(description: "\(flag) takes a positive number") } + return value + } +} + +@MainActor +struct AcceptEveryHostKey: ConnectionPrompter { + func confirm(_ question: ConnectionQuestion) async -> Bool { true } +} + +nonisolated enum Output { + static func emit(_ line: String) { + let flat = line.replacingOccurrences(of: "\r", with: " ").replacingOccurrences(of: "\n", with: " ") + FileHandle.standardOutput.write(Data((flat + "\n").utf8)) + } + + static func finish(_ line: String, code: Int32) -> Never { + emit(line) + _exit(code) + } + + static func errorLine(_ error: any Error) -> String { + let shown = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + return "ERROR \(String(describing: error)) | \(shown)" + } + + static func armWatchdog(seconds: Int) { + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(seconds)) { + Output.finish("ERROR harness: no result within \(seconds)s", code: 3) + } + } +} + +nonisolated enum LocalClient { + static let ioTimeoutSeconds = 10 + + static func open(port: Int) throws -> Int32 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd >= 0 else { throw HarnessFailure(description: "socket: \(lastError())") } + var on: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + var timeout = timeval(tv_sec: ioTimeoutSeconds, tv_usec: 0) + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, socklen_t(MemoryLayout.size)) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = UInt16(port).bigEndian + address.sin_addr.s_addr = inet_addr("127.0.0.1") + let rc = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard rc == 0 else { + let reason = lastError() + Darwin.close(fd) + throw HarnessFailure(description: "connect to 127.0.0.1:\(port): \(reason)") + } + return fd + } + + static func sendPing(_ fd: Int32) throws { + let request = Array("PING\r\n".utf8) + let sent = request.withUnsafeBytes { Darwin.send(fd, $0.baseAddress, $0.count, 0) } + guard sent == request.count else { throw HarnessFailure(description: "send PING: \(lastError())") } + } + + static func expectPong(_ fd: Int32) throws { + var reply: [UInt8] = [] + var buffer = [UInt8](repeating: 0, count: 64) + while !reply.contains(UInt8(ascii: "\n")) { + let count = buffer.withUnsafeMutableBytes { Darwin.recv(fd, $0.baseAddress, $0.count, 0) } + if count > 0 { + reply.append(contentsOf: buffer[0.. String { + String(cString: strerror(errno)) + } +} + +nonisolated enum TunnelOpener { + static func open(_ options: HarnessOptions, prompter: any ConnectionPrompter) async throws -> SSHTunnel { + let (config, credentials) = try request(options) + let destination = try HarnessOptions.endpoint(options.target) + let store = HostKeyStore( + filePath: (options.knownHostsDirectory as NSString).appendingPathComponent("known_hosts_\(UUID().uuidString)") + ) + return try await SSHTunnelFactory.create( + config: config, + remoteHost: destination.host, + remotePort: destination.port, + credentials: credentials, + prompter: prompter, + hostKeyStore: store + ) + } + + private static func request(_ options: HarnessOptions) throws -> (SSHConfiguration, SSHTunnelCredentials) { + var config = SSHConfiguration(host: options.host, port: options.port, username: options.user) + config.jumpHosts = try options.jumps.map { hop in + let endpoint = try HarnessOptions.endpoint(hop) + return SSHJumpHost(host: endpoint.host, port: endpoint.port, username: options.user) + } + switch options.auth { + case "password": + config.authMethod = .password + case "key": + config.authMethod = .privateKey + if let path = options.keyMemory { + let key = try String(contentsOfFile: path, encoding: .utf8) + return (config, SSHTunnelCredentials(keyPassphrase: options.passphrase, privateKey: key)) + } + config.privateKeyPath = options.keyFile + return (config, SSHTunnelCredentials(keyPassphrase: options.passphrase)) + case "agent": + config.authMethod = .sshAgent + case "kbdint": + config.authMethod = .keyboardInteractive + case "none": + config.authMethod = .none + default: + throw HarnessFailure(description: "unknown --auth \(options.auth)") + } + return (config, SSHTunnelCredentials(password: options.password)) + } +} + +@main +enum IOSTunnelHarness { + static func main() async { + let options: HarnessOptions + do { + options = try HarnessOptions(arguments: Array(CommandLine.arguments.dropFirst())) + } catch { + Output.finish("ERROR harness: \(error)", code: 3) + } + Output.armWatchdog(seconds: options.deadlineSeconds) + let prompter = AcceptEveryHostKey() + + switch options.mode { + case "selftest": + Output.finish("HARNESS ok", code: 0) + case "connect": + await connect(options, prompter: prompter) + case "clients": + await clients(options, prompter: prompter) + case "tunnels": + await tunnels(options, prompter: prompter) + case "loss": + await loss(options, prompter: prompter) + default: + Output.finish("ERROR harness: unknown --mode \(options.mode)", code: 3) + } + } + + static func connect(_ options: HarnessOptions, prompter: any ConnectionPrompter) async -> Never { + let tunnel = await openOrFinish(options, prompter: prompter) + do { + try LocalClient.roundTrip(port: tunnel.localPort) + } catch { + Output.finish("ERROR relay: \(error)", code: 1) + } + tunnel.close() + Output.finish("CONNECTED", code: 0) + } + + // Every client is connected before any sends, so all of them hold a channel at the same time. + static func clients(_ options: HarnessOptions, prompter: any ConnectionPrompter) async -> Never { + let tunnel = await openOrFinish(options, prompter: prompter) + var sockets: [Int32] = [] + do { + for _ in 0.. Never { + let opened: [SSHTunnel] + do { + opened = try await withThrowingTaskGroup(of: SSHTunnel.self) { group in + for _ in 0.. Never { + guard let marker = options.marker else { Output.finish("ERROR harness: loss needs --marker", code: 3) } + let relaying = await openOrFinish(options, prompter: prompter) + let held: Int32 + do { + held = try LocalClient.open(port: relaying.localPort) + try LocalClient.sendPing(held) + try LocalClient.expectPong(held) + } catch { + Output.finish("ERROR relay: \(error)", code: 1) + } + let idle = await openOrFinish(options, prompter: prompter) + do { + try LocalClient.roundTrip(port: idle.localPort) + } catch { + Output.finish("ERROR relay: \(error)", code: 1) + } + Output.emit("READY") + + while !FileManager.default.fileExists(atPath: marker) { + try? await Task.sleep(for: .milliseconds(50)) + } + let clock = ContinuousClock() + let start = clock.now + let cpuAtStart = cpuSeconds() + var relayingStopped: Duration? + var idleStopped: Duration? + while relayingStopped == nil || idleStopped == nil, start.duration(to: clock.now) < .seconds(options.waitSeconds) { + if relayingStopped == nil, !relaying.isRunning { relayingStopped = start.duration(to: clock.now) } + if idleStopped == nil, !idle.isRunning { idleStopped = start.duration(to: clock.now) } + try? await Task.sleep(for: .milliseconds(100)) + } + let cpu = cpuSeconds() - cpuAtStart + Darwin.close(held) + Output.finish( + "LOSS relaying=\(seconds(relayingStopped)) idle=\(seconds(idleStopped)) cpu=\(String(format: "%.2f", cpu))", + code: 0 + ) + } + + private static func openOrFinish(_ options: HarnessOptions, prompter: any ConnectionPrompter) async -> SSHTunnel { + do { + return try await TunnelOpener.open(options, prompter: prompter) + } catch { + Output.finish(Output.errorLine(error), code: 1) + } + } + + private static func seconds(_ duration: Duration?) -> String { + guard let duration else { return "none" } + let value = Double(duration.components.seconds) + Double(duration.components.attoseconds) / 1e18 + return String(format: "%.1f", value) + } + + private static func cpuSeconds() -> Double { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + let user = Double(usage.ru_utime.tv_sec) + Double(usage.ru_utime.tv_usec) / 1e6 + let system = Double(usage.ru_stime.tv_sec) + Double(usage.ru_stime.tv_usec) / 1e6 + return user + system + } +} diff --git a/apple/scripts/sftp-test-server.sh b/apple/scripts/sftp-test-server.sh index 2f21825562..2ca51d9864 100755 --- a/apple/scripts/sftp-test-server.sh +++ b/apple/scripts/sftp-test-server.sh @@ -9,20 +9,23 @@ # against this container, and the probe keeps them measured. # # Usage: -# sftp-test-server.sh up # start it, wait until it answers +# sftp-test-server.sh up # start it, wait until sshd sends its banner # sftp-test-server.sh down # remove it -# sftp-test-server.sh status # report whether it is listening +# sftp-test-server.sh status # report whether sshd answers # # The probe connects to 127.0.0.1:22022 and reads no environment variable, so it needs the # default TABLEPRO_SFTP_TEST_PORT. set -euo pipefail +# shellcheck source=lib/openssh-fixture.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/openssh-fixture.sh" + CONTAINER="${TABLEPRO_SFTP_TEST_CONTAINER:-tp-sftp-it}" PORT="${TABLEPRO_SFTP_TEST_PORT:-22022}" USERNAME="tp" PASSWORD="tppass" -IMAGE="lscr.io/linuxserver/openssh-server:latest" +IMAGE="$OPENSSH_FIXTURE_IMAGE_10" usage() { awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" @@ -30,7 +33,7 @@ usage() { } listening() { - nc -z -w 2 127.0.0.1 "$PORT" > /dev/null 2>&1 + openssh_banner 127.0.0.1 "$PORT" > /dev/null } case "${1:-}" in @@ -39,28 +42,25 @@ case "${1:-}" in echo "sftp test server already listening on 127.0.0.1:$PORT" exit 0 fi - docker rm -f "$CONTAINER" > /dev/null 2>&1 || true + docker rm -f -v "$CONTAINER" > /dev/null 2>&1 || true docker run -d --name "$CONTAINER" \ - -p "$PORT:2222" \ + -p "127.0.0.1:$PORT:2222" \ -e USER_NAME="$USERNAME" \ -e USER_PASSWORD="$PASSWORD" \ -e PASSWORD_ACCESS=true \ -e SUDO_ACCESS=false \ "$IMAGE" > /dev/null - for _ in $(seq 1 60); do - if listening; then - echo "sftp test server listening on 127.0.0.1:$PORT as $USERNAME" - exit 0 - fi - sleep 1 - done - echo "sftp test server did not start within 60s" >&2 + if openssh_wait_for_banner 127.0.0.1 "$PORT" 60; then + echo "sftp test server listening on 127.0.0.1:$PORT as $USERNAME" + exit 0 + fi + echo "sftp test server sent no SSH banner within 60s" >&2 docker logs "$CONTAINER" 2>&1 | tail -20 >&2 exit 1 ;; down) - docker rm -f "$CONTAINER" > /dev/null 2>&1 || true + docker rm -f -v "$CONTAINER" > /dev/null 2>&1 || true echo "removed $CONTAINER" ;; status) diff --git a/apple/scripts/ssh-test-servers.sh b/apple/scripts/ssh-test-servers.sh new file mode 100755 index 0000000000..3cc708cad2 --- /dev/null +++ b/apple/scripts/ssh-test-servers.sh @@ -0,0 +1,353 @@ +#!/usr/bin/env bash +# +# Runs the OpenSSH servers scripts/probes/check-ssh-tunnel-matrix.sh opens tunnels through, each +# published on 127.0.0.1 only. +# +# One server per sshd configuration a cell needs, from the pinned images in +# scripts/lib/openssh-fixture.sh. Before a server's first start its /config gets a drop-in, the +# authorized keys and, on the TOTP servers, the code secret: the image writes its own sshd_config +# at init and includes the drop-in near the top, and sshd keeps the first value it reads. A drop-in +# is ssh-test-servers/.conf followed by common.conf. The TOTP servers run an image built +# from ssh-test-servers/totp.Dockerfile. Every server shares one Docker network with redis, the +# query target behind each tunnel, and with hop2, which only hop1 reaches. Neither is published. +# +# Usage: +# ssh-test-servers.sh up [server...] # start the servers, every one by default, and wait for each +# ssh-test-servers.sh down # remove the servers, their network and their keys +# ssh-test-servers.sh status # each server's banner, or why it has none +# ssh-test-servers.sh kill # docker kill, which is how the connection-loss cell drops one +# ssh-test-servers.sh start # start a killed server again and wait for its banner +# ssh-test-servers.sh env # NAME=value lines for a runner to eval +# +# Servers: v10 v9 pq10 pq9 mac10 rsa256 rsa512 kbdpass passtotp keytotp totp maxauth hop1 hop2 loss. +# A bare up removes what an earlier up left and makes new keys; up with names restarts only those. +# Ports run from TABLEPRO_SSH_TEST_PORT_BASE + 1 (default 22601). Keys live in TABLEPRO_SSH_TEST_DIR +# (default $TMPDIR/tablepro-ssh-test-servers). decoy-1 to decoy-4 are keys no server accepts, for an +# agent that offers more keys than maxauth allows tries. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/openssh-fixture.sh +source "$SCRIPT_DIR/lib/openssh-fixture.sh" + +FIXTURE_DIR="$SCRIPT_DIR/ssh-test-servers" +STATE_DIR="${TABLEPRO_SSH_TEST_DIR:-${TMPDIR:-/tmp}/tablepro-ssh-test-servers}" +STATE_DIR="${STATE_DIR%/}" +STATE_MARKER="$STATE_DIR/.tablepro-ssh-test-servers" +PORT_BASE="${TABLEPRO_SSH_TEST_PORT_BASE:-22600}" +NETWORK="tp-ssh-matrix" +PREFIX="tp-ssh-" +LOGIN="tablepro" +PASSWORD="tppass" +KEY_PASSPHRASE="tp-key-pass" +# The usual documentation example: it signs in to these throwaway servers and nowhere else. +TOTP_SECRET="JBSWY3DPEHPK3PXP" +SERVERS="v10 v9 pq10 pq9 mac10 rsa256 rsa512 kbdpass passtotp keytotp totp maxauth hop1 hop2 loss" +AUTHORIZED_KEYS="ed25519 ed25519-pass rsa rsa-pass ecdsa ecdsa-pass rsa-pem rsa-pem-pass ecdsa-pem ecdsa-pem-pass sk-ed25519" +DECOY_KEYS="decoy-1 decoy-2 decoy-3 decoy-4" + +usage() { + awk 'NR > 2 { if (!/^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}" + exit 3 +} + +die() { + echo "$*" >&2 + exit 1 +} + +# image, port offset (- for unpublished), drop-in variant (- for none), PASSWORD_ACCESS. +# PASSWORD_ACCESS=false keeps /etc/shadow from sshd, so on totp and keytotp only the code signs in. +server_spec() { + case "$1" in + v10) echo "10 1 - true" ;; + v9) echo "9 2 - true" ;; + pq10) echo "10 3 pq-only true" ;; + pq9) echo "9 4 pq-only true" ;; + mac10) echo "10 5 mac-only true" ;; + rsa256) echo "10 6 rsa-sha2-256 true" ;; + rsa512) echo "10 7 rsa-sha2-512 true" ;; + kbdpass) echo "10 8 kbdint-password true" ;; + passtotp) echo "totp 9 password-totp true" ;; + keytotp) echo "totp 10 publickey-totp false" ;; + totp) echo "totp 11 totp false" ;; + maxauth) echo "10 12 max-auth-tries true" ;; + hop1) echo "10 13 - true" ;; + hop2) echo "10 - - true" ;; + loss) echo "10 14 - true" ;; + *) return 1 ;; + esac +} + +server_port() { + local image offset conf access + read -r image offset conf access <<< "$(server_spec "$1")" + [ "$offset" = "-" ] && return 1 + echo $((PORT_BASE + offset)) +} + +require_docker() { + command -v docker > /dev/null 2>&1 || die "docker not found" + docker info > /dev/null 2>&1 || die "Docker is not running" +} + +require_server() { + [ -n "${1:-}" ] || usage + server_spec "$1" > /dev/null || die "unknown server: $1 (servers: $SERVERS)" +} + +ensure_image() { + docker image inspect "$1" > /dev/null 2>&1 || docker pull --quiet "$1" > /dev/null || die "could not pull $1" +} + +# Tagged by what goes into it, so an edit to the Dockerfile or the PAM stack builds a new image. +totp_image() { + local tag + tag="tablepro-ssh-totp:$( + { + cat "$FIXTURE_DIR/totp.Dockerfile" "$FIXTURE_DIR/pam-sshd-totp" + printf '%s' "$OPENSSH_FIXTURE_IMAGE_10" + } | shasum -a 256 | cut -c1-12 + )" + if ! docker image inspect "$tag" > /dev/null 2>&1; then + echo "building $tag" >&2 + docker build --quiet --build-arg "OPENSSH_IMAGE=$OPENSSH_FIXTURE_IMAGE_10" \ + -t "$tag" -f "$FIXTURE_DIR/totp.Dockerfile" "$FIXTURE_DIR" > /dev/null || die "could not build $tag" + fi + echo "$tag" +} + +keygen() { + local name="$1" type="$2" passphrase="$3" + shift 3 + ssh-keygen -q -t "$type" -N "$passphrase" -C "tablepro-$name" -f "$STATE_DIR/keys/$name" "$@" < /dev/null +} + +# keys-nopub/ holds the same private keys without their .pub: the app reads a .pub next to a key +# file when there is one and derives the public key otherwise, and the two paths fail differently. +make_keys() { + local name + rm -rf "$STATE_DIR/keys" "$STATE_DIR/keys-nopub" + mkdir -p "$STATE_DIR/keys" "$STATE_DIR/keys-nopub" + keygen ed25519 ed25519 "" + keygen ed25519-pass ed25519 "$KEY_PASSPHRASE" + keygen rsa rsa "" -b 3072 + keygen rsa-pass rsa "$KEY_PASSPHRASE" -b 3072 + keygen ecdsa ecdsa "" -b 256 + keygen ecdsa-pass ecdsa "$KEY_PASSPHRASE" -b 256 + keygen rsa-pem rsa "" -b 3072 -m PEM + keygen rsa-pem-pass rsa "$KEY_PASSPHRASE" -b 3072 -m PEM + keygen ecdsa-pem ecdsa "" -b 256 -m PEM + keygen ecdsa-pem-pass ecdsa "$KEY_PASSPHRASE" -b 256 -m PEM + keygen stray-pass ed25519 "$KEY_PASSPHRASE" + for name in $DECOY_KEYS; do + keygen "$name" ed25519 "" + done + python3 -I "$FIXTURE_DIR/make-sk-key.py" "$STATE_DIR/keys/sk-ed25519" + for name in $AUTHORIZED_KEYS stray-pass $DECOY_KEYS; do + cp -p "$STATE_DIR/keys/$name" "$STATE_DIR/keys-nopub/$name" + done +} + +stage_server() { + local name="$1" image="$2" conf="$3" stage="$STATE_DIR/stage/$1" key + rm -rf "$stage" + mkdir -p "$stage/sshd/sshd_config.d" "$stage/.ssh" + { + [ "$conf" = "-" ] || cat "$FIXTURE_DIR/$conf.conf" + cat "$FIXTURE_DIR/common.conf" + } > "$stage/sshd/sshd_config.d/tablepro.conf" + for key in $AUTHORIZED_KEYS; do + cat "$STATE_DIR/keys/$key.pub" + done > "$stage/.ssh/authorized_keys" + if [ "$image" = "totp" ]; then + printf '%s\n" TOTP_AUTH\n' "$TOTP_SECRET" > "$stage/.google_authenticator" + chmod 600 "$stage/.google_authenticator" + fi +} + +image_ref() { + case "$1" in + 10) echo "$OPENSSH_FIXTURE_IMAGE_10" ;; + 9) echo "$OPENSSH_FIXTURE_IMAGE_9" ;; + totp) echo "$TOTP_IMAGE" ;; + esac +} + +# docker cp into a created container lands before init runs. /config/sshd does not exist yet, so +# the drop-in has to arrive inside its directory, not as a single file. +create_server() { + local name="$1" image offset conf access container="$PREFIX$1" + read -r image offset conf access <<< "$(server_spec "$name")" + stage_server "$name" "$image" "$conf" + local args=( + --name "$container" --network "$NETWORK" --network-alias "$name" + -e "USER_NAME=$LOGIN" -e "USER_PASSWORD=$PASSWORD" -e "PASSWORD_ACCESS=$access" + -e SUDO_ACCESS=false -e LOG_STDOUT=true + ) + if [ "$offset" != "-" ]; then + args+=(-p "127.0.0.1:$((PORT_BASE + offset)):2222") + fi + docker rm -f -v "$container" > /dev/null 2>&1 || true + docker create "${args[@]}" "$(image_ref "$image")" > /dev/null + docker cp "$STATE_DIR/stage/$name/." "$container:/config/" + docker start "$container" > /dev/null +} + +# hop2 is not published, so its banner is read from inside its own container. +wait_ready() { + local name="$1" port + if port="$(server_port "$name")"; then + openssh_wait_for_banner 127.0.0.1 "$port" 90 && return 0 + else + local deadline=$((SECONDS + 90)) + while ((SECONDS < deadline)); do + docker exec "$PREFIX$name" bash -c \ + 'exec 3<> /dev/tcp/127.0.0.1/2222 && IFS= read -r -t 3 line <&3 && [[ $line == SSH-2.0-* ]]' \ + > /dev/null 2>&1 && return 0 + sleep 0.25 + done + fi + echo "$name sent no SSH banner within 90s; its log:" >&2 + docker logs "$PREFIX$name" 2>&1 | tail -20 >&2 + return 1 +} + +start_redis() { + local container="${PREFIX}redis" _ + docker rm -f -v "$container" > /dev/null 2>&1 || true + docker run -d --name "$container" --network "$NETWORK" --network-alias redis \ + "$OPENSSH_FIXTURE_REDIS_IMAGE" redis-server --save "" --appendonly no --protected-mode no > /dev/null + for _ in $(seq 1 120); do + [ "$(docker exec "$container" redis-cli ping 2> /dev/null)" = "PONG" ] && return 0 + sleep 0.25 + done + die "redis did not answer PING within 30s" +} + +remove_all() { + local name + for name in $SERVERS redis; do + docker rm -f -v "$PREFIX$name" > /dev/null 2>&1 || true + done + docker network rm "$NETWORK" > /dev/null 2>&1 || true +} + +cmd_up() { + local names="$*" name image offset conf access needs_totp=0 + require_docker + for name in $names; do + require_server "$name" + done + if [ -z "$names" ]; then + names="$SERVERS" + remove_all + mkdir -p "$STATE_DIR" + touch "$STATE_MARKER" + make_keys + elif [ ! -f "$STATE_DIR/keys/ed25519" ]; then + die "no keys in $STATE_DIR; run a bare up first" + fi + + for name in $names; do + read -r image offset conf access <<< "$(server_spec "$name")" + [ "$image" = "totp" ] && needs_totp=1 + done + ensure_image "$OPENSSH_FIXTURE_IMAGE_10" + ensure_image "$OPENSSH_FIXTURE_IMAGE_9" + ensure_image "$OPENSSH_FIXTURE_REDIS_IMAGE" + TOTP_IMAGE="" + if [ "$needs_totp" = 1 ]; then + TOTP_IMAGE="$(totp_image)" + fi + + docker network inspect "$NETWORK" > /dev/null 2>&1 || docker network create "$NETWORK" > /dev/null + if [ "$(docker inspect -f '{{.State.Running}}' "${PREFIX}redis" 2> /dev/null)" != "true" ]; then + start_redis + fi + for name in $names; do + create_server "$name" + done + for name in $names; do + wait_ready "$name" || exit 1 + done + echo "SSH test servers ready: $names" +} + +cmd_down() { + remove_all + if [ -f "$STATE_MARKER" ]; then + rm -rf "$STATE_DIR/keys" "$STATE_DIR/keys-nopub" "$STATE_DIR/stage" + rm -f "$STATE_MARKER" + rmdir "$STATE_DIR" 2> /dev/null || true + fi + echo "removed the SSH test servers" +} + +cmd_status() { + local name state port banner failed=0 + for name in $SERVERS redis; do + state="$(docker inspect -f '{{.State.Status}}' "$PREFIX$name" 2> /dev/null || echo "missing")" + if [ "$state" != "running" ]; then + printf '%-9s %s\n' "$name" "$state" + failed=1 + elif [ "$name" = "redis" ] || ! port="$(server_port "$name")"; then + printf '%-9s running, not published\n' "$name" + elif banner="$(openssh_banner 127.0.0.1 "$port")"; then + printf '%-9s 127.0.0.1:%s %s\n' "$name" "$port" "$banner" + else + printf '%-9s 127.0.0.1:%s no banner\n' "$name" "$port" + failed=1 + fi + done + return "$failed" +} + +cmd_env() { + local name port + printf 'SSH_TEST_HOST=127.0.0.1\n' + printf 'SSH_TEST_USER=%q\n' "$LOGIN" + printf 'SSH_TEST_PASSWORD=%q\n' "$PASSWORD" + printf 'SSH_TEST_KEY_PASSPHRASE=%q\n' "$KEY_PASSPHRASE" + printf 'SSH_TEST_TOTP_SECRET=%q\n' "$TOTP_SECRET" + printf 'SSH_TEST_KEYS=%q\n' "$STATE_DIR/keys" + printf 'SSH_TEST_KEYS_NOPUB=%q\n' "$STATE_DIR/keys-nopub" + printf 'SSH_TEST_DECOY_KEYS=%q\n' "$DECOY_KEYS" + printf 'SSH_TEST_TARGET=redis:6379\n' + printf 'SSH_TEST_HOP2=hop2:2222\n' + for name in $SERVERS; do + port="$(server_port "$name")" || continue + printf 'SSH_TEST_PORT_%s=%s\n' "$(echo "$name" | tr '[:lower:]' '[:upper:]')" "$port" + done +} + +case "${1:-}" in + up) + shift + cmd_up "$@" + ;; + down) + cmd_down + ;; + status) + cmd_status + ;; + kill) + require_server "${2:-}" + docker kill "$PREFIX$2" > /dev/null + echo "killed $2" + ;; + start) + require_server "${2:-}" + docker start "$PREFIX$2" > /dev/null + wait_ready "$2" + echo "$2 ready" + ;; + env) + cmd_env + ;; + *) + usage + ;; +esac diff --git a/apple/scripts/ssh-test-servers/common.conf b/apple/scripts/ssh-test-servers/common.conf new file mode 100644 index 0000000000..1ad8cfa8c0 --- /dev/null +++ b/apple/scripts/ssh-test-servers/common.conf @@ -0,0 +1,6 @@ +# Every server. The image writes AllowTcpForwarding no into its own sshd_config. +AllowTcpForwarding yes +# Every connection from the Mac arrives from the Docker gateway, so OpenSSH's per-source +# penalties refuse the runner after about twenty failed logins: "Not allowed at this time". +PerSourcePenalties no +LogLevel DEBUG1 diff --git a/apple/scripts/ssh-test-servers/kbdint-password.conf b/apple/scripts/ssh-test-servers/kbdint-password.conf new file mode 100644 index 0000000000..493362c01a --- /dev/null +++ b/apple/scripts/ssh-test-servers/kbdint-password.conf @@ -0,0 +1,5 @@ +# The password method is off and keyboard-interactive runs the image's own PAM stack, which asks +# "Password: ". A client that only sends the password method cannot sign in here. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication no diff --git a/apple/scripts/ssh-test-servers/mac-only.conf b/apple/scripts/ssh-test-servers/mac-only.conf new file mode 100644 index 0000000000..651ce03cc7 --- /dev/null +++ b/apple/scripts/ssh-test-servers/mac-only.conf @@ -0,0 +1,4 @@ +# Key exchange and cipher agree, the MAC does not: aes128-ctr needs a MAC and libssh2 has no UMAC. +# It fails with the same -5 as a key exchange mismatch, so it tells the two stages apart. +Ciphers aes128-ctr +MACs umac-128-etm@openssh.com diff --git a/apple/scripts/ssh-test-servers/make-sk-key.py b/apple/scripts/ssh-test-servers/make-sk-key.py new file mode 100755 index 0000000000..5f74a7a863 --- /dev/null +++ b/apple/scripts/ssh-test-servers/make-sk-key.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Writes an sk-ssh-ed25519@openssh.com private key and its .pub without a security key attached. +# Signing with it can never work, which is the point: the cell checks the client names the key +# type before it tries. ssh-keygen -l reads it as ED25519-SK and sshd accepts the .pub. +# +# Usage: make-sk-key.py + +import base64 +import os +import struct +import sys + +KEY_TYPE = "sk-ssh-ed25519@openssh.com" +APPLICATION = "ssh:" + + +def string(value): + if isinstance(value, str): + value = value.encode() + return struct.pack(">I", len(value)) + value + + +def main(): + if len(sys.argv) != 2: + sys.exit("usage: make-sk-key.py ") + path = sys.argv[1] + public_key = os.urandom(32) + public_blob = string(KEY_TYPE) + string(public_key) + string(APPLICATION) + + check = os.urandom(4) + flags = bytes([0x01]) + private = ( + check + + check + + string(KEY_TYPE) + + string(public_key) + + string(APPLICATION) + + flags + + string(os.urandom(64)) + + string(b"") + + string("tablepro-sk") + ) + pad = 1 + while len(private) % 8: + private += bytes([pad]) + pad += 1 + + body = ( + b"openssh-key-v1\x00" + + string("none") + + string("none") + + string(b"") + + struct.pack(">I", 1) + + string(public_blob) + + string(private) + ) + encoded = base64.b64encode(body).decode() + lines = [encoded[i : i + 70] for i in range(0, len(encoded), 70)] + + with open(path, "w", encoding="ascii") as handle: + handle.write("-----BEGIN OPENSSH PRIVATE KEY-----\n") + handle.write("\n".join(lines) + "\n") + handle.write("-----END OPENSSH PRIVATE KEY-----\n") + os.chmod(path, 0o600) + with open(path + ".pub", "w", encoding="ascii") as handle: + handle.write(f"{KEY_TYPE} {base64.b64encode(public_blob).decode()} tablepro-sk\n") + + +if __name__ == "__main__": + main() diff --git a/apple/scripts/ssh-test-servers/max-auth-tries.conf b/apple/scripts/ssh-test-servers/max-auth-tries.conf new file mode 100644 index 0000000000..ec3fdbde00 --- /dev/null +++ b/apple/scripts/ssh-test-servers/max-auth-tries.conf @@ -0,0 +1,2 @@ +# Fewer tries than the decoy keys an agent offers before the right one (#2601). +MaxAuthTries 3 diff --git a/apple/scripts/ssh-test-servers/pam-sshd-totp b/apple/scripts/ssh-test-servers/pam-sshd-totp new file mode 100644 index 0000000000..abb9f60d9f --- /dev/null +++ b/apple/scripts/ssh-test-servers/pam-sshd-totp @@ -0,0 +1,12 @@ +#%PAM-1.0 +# sshd sends the password method and keyboard-interactive through this one stack, and answers each +# hidden prompt of the password method with the password. So the code comes first and a failed code +# falls through to the password: on passtotp the password step signs in and keyboard-interactive +# asks for the code. That also lets a wrong code followed by the password in on passtotp, so a +# wrong-code cell belongs on totp or keytotp. Those start with PASSWORD_ACCESS=false, which keeps +# /etc/shadow from sshd, so pam_unix always fails there and only the code signs in. pam_unix also +# reports an unreadable shadow entry as an expired account, hence pam_permit for the account check. +auth [success=done default=ignore] pam_google_authenticator.so secret=/config/.google_authenticator +auth required pam_unix.so +account required pam_permit.so +session include base-session diff --git a/apple/scripts/ssh-test-servers/password-totp.conf b/apple/scripts/ssh-test-servers/password-totp.conf new file mode 100644 index 0000000000..ab9a4488c7 --- /dev/null +++ b/apple/scripts/ssh-test-servers/password-totp.conf @@ -0,0 +1,6 @@ +# The password, then a verification code. The right password alone is a partial success, which +# libssh2 reports with the same -18 as a wrong one. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication yes +AuthenticationMethods password,keyboard-interactive diff --git a/apple/scripts/ssh-test-servers/pq-only.conf b/apple/scripts/ssh-test-servers/pq-only.conf new file mode 100644 index 0000000000..225e0c2a78 --- /dev/null +++ b/apple/scripts/ssh-test-servers/pq-only.conf @@ -0,0 +1,3 @@ +# Post-quantum key exchange only. libssh2 1.11.1 has neither ML-KEM nor sntrup761, so no key +# exchange agrees and the client must name that stage. +KexAlgorithms mlkem768x25519-sha256,sntrup761x25519-sha512 diff --git a/apple/scripts/ssh-test-servers/publickey-totp.conf b/apple/scripts/ssh-test-servers/publickey-totp.conf new file mode 100644 index 0000000000..9638e443a1 --- /dev/null +++ b/apple/scripts/ssh-test-servers/publickey-totp.conf @@ -0,0 +1,6 @@ +# A key, then a verification code. The key alone is a partial success, which libssh2 reports as a +# failed public key login. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication no +AuthenticationMethods publickey,keyboard-interactive diff --git a/apple/scripts/ssh-test-servers/rsa-sha2-256.conf b/apple/scripts/ssh-test-servers/rsa-sha2-256.conf new file mode 100644 index 0000000000..91705cb2ba --- /dev/null +++ b/apple/scripts/ssh-test-servers/rsa-sha2-256.conf @@ -0,0 +1,2 @@ +# server-sig-algs then lists only this signature, so an RSA key has to sign with it. +PubkeyAcceptedAlgorithms rsa-sha2-256 diff --git a/apple/scripts/ssh-test-servers/rsa-sha2-512.conf b/apple/scripts/ssh-test-servers/rsa-sha2-512.conf new file mode 100644 index 0000000000..e0ae66d263 --- /dev/null +++ b/apple/scripts/ssh-test-servers/rsa-sha2-512.conf @@ -0,0 +1,2 @@ +# server-sig-algs then lists only this signature, so an RSA key has to sign with it. +PubkeyAcceptedAlgorithms rsa-sha2-512 diff --git a/apple/scripts/ssh-test-servers/totp.Dockerfile b/apple/scripts/ssh-test-servers/totp.Dockerfile new file mode 100644 index 0000000000..6a4ec83f49 --- /dev/null +++ b/apple/scripts/ssh-test-servers/totp.Dockerfile @@ -0,0 +1,7 @@ +# The 10.3 server with a TOTP second factor. Built by scripts/ssh-test-servers.sh, which passes the +# pinned image from scripts/lib/openssh-fixture.sh. The package version is pinned too, so the build +# fails rather than changing when Alpine moves it. +ARG OPENSSH_IMAGE +FROM ${OPENSSH_IMAGE} +RUN apk add --no-cache google-authenticator=1.11-r0 +COPY pam-sshd-totp /etc/pam.d/sshd diff --git a/apple/scripts/ssh-test-servers/totp.conf b/apple/scripts/ssh-test-servers/totp.conf new file mode 100644 index 0000000000..43ab0c5b9b --- /dev/null +++ b/apple/scripts/ssh-test-servers/totp.conf @@ -0,0 +1,6 @@ +# A verification code and nothing else: keyboard-interactive asks "Verification code: " first, +# a prompt a password cannot answer. +UsePAM yes +KbdInteractiveAuthentication yes +PasswordAuthentication no +AuthenticationMethods keyboard-interactive From 0c92df3a0cfdfd035f1f299cb03a1b9a9b720cc0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:09:04 +0700 Subject: [PATCH 002/151] docs(changelog): add the SSH tunnel fixes from #3687 --- apple/changelog.d/3687.fixed.1.md | 1 + apple/changelog.d/3687.fixed.2.md | 1 + apple/changelog.d/3687.fixed.3.md | 1 + apple/changelog.d/3687.fixed.4.md | 1 + apple/changelog.d/3687.fixed.5.md | 1 + apple/changelog.d/3687.fixed.md | 1 + 6 files changed, 6 insertions(+) create mode 100644 apple/changelog.d/3687.fixed.1.md create mode 100644 apple/changelog.d/3687.fixed.2.md create mode 100644 apple/changelog.d/3687.fixed.3.md create mode 100644 apple/changelog.d/3687.fixed.4.md create mode 100644 apple/changelog.d/3687.fixed.5.md create mode 100644 apple/changelog.d/3687.fixed.md diff --git a/apple/changelog.d/3687.fixed.1.md b/apple/changelog.d/3687.fixed.1.md new file mode 100644 index 0000000000..23741e185b --- /dev/null +++ b/apple/changelog.d/3687.fixed.1.md @@ -0,0 +1 @@ +Password sign-in on iPhone and iPad failed on SSH servers that ask for the password through keyboard-interactive. diff --git a/apple/changelog.d/3687.fixed.2.md b/apple/changelog.d/3687.fixed.2.md new file mode 100644 index 0000000000..1aee53e565 --- /dev/null +++ b/apple/changelog.d/3687.fixed.2.md @@ -0,0 +1 @@ +A missing or wrong SSH key passphrase, or a security key, showed only an error number on iPhone and iPad. diff --git a/apple/changelog.d/3687.fixed.3.md b/apple/changelog.d/3687.fixed.3.md new file mode 100644 index 0000000000..9560fbb21f --- /dev/null +++ b/apple/changelog.d/3687.fixed.3.md @@ -0,0 +1 @@ +An SSH server that asks for a second sign-in step looked like a wrong password on iPhone and iPad. diff --git a/apple/changelog.d/3687.fixed.4.md b/apple/changelog.d/3687.fixed.4.md new file mode 100644 index 0000000000..c3d4cee1ac --- /dev/null +++ b/apple/changelog.d/3687.fixed.4.md @@ -0,0 +1 @@ +An SSH connection noticed that its server had stopped only after the second missed keep-alive. diff --git a/apple/changelog.d/3687.fixed.5.md b/apple/changelog.d/3687.fixed.5.md new file mode 100644 index 0000000000..d3d884eb2d --- /dev/null +++ b/apple/changelog.d/3687.fixed.5.md @@ -0,0 +1 @@ +TablePro could quit when a connection through an SSH tunnel closed while data was still arriving. diff --git a/apple/changelog.d/3687.fixed.md b/apple/changelog.d/3687.fixed.md new file mode 100644 index 0000000000..4f633ec4dd --- /dev/null +++ b/apple/changelog.d/3687.fixed.md @@ -0,0 +1 @@ +An SSH server with no key exchange, cipher or MAC in common showed only an error number on iPhone and iPad. From 7e3b593c91767c6153da6847dee1e7e4eee9e1f8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:10:59 +0700 Subject: [PATCH 003/151] feat(ios): show an open transaction on every tab, with Commit and Roll Back --- .../TableProDatabase/ConnectionManager.swift | 18 +- .../DriverTransactionState.swift | 2 + .../WriteTransactionPolicy.swift | 5 +- .../ConnectionManagerTests.swift | 68 ++++ .../WriteTransactionTests.swift | 45 ++- .../OracleCoreConnection.swift | 19 +- .../OracleSessionTransaction.swift | 22 +- .../OracleTransactionRole.swift | 21 +- .../OracleSessionTransactionTests.swift | 79 ++++ .../OracleTransactionRoleTests.swift | 39 +- .../TableProMobile/AppState.swift | 18 +- .../Coordinators/ConnectionCoordinator.swift | 268 +++++++++++++- .../Coordinators/SessionRedialer.swift | 58 +++ .../TableProMobile/Drivers/DuckDBDriver.swift | 5 +- .../TableProMobile/Drivers/MySQLDriver.swift | 7 +- .../Drivers/PostgreSQLDriver.swift | 91 ++++- .../Helpers/ConnectionListRowModel.swift | 8 +- .../TableProMobile/Helpers/RowInserter.swift | 13 +- .../TableProMobile/Localizable.xcstrings | 69 ++++ .../Platform/SessionTransactionMonitor.swift | 167 +++++++++ .../ViewModels/QueryEditorViewModel.swift | 5 + .../TableProMobile/Views/ConnectedView.swift | 44 ++- .../Views/ConnectionListRow.swift | 7 + .../Views/ConnectionListView.swift | 3 +- .../Views/DataBrowserView.swift | 27 +- .../TableProMobile/Views/InsertRowView.swift | 3 +- .../Views/QueryEditorView.swift | 4 +- .../TableProMobile/Views/RowDetailView.swift | 11 +- .../TableProMobile/Views/TableListView.swift | 4 +- .../Views/TransactionIndicatorContent.swift | 81 +++++ .../Views/TransactionIndicatorView.swift | 250 +++++++++++++ .../BackgroundReleaseCoordinatorTests.swift | 13 - .../ConnectionCoordinatorReconnectTests.swift | 344 ++++++++++++++++++ .../ConnectionListRowModelTests.swift | 21 ++ .../Drivers/DuckDBDriverTests.swift | 34 ++ .../Drivers/MySQLDriverTransactionTests.swift | 15 + .../PostgreSQLTransactionStatementTests.swift | 72 +++- .../Helpers/RowInserterTests.swift | 30 +- .../Mocks/MockDatabaseDriver.swift | 34 +- .../Mocks/StubDriverFactory.swift | 39 ++ .../Onboarding/AppStateFixture.swift | 6 +- .../SessionTransactionMonitorTests.swift | 243 +++++++++++++ .../QueryEditorViewModelTests.swift | 19 + .../Support/TestGate.swift | 28 ++ .../Views/BottomSafeAreaBarLayoutTests.swift | 70 +++- .../TransactionIndicatorContentTests.swift | 139 +++++++ 46 files changed, 2466 insertions(+), 102 deletions(-) create mode 100644 apple/TableProMobile/TableProMobile/Coordinators/SessionRedialer.swift create mode 100644 apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/TransactionIndicatorContent.swift create mode 100644 apple/TableProMobile/TableProMobile/Views/TransactionIndicatorView.swift create mode 100644 apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Mocks/StubDriverFactory.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Support/TestGate.swift create mode 100644 apple/TableProMobile/TableProMobileTests/Views/TransactionIndicatorContentTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift index 4a2dc13a3b..63df7d0a52 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/ConnectionManager.swift @@ -16,6 +16,7 @@ public final class ConnectionManager: @unchecked Sendable { private var blockingTeardowns: Set = [] private var attemptGenerations: [UUID: Int] = [:] private var lastTeardownId = 0 + private var sessionEndObservers: [@Sendable (UUID, any DatabaseDriver) -> Void] = [] public init( driverFactory: DriverFactory, @@ -217,9 +218,10 @@ public final class ConnectionManager: @unchecked Sendable { /// `retireTeardown` cannot clear an entry that is not installed yet. private func claimTeardown(for connectionId: UUID) -> ConnectionTeardown? { lock.lock() - defer { lock.unlock() } guard let session = sessions.removeValue(forKey: connectionId) else { - return teardowns[connectionId] + let running = teardowns[connectionId] + lock.unlock() + return running } lastTeardownId += 1 let teardown = ConnectionTeardown(id: lastTeardownId) @@ -236,9 +238,21 @@ public final class ConnectionManager: @unchecked Sendable { if session.driver.holdsSuspensionBlockingResource { blockingTeardowns.insert(connectionId) } + let observers = sessionEndObservers + lock.unlock() + // After the unlock: an observer may call back into the manager, and this lock is not recursive. + for observer in observers { + observer(connectionId, session.driver) + } return teardown } + public func observeSessionEnds(_ observer: @escaping @Sendable (UUID, any DatabaseDriver) -> Void) { + lock.lock() + defer { lock.unlock() } + sessionEndObservers.append(observer) + } + /// Runs before `ConnectionTeardown.finish()`, so a waiter it wakes already reads the cleared state. private func retireTeardown(_ teardownId: Int, for connectionId: UUID) { lock.lock() diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift index 3128df9d5f..201355116a 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/DriverTransactionState.swift @@ -4,5 +4,7 @@ public enum DriverTransactionState: Sendable, Equatable { case idle case explicitTransaction case implicitTransaction + // A statement failed inside the transaction: the server refuses everything until a ROLLBACK, and a COMMIT rolls back. + case abortedTransaction case unknown } diff --git a/apple/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift b/apple/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift index d7a53e025e..695da2b08a 100644 --- a/apple/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift +++ b/apple/Packages/TableProCore/Sources/TableProDatabase/WriteTransactionPolicy.swift @@ -8,9 +8,10 @@ public enum WriteTransactionPolicy { ) -> Bool { guard supportsTransactions else { return false } switch state { - case .idle, .implicitTransaction: + case .idle: return true - case .explicitTransaction: + // MySQL's START TRANSACTION commits the work an autocommit-off session holds, so the write joins it. + case .explicitTransaction, .implicitTransaction, .abortedTransaction: return false case .unknown: return statementCount > 1 diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift index c50ac803ce..01a8e0d9ad 100644 --- a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/ConnectionManagerTests.swift @@ -544,4 +544,72 @@ struct ConnectionManagerTests { #expect(ssh.tunnelledConnectionIds == [connection.id]) } + + @Test("A replaced or disconnected session reaches the observer with the driver it ended") + func sessionEndsReachTheObserver() async throws { + let factory = MockDriverFactory() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore()) + let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) + let ended = EndedSessions() + manager.observeSessionEnds { id, driver in + ended.record(id, driver, stillListed: false) + } + + let first = MockDatabaseDriver() + factory.drivers["mock"] = first + _ = try await manager.connect(connection) + #expect(ended.records.isEmpty) + + let second = MockDatabaseDriver() + factory.drivers["mock"] = second + _ = try await manager.connect(connection) + await manager.disconnect(connection.id) + + let records = ended.records + let endedIds = records.map { $0.id } + #expect(endedIds == [connection.id, connection.id]) + #expect(records.first?.driver === first) + #expect(records.last?.driver === second) + } + + @Test("An observer that reads the manager back runs after the session is gone, outside the lock") + func observerCanReadTheManager() async throws { + let factory = MockDriverFactory() + factory.drivers["mock"] = MockDatabaseDriver() + let manager = ConnectionManager(driverFactory: factory, secureStore: MockSecureStore()) + let connection = DatabaseConnection(name: "Test", type: DatabaseType(rawValue: "mock")) + let ended = EndedSessions() + manager.observeSessionEnds { id, driver in + ended.record(id, driver, stillListed: manager.session(for: id) != nil) + } + + _ = try await manager.connect(connection) + await manager.disconnect(connection.id) + + let stillListed = ended.records.map { $0.stillListed } + #expect(stillListed == [false]) + } +} + +private final class EndedSessions: @unchecked Sendable { + struct Record { + let id: UUID + let driver: any DatabaseDriver + let stillListed: Bool + } + + private let lock = NSLock() + private var recorded: [Record] = [] + + var records: [Record] { + lock.lock() + defer { lock.unlock() } + return recorded + } + + func record(_ id: UUID, _ driver: any DatabaseDriver, stillListed: Bool) { + lock.lock() + defer { lock.unlock() } + recorded.append(Record(id: id, driver: driver, stillListed: stillListed)) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift index 0b36c5efd4..12385dec7c 100644 --- a/apple/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProDatabaseTests/WriteTransactionTests.swift @@ -111,7 +111,7 @@ private final class ProtocolDefaultDriver: DatabaseDriver, @unchecked Sendable { struct WriteTransactionPolicyTests { @Test("A driver without transactions never opens one") func withoutTransactions() { - for state in [DriverTransactionState.idle, .explicitTransaction, .implicitTransaction, .unknown] { + for state in [DriverTransactionState.idle, .explicitTransaction, .implicitTransaction, .abortedTransaction, .unknown] { for count in 1...2 { #expect( WriteTransactionPolicy.opensTransaction( @@ -128,17 +128,31 @@ struct WriteTransactionPolicyTests { #expect(WriteTransactionPolicy.opensTransaction(supportsTransactions: true, state: .idle, statementCount: 2)) } - @Test("A transaction the server opened because autocommit is off still wraps and commits") - func implicitTransactionWraps() { + @Test("A transaction the server opened because autocommit is off is joined, since a BEGIN would commit it") + func implicitTransactionIsJoined() { #expect( WriteTransactionPolicy.opensTransaction( supportsTransactions: true, state: .implicitTransaction, statementCount: 1 - ) + ) == false ) #expect( WriteTransactionPolicy.opensTransaction( supportsTransactions: true, state: .implicitTransaction, statementCount: 2 - ) + ) == false + ) + } + + @Test("A transaction a failed statement aborted is joined, so the server's refusal reaches the caller") + func abortedTransactionIsJoined() { + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .abortedTransaction, statementCount: 1 + ) == false + ) + #expect( + WriteTransactionPolicy.opensTransaction( + supportsTransactions: true, state: .abortedTransaction, statementCount: 2 + ) == false ) } @@ -192,14 +206,27 @@ struct DatabaseDriverWriteTests { #expect(driver.events == [.execute("UPDATE t SET v = 1")]) } - @Test("A session with autocommit off is wrapped and committed") - func implicitTransactionWraps() async throws { + @Test("A session with autocommit off runs the batch inside the work it holds, with no BEGIN or COMMIT") + func implicitTransactionIsJoined() async throws { let driver = RecordingWriteDriver() driver.scriptedState = .implicitTransaction - _ = try await driver.executeWrite(["UPDATE t SET v = 1"]) + _ = try await driver.executeWrite(["INSERT a", "INSERT b"]) - #expect(driver.events == [.begin(.readWrite), .execute("UPDATE t SET v = 1"), .commit]) + #expect(driver.events == [.execute("INSERT a"), .execute("INSERT b")]) + } + + @Test("A session in an aborted transaction sends the statement bare and leaves the rollback to the user") + func abortedTransactionIsJoined() async throws { + let driver = RecordingWriteDriver() + driver.scriptedState = .abortedTransaction + driver.failingStatement = "UPDATE t SET v = 1" + + await #expect(throws: WriteFailure.self) { + _ = try await driver.executeWrite(["UPDATE t SET v = 1"]) + } + + #expect(driver.events == [.execute("UPDATE t SET v = 1")]) } @Test("An unknown state leaves a single statement bare and wraps a batch") diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index f2aa64f37a..19219de1b6 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -579,14 +579,21 @@ public final class OracleCoreConnection: @unchecked Sendable { } } - private func recordSuccess(of role: OracleTransactionRole, on session: Int) { - state.withLock { $0.transaction.statementSucceeded(role, on: session) } + private func recordSuccess(of role: OracleTransactionRole, on session: Int) async { + var answer: Bool? + if role == .dataDefinition, holdsTransaction { + answer = await serverHoldsTransaction() + } + let serverHoldsTransaction = answer + state.withLock { + $0.transaction.statementSucceeded(role, on: session, serverHoldsTransaction: serverHoldsTransaction) + } } /// Called under the query gate, so the answer about the transaction comes from the connection the refused - /// `COMMIT` or `ROLLBACK` ran on. + /// statement ran on. private func recordFailure(of role: OracleTransactionRole) async { - guard role == .endsTransaction, holdsTransaction else { return } + guard role.mayEndTransactionWhenRefused, holdsTransaction else { return } let serverHoldsTransaction = await serverHoldsTransaction() state.withLock { $0.transaction.statementFailed(role, serverHoldsTransaction: serverHoldsTransaction) } } @@ -765,7 +772,7 @@ public final class OracleCoreConnection: @unchecked Sendable { let zones = await sessionZones(on: connection) return try await collectRows(statement, options: admitted.options, zones: zones, on: connection) } - recordSuccess(of: role, on: admitted.session) + await recordSuccess(of: role, on: admitted.session) forgetSessionZonesIfChanged(by: query) await queryGate.release() return result @@ -872,7 +879,7 @@ public final class OracleCoreConnection: @unchecked Sendable { let zones = await sessionZones(on: connection) try await streamRows(query, options: admitted.options, zones: zones, on: connection, continuation: continuation) } - recordSuccess(of: role, on: admitted.session) + await recordSuccess(of: role, on: admitted.session) forgetSessionZonesIfChanged(by: query) await queryGate.release() continuation.finish() diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift index 9c5bb23cff..6961481637 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift @@ -8,7 +8,7 @@ import Foundation /// driver's commit flag and the server commits it in the same round trip. /// /// A transaction opens with ``open()``, which is what the app's own writes call, or with a statement that opens one -/// (``OracleTransactionRole/opensTransaction``). It ends with a `COMMIT` or `ROLLBACK`, whoever sends it. +/// (``OracleTransactionRole/opensTransaction``). It ends with a `COMMIT`, a `ROLLBACK` or DDL, whoever sends it. /// /// The transaction belongs to the connection its first statement ran on. A connection that closes takes the /// transaction with it, so a statement that finds the transaction's connection replaced fails with @@ -31,34 +31,40 @@ struct OracleSessionTransaction: Equatable, Sendable { return false case .opensTransaction, .endsTransaction: guard isOpen else { return false } - case .other: + case .dataDefinition, .other: guard isOpen else { return true } } try bind(to: session) return false } - /// A statement that opens a transaction opens it here only once Oracle has accepted it, and a `COMMIT` or - /// `ROLLBACK` ends it. - mutating func statementSucceeded(_ role: OracleTransactionRole, on session: Int) { + // DDL commits around itself, but DDL on a private temporary table can leave the transaction open: the server decides. + mutating func statementSucceeded( + _ role: OracleTransactionRole, + on session: Int, + serverHoldsTransaction: Bool? = nil + ) { switch role { case .opensTransaction: isOpen = true self.session = session case .endsTransaction: close() + case .dataDefinition: + if serverHoldsTransaction == false { close() } case .query, .other: break } } - /// A `COMMIT` or `ROLLBACK` the server refused ends the transaction only when the server no longer holds one: a - /// malformed one leaves it open, and a commit a deferred constraint rolled back (ORA-02091) ends it. + /// A `COMMIT`, `ROLLBACK` or DDL statement the server refused ends the transaction only when the server no longer + /// holds one: a malformed one leaves it open, a commit a deferred constraint rolled back (ORA-02091) ends it, and + /// so does DDL that failed after the commit Oracle runs before it. /// /// `serverHoldsTransaction` is nil when there was no live connection to ask. The transaction is then left as it /// is, and the next write finds out whether its connection survived. mutating func statementFailed(_ role: OracleTransactionRole, serverHoldsTransaction: Bool?) { - guard role == .endsTransaction, serverHoldsTransaction == false else { return } + guard role.mayEndTransactionWhenRefused, serverHoldsTransaction == false else { return } close() } diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift index b0f322bffc..5860771ab8 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift @@ -15,8 +15,12 @@ enum OracleTransactionRole: Equatable, Sendable { /// `COMMIT`, or a `ROLLBACK` that is not to a savepoint. case endsTransaction - /// Everything else: DML, DDL, PL/SQL, `CALL`, `ROLLBACK TO`, and the `FORCE` forms of `COMMIT` and `ROLLBACK`, - /// which settle an in-doubt distributed transaction rather than the session's own. + // Oracle commits before and after DDL (SQL Reference, COMMIT). ALTER SESSION and ALTER SYSTEM are + // session and system control, which commit nothing, and the schema switch sends ALTER SESSION. + case dataDefinition + + /// Everything else: DML, PL/SQL, `CALL`, `ROLLBACK TO`, session and system control, and the `FORCE` forms of + /// `COMMIT` and `ROLLBACK`, which settle an in-doubt distributed transaction rather than the session's own. case other /// How much of a statement is read for its first words. A dump can hold a statement millions of characters long, @@ -39,11 +43,24 @@ enum OracleTransactionRole: Equatable, Sendable { case "ROLLBACK": let word = Self.skippingWork(&reader) self = word == "TO" || word == "FORCE" ? .other : .endsTransaction + case "CREATE", "DROP", "TRUNCATE", "RENAME", "GRANT", "REVOKE", "COMMENT", "AUDIT", "NOAUDIT", "ANALYZE", + "PURGE", "FLASHBACK": + self = .dataDefinition + case "ALTER": + let word = reader.nextWord() + self = word == "SESSION" || word == "SYSTEM" ? .other : .dataDefinition + case "ASSOCIATE", "DISASSOCIATE": + self = reader.nextWord() == "STATISTICS" ? .dataDefinition : .other default: self = .other } } + // A refused COMMIT, ROLLBACK or DDL statement may still have ended the transaction, so only the server can say. + var mayEndTransactionWhenRefused: Bool { + self == .endsTransaction || self == .dataDefinition + } + private static func skippingWork(_ reader: inout HeaderReader) -> String? { let word = reader.nextWord() guard word == "WORK" else { return word } diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift index c5a325377d..a178506f34 100644 --- a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleSessionTransactionTests.swift @@ -132,6 +132,85 @@ struct OracleSessionTransactionTests { #expect(transaction.isOpen) } + @Test func testDataDefinitionOutsideATransactionCommitsAsItRuns() throws { + var transaction = OracleSessionTransaction() + #expect(try transaction.admit(.dataDefinition, on: 1)) + transaction.statementSucceeded(.dataDefinition, on: 1) + #expect(!transaction.isOpen) + } + + @Test func testDataDefinitionEndsTheTransactionOnceOracleRunsIt() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + #expect(!(try transaction.admit(.dataDefinition, on: 1))) + #expect(transaction.isOpen) + + transaction.statementSucceeded(.dataDefinition, on: 1, serverHoldsTransaction: false) + #expect(!transaction.isOpen) + #expect(try transaction.admit(.other, on: 1)) + } + + @Test func testDataDefinitionTheServerRanInsideTheTransactionKeepsItOpen() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.dataDefinition, on: 1) + + transaction.statementSucceeded(.dataDefinition, on: 1, serverHoldsTransaction: true) + #expect(transaction.isOpen) + #expect(!(try transaction.admit(.other, on: 1))) + } + + @Test func testDataDefinitionWithNoConnectionToAskLeavesTheTransaction() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.dataDefinition, on: 1) + + transaction.statementSucceeded(.dataDefinition, on: 1, serverHoldsTransaction: nil) + #expect(transaction.isOpen) + } + + @Test func testRefusedDataDefinitionEndsTheTransactionWhenTheServerHoldsNone() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.dataDefinition, on: 1) + transaction.statementFailed(.dataDefinition, serverHoldsTransaction: false) + #expect(!transaction.isOpen) + } + + @Test func testRefusedDataDefinitionLeavesTheTransactionOpenWhenTheServerStillHoldsIt() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.dataDefinition, on: 1) + transaction.statementFailed(.dataDefinition, serverHoldsTransaction: true) + #expect(transaction.isOpen) + #expect(!(try transaction.admit(.other, on: 1))) + } + + @Test func testRefusedDataDefinitionWithNoConnectionToAskLeavesTheTransaction() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + _ = try transaction.admit(.dataDefinition, on: 1) + transaction.statementFailed(.dataDefinition, serverHoldsTransaction: nil) + #expect(transaction.isOpen) + } + + @Test func testDataDefinitionOnAReplacedConnectionReportsTheTransactionLost() throws { + var transaction = OracleSessionTransaction() + transaction.open() + _ = try transaction.admit(.other, on: 1) + + if let error = #expect(throws: (any Error).self, performing: { try transaction.admit(.dataDefinition, on: 2) }) { + #expect(error as? OracleCoreError == .transactionLost) + } + #expect(!transaction.isOpen) + } + @Test func testOpeningAnOpenTransactionKeepsItsConnection() throws { var transaction = OracleSessionTransaction() transaction.open() diff --git a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift index e54664037a..e9f07b54bb 100644 --- a/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift +++ b/apple/Packages/TableProOracle/Tests/TableProOracleCoreTests/OracleTransactionRoleTests.swift @@ -64,13 +64,50 @@ struct OracleTransactionRoleTests { #expect(OracleTransactionRole(of: "SELECT \(values) FROM dual") == .query) } + @Test func testDataDefinitionIsReadAsTheCommitOracleRunsAroundIt() { + for sql in [ + "CREATE TABLE t (a NUMBER)", + "create or replace view v AS SELECT 1 a FROM dual", + "-- keep\nCREATE INDEX t_a ON t (a)", + "ALTER TABLE t ADD (b NUMBER)", + "alter index t_a rebuild", + "ALTER USER hr IDENTIFIED BY secret", + "DROP TABLE t PURGE", + "TRUNCATE TABLE logs", + "RENAME t TO t2", + "GRANT SELECT ON emp TO hr", + "REVOKE SELECT ON emp FROM hr", + "COMMENT ON TABLE emp IS 'staff'", + "AUDIT SELECT ON emp", + "NOAUDIT SELECT ON emp", + "ANALYZE TABLE emp COMPUTE STATISTICS", + "ASSOCIATE STATISTICS WITH PACKAGES emp_stats", + "DISASSOCIATE STATISTICS FROM PACKAGES emp_stats", + "PURGE RECYCLEBIN", + "FLASHBACK TABLE emp TO BEFORE DROP", + ] { + #expect(OracleTransactionRole(of: sql) == .dataDefinition, "\(sql)") + } + } + + @Test func testSessionAndSystemControlCommitNothing() { + for sql in [ + "ALTER SESSION SET CURRENT_SCHEMA = \"HR\"", + "alter session set nls_date_format = 'YYYY-MM-DD'", + "/* switch */ ALTER SESSION SET CURRENT_SCHEMA = hr", + "ALTER SYSTEM FLUSH SHARED_POOL", + "SET ROLE ALL", + ] { + #expect(OracleTransactionRole(of: sql) == .other, "\(sql)") + } + } + @Test func testEverythingElseIsOrdinaryWork() { for sql in [ "INSERT INTO emp VALUES (1)", "UPDATE emp SET sal = sal * 2", "DELETE FROM emp", "MERGE INTO emp USING dual ON (1 = 1) WHEN MATCHED THEN UPDATE SET sal = 1", - "CREATE TABLE t (a NUMBER)", "BEGIN NULL; END;", "DECLARE v NUMBER; BEGIN v := 1; END;", "CALL p()", diff --git a/apple/TableProMobile/TableProMobile/AppState.swift b/apple/TableProMobile/TableProMobile/AppState.swift index 70deca10ba..084bb272ba 100644 --- a/apple/TableProMobile/TableProMobile/AppState.swift +++ b/apple/TableProMobile/TableProMobile/AppState.swift @@ -30,6 +30,8 @@ final class AppState { private(set) var sampleResetRevision = 0 let onboarding: OnboardingPreferences let connectionManager: ConnectionManager + let sessionTransactions: SessionTransactionMonitor + let sessionRedialer: SessionRedialer let backgroundRelease: BackgroundReleaseCoordinator let queryActivities = QueryActivityController() let syncCoordinator: IOSSyncCoordinator @@ -56,7 +58,8 @@ final class AppState { sampleInstaller: SampleDatabaseInstaller = .live, localDatabaseFiles: LocalDatabaseFileLocator = .live, bookmarkStore: FileBookmarkStore = FileBookmarkStore(), - libraryPublisher: ConnectionLibraryPublisher? = nil + libraryPublisher: ConnectionLibraryPublisher? = nil, + driverFactory injectedDriverFactory: (any DriverFactory)? = nil ) { self.sampleInstaller = sampleInstaller self.libraryPublisher = libraryPublisher ?? .live() @@ -69,7 +72,8 @@ final class AppState { queryHistory = QueryHistoryStorage(directory: libraryDirectory) groupStorage = GroupPersistence(directory: libraryDirectory) tagStorage = TagPersistence(directory: libraryDirectory) - let driverFactory = IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localDatabaseFiles) + let driverFactory: any DriverFactory = injectedDriverFactory + ?? IOSDriverFactory(bookmarkStore: bookmarkStore, localFiles: localDatabaseFiles) self.secureStore = secureStore let sshProvider = IOSSSHProvider(secureStore: secureStore, container: localDatabaseFiles.container) self.sshProvider = sshProvider @@ -79,6 +83,16 @@ final class AppState { sshProvider: sshProvider ) self.connectionManager = connectionManager + let sessionTransactions = SessionTransactionMonitor { [connectionManager] id in + connectionManager.session(for: id)?.driver + } + self.sessionTransactions = sessionTransactions + connectionManager.observeSessionEnds { [weak sessionTransactions] id, driver in + Task { @MainActor in + sessionTransactions?.sessionEnded(id, driver: driver) + } + } + self.sessionRedialer = SessionRedialer(connectionManager: connectionManager) self.backgroundRelease = BackgroundReleaseCoordinator(connectionManager: connectionManager) loadPersistedData() diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index e306a1f1d4..681f52a0d7 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -21,6 +21,9 @@ final class ConnectionCoordinator { private(set) var isSwitching = false private(set) var isReconnecting = false + @ObservationIgnored private var sessionCheck: Task? + @ObservationIgnored private var statementFailedDuringCheck = false + @ObservationIgnored private var metadataRefused = false var failureAlertMessage: String? var showFailureAlert = false @@ -172,11 +175,14 @@ final class ConnectionCoordinator { tables = existingTables await loadDatabases() await loadSchemas() + await refreshTransaction() guard attemptToken == token else { return } phase = .connected appState.libraryPreferences.recordConnected(connection.id) return } catch { + guard attemptToken == token else { return } + if await keepsRefusingSession(existing, token: token, refusal: error) { return } guard attemptToken == token else { return } session = nil await appState.connectionManager.disconnect(connection.id) @@ -200,6 +206,7 @@ final class ConnectionCoordinator { tables = newTables await loadDatabases() await loadSchemas() + await refreshTransaction() guard attemptToken == token else { return } phase = .connected appState.libraryPreferences.recordConnected(connection.id) @@ -228,23 +235,70 @@ final class ConnectionCoordinator { } func reconnectIfNeeded() async { - guard let session, !isSwitching, !isReconnecting, connectTask == nil else { return } - do { - _ = try await session.driver.ping() - return - } catch { - // Ping failed; fall through to actual reconnect path below. + await restoreSession(after: .whileIdle) + } + + enum SessionLoss { + // Nothing was in flight, so the server rolled the transaction back. + case whileIdle + // A statement was in flight, and it may have committed the transaction before the reply was lost. + case duringWork + // The transaction is gone either way, which is what was asked for. + case duringRollBack + } + + // True when the redial dropped a held transaction and the outcome is already settled for the user. + @discardableResult + private func restoreSession(after loss: SessionLoss) async -> Bool { + // One check at a time, and a statement that failed while it ran makes the loss ambiguous. + if let sessionCheck { + if loss == .duringWork { statementFailedDuringCheck = true } + return await sessionCheck.value + } + statementFailedDuringCheck = false + let check = Task { + let settled = await checkSession(after: loss) + sessionCheck = nil + return settled } + sessionCheck = check + return await check.value + } + private func checkSession(after loss: SessionLoss) async -> Bool { + guard let session, !isSwitching, !isReconnecting, connectTask == nil else { return false } let token = attemptToken + var workWasRunning = appState.sessionTransactions.isBusy(connection.id) + + if await takesNewerSession(than: session, token: token) { return false } + if appState.connectionManager.session(for: connection.id) != nil, await Self.answersPing(session.driver) { + return false + } + guard attemptToken == token else { return false } + // Another window can finish a whole redial while this ping is out. + if await takesNewerSession(than: session, token: token) { return false } + + workWasRunning = workWasRunning || appState.sessionTransactions.isBusy(connection.id) + let heldTransaction = transactionHold != nil isReconnecting = true defer { isReconnecting = false } do { - let newSession = try await appState.connectionManager.connect(connection, prompter: prompts) - guard attemptToken == token else { return } - self.session = newSession + let outcome = try await appState.sessionRedialer.redial( + dialTarget, + schema: supportsSchemas ? activeSchema : nil, + prompter: prompts + ) + guard attemptToken == token else { return false } + await takeSession(outcome.session, token: token, dialedHere: outcome.dialedHere) + guard attemptToken == token, heldTransaction else { return false } + let settledLoss: SessionLoss = workWasRunning || statementFailedDuringCheck ? .duringWork : loss + if let message = Self.lostTransactionMessage(settledLoss, connectionName: displayName) { + failureAlertMessage = message + showFailureAlert = true + } + return true } catch { - guard attemptToken == token else { return } + guard attemptToken == token else { return false } let context = ErrorContext( operation: "reconnect", databaseType: connection.type, @@ -253,7 +307,198 @@ final class ConnectionCoordinator { ) phase = .error(ErrorClassifier.classify(error, context: context)) self.session = nil + return false + } + } + + private func takesNewerSession(than held: ConnectionSession, token: UUID) async -> Bool { + // A redial another window started is not ready to share until its schema is back. + await appState.sessionRedialer.settled(connection.id) + guard attemptToken == token, + let live = appState.connectionManager.session(for: connection.id), + live.driver !== held.driver else { + return false + } + await takeSession(live, token: token, dialedHere: false) + return true + } + + private var dialTarget: DatabaseConnection { + guard supportsDatabaseSwitching, !activeDatabase.isEmpty else { return connection } + var target = connection + target.database = activeDatabase + return target + } + + // A session another window dialed sits on that window's database and schema, so this one + // follows it before any write can name a table from the old list. + private func takeSession(_ newSession: ConnectionSession, token: UUID, dialedHere: Bool) async { + let driver = newSession.driver + let fetched: [TableInfo]? + do { + fetched = try await driver.fetchTables(schema: nil) + } catch { + Self.logger.warning("Failed to load tables on the new session: \(error.localizedDescription, privacy: .public)") + fetched = nil + } + guard attemptToken == token else { return } + var movedPlacement = false + if !dialedHere { + let database = sessionDatabase(of: newSession) + if supportsDatabaseSwitching, !database.isEmpty, database != activeDatabase { + activeDatabase = database + movedPlacement = true + } + } + // The dial restored this window's schema, or another window's; a failed restore leaves the default. + if supportsSchemas, let schema = driver.currentSchema, schema != activeSchema { + activeSchema = schema + movedPlacement = true + } + if let fetched { + tables = fetched + } else { + metadataRefused = true + if movedPlacement { tables = [] } + } + session = newSession + await refreshTransaction() + } + + // A transaction the server aborted refuses catalog reads, yet the session still holds the user's work. + private func keepsRefusingSession(_ existing: ConnectionSession, token: UUID, refusal: any Error) async -> Bool { + guard await Self.answersPing(existing.driver), attemptToken == token else { return false } + await refreshTransaction() + guard attemptToken == token, + appState.sessionTransactions.hold(for: connection.id, on: existing.driver) != nil else { return false } + Self.logger.info("Kept a session that refused its catalog: \(refusal.localizedDescription, privacy: .public)") + let database = sessionDatabase(of: existing) + if supportsDatabaseSwitching, !database.isEmpty { + activeDatabase = database + } + metadataRefused = true + session = existing + phase = .connected + appState.libraryPreferences.recordConnected(connection.id) + return true + } + + private func sessionDatabase(of taken: ConnectionSession) -> String { + guard let live = appState.connectionManager.session(for: connection.id), live.driver === taken.driver else { + return taken.activeDatabase + } + return live.activeDatabase + } + + static func lostTransactionMessage(_ loss: SessionLoss, connectionName: String) -> String? { + switch loss { + case .whileIdle: + String( + format: String( + localized: "The connection to %@ was lost while it waited, and the server rolled back its open transaction. It has been reconnected; run the statements again." + ), + connectionName + ) + case .duringWork: + String( + format: String( + localized: "The connection to %@ was lost before the server answered, so its open transaction may have been committed or rolled back. It has been reconnected; check which of its changes were saved before running them again." + ), + connectionName + ) + case .duringRollBack: + nil + } + } + + private static func answersPing(_ driver: any DatabaseDriver) async -> Bool { + do { + _ = try await driver.ping() + return true + } catch { + return false + } + } + + // MARK: - Transactions + + var transactionHold: SessionTransactionMonitor.Hold? { + appState.sessionTransactions.hold(for: connection.id, on: session?.driver) + } + + var transactionToken: SessionTransactionMonitor.HoldToken? { + appState.sessionTransactions.token(for: connection.id, on: session?.driver) + } + + func track(failed: (T) -> Bool = { _ in false }, _ work: () async throws -> T) async rethrows -> T { + do { + let value = try await appState.sessionTransactions.track(connection.id, work) + if failed(value) { + await restoreSession(after: .duringWork) + } + await reloadMetadataIfReleased() + return value + } catch { + await restoreSession(after: .duringWork) + throw error + } + } + + // The token is what the indicator showed when the user asked, so a swapped session or newer work refuses. + func commitTransaction(as token: SessionTransactionMonitor.HoldToken) async throws { + do { + try await appState.sessionTransactions.commit(connection.id, as: token) + } catch { + guard await !restoreSession(after: .duringWork) else { return } + throw error + } + await reloadMetadataIfReleased() + } + + func rollBackTransaction(as token: SessionTransactionMonitor.HoldToken) async throws { + do { + try await appState.sessionTransactions.rollBack(connection.id, as: token) + } catch { + guard await !restoreSession(after: .duringRollBack) else { return } + throw error + } + await reloadMetadataIfReleased() + } + + func refreshTransaction() async { + await appState.sessionTransactions.refresh(connection.id) + } + + // A Roll Back from any window can undo DDL; a failed read keeps the list it has. + func reloadAfterRollBack() async { + guard !metadataRefused else { + await reloadMetadataIfReleased() + return + } + guard let session else { return } + do { + let reloaded = try await session.driver.fetchTables(schema: supportsSchemas ? activeSchema : nil) + guard self.session?.driver === session.driver else { return } + tables = reloaded + } catch { + Self.logger.warning("Failed to reload tables after a Roll Back: \(error.localizedDescription, privacy: .public)") + } + } + + private func reloadMetadataIfReleased() async { + guard metadataRefused, let session, + appState.sessionTransactions.hold(for: connection.id, on: session.driver) == nil else { return } + do { + let reloaded = try await session.driver.fetchTables(schema: nil) + guard self.session?.driver === session.driver else { return } + tables = reloaded + metadataRefused = false + } catch { + Self.logger.warning("Tables are still refused: \(error.localizedDescription, privacy: .public)") + return } + await loadDatabases() + await loadSchemas() } // MARK: - Database / Schema Switching @@ -281,6 +526,7 @@ final class ConnectionCoordinator { showFailureAlert = true } } + await refreshTransaction() } private func reconnectWithDatabase(_ database: String) async { @@ -349,6 +595,8 @@ final class ConnectionCoordinator { failureAlertMessage = String(localized: "Failed to refresh tables") showFailureAlert = true } + // With autocommit off, a MySQL read opens a transaction. + await refreshTransaction() } // MARK: - Query History diff --git a/apple/TableProMobile/TableProMobile/Coordinators/SessionRedialer.swift b/apple/TableProMobile/TableProMobile/Coordinators/SessionRedialer.swift new file mode 100644 index 0000000000..8c939e84ff --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Coordinators/SessionRedialer.swift @@ -0,0 +1,58 @@ +import Foundation +import os +import TableProDatabase +import TableProModels + +// Each scene has its own coordinator on the one shared session, so two windows coming back at once +// would each dial, and the later attempt cancels the earlier one's. +@MainActor +final class SessionRedialer { + struct Outcome { + let session: ConnectionSession + let dialedHere: Bool + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "SessionRedialer") + + private let connectionManager: ConnectionManager + private var dials: [UUID: Task] = [:] + + init(connectionManager: ConnectionManager) { + self.connectionManager = connectionManager + } + + // The schema goes back inside the dial, so a window that joins or adopts it never reads the default schema. + func redial( + _ connection: DatabaseConnection, + schema: String?, + prompter: (any ConnectionPrompter)? + ) async throws -> Outcome { + if let inFlight = dials[connection.id] { + return Outcome(session: try await inFlight.value, dialedHere: false) + } + let manager = connectionManager + let dial = Task { + let session = try await manager.connect(connection, prompter: prompter) + await Self.restore(schema, on: session.driver) + return session + } + dials[connection.id] = dial + defer { + if dials[connection.id] == dial { dials[connection.id] = nil } + } + return Outcome(session: try await dial.value, dialedHere: true) + } + + func settled(_ connectionId: UUID) async { + _ = try? await dials[connectionId]?.value + } + + private static func restore(_ schema: String?, on driver: any DatabaseDriver) async { + guard let schema, let current = driver.currentSchema, current != schema else { return } + do { + try await driver.switchSchema(to: schema) + } catch { + logger.warning("Failed to restore schema \(schema, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift index 65e0e3081e..46c12c2605 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver.swift @@ -47,8 +47,9 @@ nonisolated final class DuckDBDriver: DatabaseDriver, @unchecked Sendable { fileAccess.end() } + // In-process, so an open handle is a live session; an aborted transaction refuses every statement until ROLLBACK. func ping() async throws -> Bool { - _ = try await actor.query("SELECT 1") + guard await actor.isOpen else { throw DuckDBDriverError.notConnected } return true } @@ -206,6 +207,8 @@ actor DuckDBActor { if let conn { interruptHandle.attach(conn) } } + var isOpen: Bool { database != nil && connection != nil } + func close() { interruptHandle.detach() closeStreamResult() diff --git a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift index d3f99338f9..8812d63829 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MySQLDriver.swift @@ -359,7 +359,12 @@ nonisolated final class MySQLDriver: DatabaseDriver, @unchecked Sendable { } func sessionTransactionState() async -> DriverTransactionState { - await actor.transactionState() + guard Self.reportsSessionStatusFlags(for: databaseType, banner: serverVersion) else { return .unknown } + return await actor.transactionState() + } + + static func reportsSessionStatusFlags(for databaseType: DatabaseType, banner: String?) -> Bool { + serverFlavor(for: databaseType, banner: banner).reportsSessionStatusFlags } /// Databend reports no `NO_BACKSLASH_ESCAPES` flag, so its session says nothing. diff --git a/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift index 4dc014aa63..a82a6028ae 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/PostgreSQLDriver.swift @@ -88,7 +88,7 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { } func ping() async throws -> Bool { - _ = try await actor.execute("SELECT 1") + try await actor.ping() return true } @@ -133,10 +133,13 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { let task = Task { do { switch try await actor.beginStream(query: query, id: id, readsOnly: readsOnly) { - case .commandOk(let affectedRows): + case .commandOk(let affectedRows, let statusMessage): if affectedRows != 0 { continuation.yield(.rowsAffected(affectedRows)) } + if let statusMessage { + continuation.yield(.statusMessage(statusMessage)) + } case .tuples(let columns): continuation.yield(.columns(columns)) var emitted = 0 @@ -258,16 +261,42 @@ nonisolated final class PostgreSQLDriver: DatabaseDriver, @unchecked Sendable { } nonisolated enum PostgreSQLSessionTransaction { - static func state(from status: PGTransactionStatusType) -> DriverTransactionState { + static func state( + from status: PGTransactionStatusType, + statusAtSend: PGTransactionStatusType? = nil + ) -> DriverTransactionState { switch status { case PQTRANS_IDLE: return .idle - case PQTRANS_INTRANS, PQTRANS_INERROR, PQTRANS_ACTIVE: + case PQTRANS_INTRANS: return .explicitTransaction + case PQTRANS_INERROR: + return .abortedTransaction + // A statement still in flight says nothing about the transaction around it; the status before it went out does. + case PQTRANS_ACTIVE: + guard let statusAtSend, statusAtSend != PQTRANS_ACTIVE else { return .unknown } + return state(from: statusAtSend) default: return .unknown } } + + // An aborted block refuses SELECT 1 on a live session, an open one takes its snapshot from it, and a stream would end. + static func pingSendsStatement(in status: PGTransactionStatusType) -> Bool { + status == PQTRANS_IDLE + } + + // A COMMIT in an aborted block answers the tag ROLLBACK with no error, so it would read as a success. + static func statusMessage( + statusAtSend: PGTransactionStatusType, + commandTag: String, + statusAfter: PGTransactionStatusType + ) -> String? { + guard statusAtSend == PQTRANS_INERROR, commandTag == "ROLLBACK", statusAfter == PQTRANS_IDLE else { + return nil + } + return String(localized: "The failed transaction was rolled back. None of its changes were kept.") + } } // MARK: - PostgreSQL Actor (thread-safe C API access) @@ -373,7 +402,31 @@ private actor PostgreSQLActor { func transactionState() -> DriverTransactionState { guard let conn else { return .unknown } - return PostgreSQLSessionTransaction.state(from: PQtransactionStatus(conn)) + return PostgreSQLSessionTransaction.state( + from: PQtransactionStatus(conn), + statusAtSend: stream?.transactionStatusAtSend + ) + } + + func ping() throws { + guard let conn else { throw PostgreSQLError.notConnected } + guard PostgreSQLSessionTransaction.pingSendsStatement(in: PQtransactionStatus(conn)) else { + try checkSocket(conn) + return + } + _ = try execute("SELECT 1") + } + + // Measured on the Mac driver: one read leaves a closed session CONNECTION_OK, the second turns it CONNECTION_BAD. + private func checkSocket(_ conn: OpaquePointer) throws { + if PQstatus(conn) == CONNECTION_OK { + _ = PQconsumeInput(conn) + _ = PQisBusy(conn) + _ = PQconsumeInput(conn) + } + guard PQstatus(conn) == CONNECTION_OK else { + throw PostgreSQLError.connectionFailed(Self.message(from: conn)) + } } /// libpq keeps the value the server last reported in a `ParameterStatus`, so a `SET` the user ran is already in it. @@ -494,7 +547,8 @@ private actor PostgreSQLActor { let state = PostgreSQLStreamState( id: id, cancelsAbandonedOutput: cancelsOutput, - cancelsUnreadRows: cancelsOutput && readsOnly + cancelsUnreadRows: cancelsOutput && readsOnly, + transactionStatusAtSend: PQtransactionStatus(conn) ) try send(query, id: id, on: conn) guard PQsetSingleRowMode(conn) == 1 else { @@ -503,7 +557,7 @@ private actor PostgreSQLActor { } guard let firstResult = PQgetResult(conn) else { markDrained(state) - return .commandOk(affectedRows: 0) + return .commandOk(affectedRows: 0, statusMessage: nil) } switch PQresultStatus(firstResult) { @@ -519,13 +573,21 @@ private actor PostgreSQLActor { return .tuples(columns) case PGRES_COMMAND_OK: let affected = Int(String(cString: PQcmdTuples(firstResult))) ?? 0 + let commandTag = String(cString: PQcmdStatus(firstResult)) PQclear(firstResult) if let error = drain(state).error { throw error } - return .commandOk(affectedRows: affected) + return .commandOk( + affectedRows: affected, + statusMessage: PostgreSQLSessionTransaction.statusMessage( + statusAtSend: state.transactionStatusAtSend, + commandTag: commandTag, + statusAfter: self.conn.map { PQtransactionStatus($0) } ?? PQTRANS_UNKNOWN + ) + ) default: state.pendingResult = firstResult if let error = drain(state).error { throw error } - return .commandOk(affectedRows: 0) + return .commandOk(affectedRows: 0, statusMessage: nil) } } @@ -746,7 +808,7 @@ private actor PostgreSQLActor { nonisolated enum PGBeginStreamResult: Sendable { case tuples([ColumnInfo]) - case commandOk(affectedRows: Int) + case commandOk(affectedRows: Int, statusMessage: String?) } /// Confined to `PostgreSQLActor`, which owns the connection the stream reads. @@ -757,14 +819,21 @@ nonisolated private final class PostgreSQLStreamState { /// A cancel rolls back the whole statement, so rows nobody will read are cut short only for a read: an /// `UPDATE ... RETURNING` past the row cap has to finish, or its write is undone. let cancelsUnreadRows: Bool + let transactionStatusAtSend: PGTransactionStatusType /// A result read before its turn: the first row, or what the row-cap check took off the wire. var pendingResult: OpaquePointer? var isDrained = false - init(id: UInt64, cancelsAbandonedOutput: Bool, cancelsUnreadRows: Bool) { + init( + id: UInt64, + cancelsAbandonedOutput: Bool, + cancelsUnreadRows: Bool, + transactionStatusAtSend: PGTransactionStatusType + ) { self.id = id self.cancelsAbandonedOutput = cancelsAbandonedOutput self.cancelsUnreadRows = cancelsUnreadRows + self.transactionStatusAtSend = transactionStatusAtSend } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift index 6c19e061dd..c4547b3b89 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionListRowModel.swift @@ -28,12 +28,14 @@ nonisolated struct ConnectionListRowModel: Hashable, Sendable { let groupLabel: ConnectionListGroupLabel? let isFavorite: Bool let showsFavoriteMark: Bool + let holdsTransaction: Bool init( connection: DatabaseConnection, section: LibrarySectionKind, tags allTags: [ConnectionTag], - groups: [ConnectionGroup] + groups: [ConnectionGroup], + holdsTransaction: Bool = false ) { id = connection.id title = connection.name.isEmpty ? connection.host : connection.name @@ -43,6 +45,7 @@ nonisolated struct ConnectionListRowModel: Hashable, Sendable { color = connection.color isFavorite = connection.isFavorite showsFavoriteMark = connection.isFavorite && section == .connections + self.holdsTransaction = holdsTransaction let tagsById = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let resolved = connection.tagIds.compactMap { tagsById[$0] } @@ -68,6 +71,9 @@ nonisolated struct ConnectionListRowModel: Hashable, Sendable { if isFavorite { parts.append(String(localized: "Favorite")) } + if holdsTransaction { + parts.append(String(localized: "Transaction open")) + } return parts.joined(separator: ", ") } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift b/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift index bba4154fad..70a972e605 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift @@ -68,6 +68,17 @@ nonisolated enum RowInserter { ) guard !statements.isEmpty else { throw IntentDataError.noInsertableValues(table) } - return try await driver.executeWrite(statements) + // The write joins an autocommit-off transaction, and this session closes right after, which would roll it back. + guard await driver.sessionTransactionState() == .implicitTransaction else { + return try await driver.executeWrite(statements) + } + do { + let affected = try await driver.executeWrite(statements) + try await driver.commitTransaction() + return affected + } catch { + try? await driver.rollbackTransaction() + throw error + } } } diff --git a/apple/TableProMobile/TableProMobile/Localizable.xcstrings b/apple/TableProMobile/TableProMobile/Localizable.xcstrings index ca1c8643d8..3df1d6ae32 100644 --- a/apple/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/apple/TableProMobile/TableProMobile/Localizable.xcstrings @@ -3753,6 +3753,12 @@ "Command" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Commit" : { + + }, + "Commit the Transaction?" : { + }, "Computed by the database" : { "localizations" : { @@ -9428,6 +9434,9 @@ } } } + }, + "Its changes are saved to the database." : { + }, "Its subgroups are deleted too. Their connections move to Ungrouped." : { "localizations" : { @@ -9456,6 +9465,9 @@ } } } + }, + "Its uncommitted changes are discarded." : { + }, "JSON" : { "extractionState" : "manual", @@ -13091,6 +13103,9 @@ "Pin" : { "extractionState" : "manual", "generatesSymbol" : false + }, + "Other work ran on this connection before you confirmed. Check the transaction and try again." : { + }, "PING failed: %@" : { "extractionState" : "manual", @@ -14673,6 +14688,15 @@ } } } + }, + "Roll Back" : { + + }, + "Roll Back the Transaction?" : { + + }, + "Roll it back to continue." : { + }, "Rosette" : { "extractionState" : "manual", @@ -16914,6 +16938,12 @@ } } } + }, + "Switch" : { + + }, + "Switch Database?" : { + }, "Switching..." : { "extractionState" : "stale", @@ -18395,6 +18425,12 @@ } } } + }, + "The connection to %@ was lost before the server answered, so its open transaction may have been committed or rolled back. It has been reconnected; check which of its changes were saved before running them again." : { + + }, + "The connection to %@ was lost while it waited, and the server rolled back its open transaction. It has been reconnected; run the statements again." : { + }, "The connection was lost while a transaction was open. Check which of its changes were saved before running them again." : { "extractionState" : "manual", @@ -18514,6 +18550,9 @@ }, "The database name is longer than the 128 bytes FreeTDS takes." : { + }, + "The database “%@” has an open transaction. Switching rolls it back and discards its uncommitted changes." : { + }, "The encrypted file is corrupt or incomplete" : { "extractionState" : "manual", @@ -18544,6 +18583,9 @@ } } } + }, + "The failed transaction was rolled back. None of its changes were kept." : { + }, "The FreeTDS configuration could not be written: %@" : { "extractionState" : "manual", @@ -19336,6 +19378,9 @@ } } } + }, + "The session that held this transaction has ended." : { + }, "The server's SSH host key has changed. Open this connection in TablePro to check it." : { "localizations" : { @@ -19523,6 +19568,9 @@ } } } + }, + "The transaction on this connection can no longer be committed. Roll it back." : { + }, "The transaction on this connection is still open. Commit or roll it back." : { @@ -20747,6 +20795,27 @@ } } } + }, + "Transaction Failed" : { + + }, + "Transaction Open" : { + + }, + "Transaction failed on %1$@, %2$@. Roll it back to continue." : { + + }, + "Transaction failed on %@. Roll it back to continue." : { + + }, + "Transaction open" : { + + }, + "Transaction open on %1$@, %2$@" : { + + }, + "Transaction open on %@" : { + }, "Transfer" : { "extractionState" : "manual", diff --git a/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift b/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift new file mode 100644 index 0000000000..23d2d83c32 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift @@ -0,0 +1,167 @@ +import Foundation +import Observation +import os +import TableProDatabase + +@MainActor @Observable +final class SessionTransactionMonitor { + nonisolated enum Hold: Sendable, Equatable { + case open + case aborted + } + + // What a confirmation was opened for: a session, and the work it had seen. Work since then makes it stale. + struct HoldToken { + let driver: any DatabaseDriver + let workGeneration: Int + } + + private struct Entry { + let driver: any DatabaseDriver + var hold: Hold + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "SessionTransactionMonitor") + + private var entries: [UUID: Entry] = [:] + private var workCounts: [UUID: Int] = [:] + private var rollBackCounts: [UUID: Int] = [:] + @ObservationIgnored private var refreshGenerations: [UUID: Int] = [:] + @ObservationIgnored private var workGenerations: [UUID: Int] = [:] + @ObservationIgnored private let liveDriver: (UUID) -> (any DatabaseDriver)? + + init(liveDriver: @escaping (UUID) -> (any DatabaseDriver)?) { + self.liveDriver = liveDriver + } + + func hold(for connectionId: UUID) -> Hold? { + entries[connectionId]?.hold + } + + // A window still on a session another window replaced must not show, or act on, the newer session's transaction. + func hold(for connectionId: UUID, on driver: (any DatabaseDriver)?) -> Hold? { + guard let driver, let entry = entries[connectionId], entry.driver === driver else { return nil } + return entry.hold + } + + func token(for connectionId: UUID, on driver: (any DatabaseDriver)?) -> HoldToken? { + guard let driver, hold(for: connectionId, on: driver) != nil else { return nil } + return HoldToken(driver: driver, workGeneration: workGenerations[connectionId, default: 0]) + } + + func rollBackCount(for connectionId: UUID) -> Int { + rollBackCounts[connectionId, default: 0] + } + + func isBusy(_ connectionId: UUID) -> Bool { + workCounts[connectionId, default: 0] > 0 + } + + func track(_ connectionId: UUID, _ work: () async throws -> T) async rethrows -> T { + workCounts[connectionId, default: 0] += 1 + workGenerations[connectionId, default: 0] += 1 + do { + let value = try await work() + await refresh(connectionId) + endWork(connectionId) + return value + } catch { + await refresh(connectionId) + endWork(connectionId) + throw error + } + } + + func refresh(_ connectionId: UUID) async { + guard let driver = liveDriver(connectionId) else { + entries[connectionId] = nil + return + } + let generation = refreshGenerations[connectionId, default: 0] + 1 + refreshGenerations[connectionId] = generation + let state = await driver.sessionTransactionState() + // A newer read, or a session that replaced this driver while it answered, owns the entry now. + guard refreshGenerations[connectionId] == generation, liveDriver(connectionId) === driver else { return } + apply(state, from: driver, to: connectionId) + } + + func commit(_ connectionId: UUID, as token: HoldToken) async throws { + let driver = try actionDriver(for: connectionId, token: token) + try await track(connectionId) { + if await driver.sessionTransactionState() == .abortedTransaction { + if liveDriver(connectionId) === driver { + entries[connectionId] = Entry(driver: driver, hold: .aborted) + } + throw TransactionActionError.cannotCommit + } + try await driver.commitTransaction() + } + } + + func rollBack(_ connectionId: UUID, as token: HoldToken) async throws { + let driver = try actionDriver(for: connectionId, token: token) + try await track(connectionId) { + try await driver.rollbackTransaction() + rollBackCounts[connectionId, default: 0] += 1 + } + } + + func sessionEnded(_ connectionId: UUID, driver: any DatabaseDriver) { + guard let entry = entries[connectionId], entry.driver === driver else { return } + entries[connectionId] = nil + } + + private func actionDriver(for connectionId: UUID, token: HoldToken) throws -> any DatabaseDriver { + let live = liveDriver(connectionId) + guard let live, live === token.driver, let entry = entries[connectionId], entry.driver === live else { + if let entry = entries[connectionId], entry.driver !== live { + entries[connectionId] = nil + } + Self.logger.info("Refused a transaction action: the session that held it is gone") + throw TransactionActionError.sessionReplaced + } + guard workGenerations[connectionId, default: 0] == token.workGeneration else { + Self.logger.info("Refused a transaction action: other work ran on the session before it was confirmed") + throw TransactionActionError.transactionChanged + } + return live + } + + private func apply(_ state: DriverTransactionState, from driver: any DatabaseDriver, to connectionId: UUID) { + switch state { + case .explicitTransaction, .implicitTransaction: + entries[connectionId] = Entry(driver: driver, hold: .open) + case .abortedTransaction: + entries[connectionId] = Entry(driver: driver, hold: .aborted) + case .idle: + entries[connectionId] = nil + case .unknown: + if let entry = entries[connectionId], entry.driver !== driver { + entries[connectionId] = nil + } + } + } + + private func endWork(_ connectionId: UUID) { + workGenerations[connectionId, default: 0] += 1 + let remaining = workCounts[connectionId, default: 1] - 1 + workCounts[connectionId] = remaining > 0 ? remaining : nil + } +} + +nonisolated enum TransactionActionError: Error, LocalizedError, Equatable { + case cannotCommit + case sessionReplaced + case transactionChanged + + var errorDescription: String? { + switch self { + case .cannotCommit: + String(localized: "The transaction on this connection can no longer be committed. Roll it back.") + case .sessionReplaced: + String(localized: "The session that held this transaction has ended.") + case .transactionChanged: + String(localized: "Other work ran on this connection before you confirmed. Check the transaction and try again.") + } + } +} diff --git a/apple/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift index 9f0a29f6e0..8ef5a317b3 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/QueryEditorViewModel.swift @@ -21,6 +21,7 @@ final class QueryEditorViewModel { case nothing case applied(count: Int) case openTransaction + case abortedTransaction /// The session was in a transaction at some point, or cannot say, so whether earlier work stands is not known. case unknown } @@ -173,6 +174,9 @@ final class QueryEditorViewModel { switch (interruption.standingWork, interruption.kind) { case (.openTransaction, _): return String(localized: "The transaction on this connection is still open. Commit or roll it back.") + // A COMMIT in an aborted PostgreSQL block rolls back without an error, so committing is not offered. + case (.abortedTransaction, _): + return String(localized: "The transaction on this connection can no longer be committed. Roll it back.") case (.unknown, _): return nil case (.nothing, _): @@ -428,6 +432,7 @@ final class QueryEditorViewModel { observedStates: [DriverTransactionState], now: DriverTransactionState ) -> StandingWork { + if now == .abortedTransaction { return .abortedTransaction } if now == .explicitTransaction || now == .implicitTransaction { return .openTransaction } guard now == .idle, observedStates.allSatisfy({ $0 == .idle }) else { return .unknown } return position > 1 ? .applied(count: position - 1) : .nothing diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift index 565fa0870b..7c076afe5f 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -8,12 +8,14 @@ struct ConnectedView: View { @Environment(ScenePresenter.self) private var presenter @Environment(\.scenePhase) private var scenePhase @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) private var horizontalSizeClass let connection: DatabaseConnection @State private var coordinator: ConnectionCoordinator? @State private var hapticSuccess = false @State private var hapticError = false @State private var showDeletedAlert = false + @State private var databaseSwitchAwaitingRollBack: String? private var displayTitle: String { connection.name.isEmpty ? connection.host : connection.name @@ -161,18 +163,22 @@ struct ConnectedView: View { tabChrome(coordinator) { TableListView(connectionId: connection.id) } + // Side by side, the detail column already shows the bar. + .transactionBar(for: coordinator, isEnabled: horizontalSizeClass == .compact) } detail: { NavigationStack { if let table = coordinator.selectedTable { DataBrowserView(table: table) .environment(coordinator) .id(table) + .transactionBar(for: coordinator) } else { ContentUnavailableView( "No Table Selected", systemImage: "tablecells", description: Text("Pick a table to browse its rows.") ) + .transactionBar(for: coordinator) } } } @@ -181,20 +187,29 @@ struct ConnectedView: View { Tab("Query", systemImage: "terminal", value: .query) { NavigationStack { tabChrome(coordinator) { QueryEditorView() } + .transactionBar(for: coordinator) } } Tab("History", systemImage: "clock", value: .history) { NavigationStack { tabChrome(coordinator) { QueryHistoryView() } + .transactionBar(for: coordinator) } } Tab("Info", systemImage: "info.circle", value: .info) { NavigationStack { tabChrome(coordinator) { ConnectionInfoView() } + .transactionBar(for: coordinator) } } } .tabViewStyle(.sidebarAdaptable) + .transactionAccessory(for: coordinator) + .onChange(of: appState.sessionTransactions.rollBackCount(for: connection.id)) { + Task { await coordinator.reloadAfterRollBack() } + } + // tabChrome sits inside each stack; this also reaches pushed row screens and their sheets, which track writes. + .environment(coordinator) .background { Button("") { coordinator.selectedTab = .tables } .keyboardShortcut("1", modifiers: .command) @@ -248,6 +263,24 @@ struct ConnectedView: View { } message: { Text(coordinator.failureAlertMessage ?? "") } + .confirmationDialog( + String(localized: "Switch Database?"), + isPresented: Binding( + get: { databaseSwitchAwaitingRollBack != nil }, + set: { if !$0 { databaseSwitchAwaitingRollBack = nil } } + ), + titleVisibility: .visible, + presenting: databaseSwitchAwaitingRollBack + ) { database in + Button(String(localized: "Switch"), role: .destructive) { + Task { await coordinator.switchDatabase(to: database) } + } + } message: { _ in + Text(String( + format: String(localized: "The database “%@” has an open transaction. Switching rolls it back and discards its uncommitted changes."), + coordinator.activeDatabase + )) + } .userActivity(SceneIntent.viewConnectionActivity, isActive: appState.offersHandoff(for: connection)) { activity in activity.title = connection.name.isEmpty ? connection.host : connection.name activity.isEligibleForHandoff = true @@ -291,7 +324,7 @@ struct ConnectedView: View { Menu { ForEach(coordinator.databases, id: \.self) { db in Button { - Task { await coordinator.switchDatabase(to: db) } + requestDatabaseSwitch(to: db, on: coordinator) } label: { if db == coordinator.activeDatabase { Label(db, systemImage: "checkmark") @@ -329,4 +362,13 @@ struct ConnectedView: View { } } } + + private func requestDatabaseSwitch(to database: String, on coordinator: ConnectionCoordinator) { + if TransactionIndicatorContent.databaseSwitchEndsSession(for: coordinator.connection.type), + coordinator.transactionHold != nil { + databaseSwitchAwaitingRollBack = database + } else { + Task { await coordinator.switchDatabase(to: database) } + } + } } diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift index 3db1b7f5e3..d77d231612 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListRow.swift @@ -71,6 +71,13 @@ struct ConnectionListRow: View { } .frame(maxWidth: .infinity, alignment: .leading) + if model.holdsTransaction { + Image(systemName: TransactionIndicatorContent.openSymbolName) + .font(.caption) + .foregroundStyle(.orange) + .accessibilityHidden(true) + } + if model.showsFavoriteMark { Image(systemName: "star.fill") .font(.caption) diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 07d9eccea6..12525ab25a 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -378,7 +378,8 @@ struct ConnectionListView: View { connection: connection, section: section, tags: appState.tags, - groups: appState.groups + groups: appState.groups, + holdsTransaction: appState.sessionTransactions.hold(for: connection.id) != nil ), isRenaming: renamingRow == rowId, onOpen: { open(connection.id) }, diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index c1018f6f0f..b56e4d2df2 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -104,13 +104,23 @@ struct DataBrowserView: View { ) await viewModel.load(isInitial: true) } - .onChange(of: activeSchema) { _, newSchema in + // A redial or another window's database switch hands this table a new session. + .onChange(of: BrowseSource(session: session, schema: activeSchema)) { _, source in + guard source.driver != nil else { return } viewModel.attach( session: session, table: table, databaseType: connection.type, - host: connection.host, schema: newSchema + host: connection.host, schema: source.schema ) Task { await viewModel.load(isInitial: true) } } + .onChange(of: appState.sessionTransactions.rollBackCount(for: connection.id)) { + Task { await viewModel.load() } + } + // With autocommit off, a MySQL read opens a transaction. + .onChange(of: viewModel.isLoading || viewModel.isPageLoading) { _, loading in + guard !loading else { return } + Task { await coordinator.refreshTransaction() } + } .onDisappear { viewModel.cancel() } .sheet(isPresented: $showInsertSheet) { insertSheet } .sheet(isPresented: $showFilterSheet) { @@ -179,6 +189,7 @@ struct DataBrowserView: View { .sensoryFeedback(.error, trigger: hapticError) .navigationDestination(isPresented: $showStructure) { StructureView(table: table, session: session, databaseType: connection.type) + .transactionBar(for: coordinator) } .alert("Go to Page", isPresented: $showGoToPage) { TextField("Page number", text: $goToPageInput) @@ -515,7 +526,7 @@ struct DataBrowserView: View { } private func performDelete(_ pkValues: [(column: String, value: String)]) async { - let success = await viewModel.deleteRow(pkValues: pkValues) + let success = await coordinator.track(failed: { !$0 }) { await viewModel.deleteRow(pkValues: pkValues) } if success { hapticSuccess.toggle() } else { @@ -523,3 +534,13 @@ struct DataBrowserView: View { } } } + +private struct BrowseSource: Equatable { + let driver: ObjectIdentifier? + let schema: String? + + init(session: ConnectionSession?, schema: String?) { + driver = session.map { ObjectIdentifier($0.driver) } + self.schema = schema + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift b/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift index 7984621ab9..d401d256ba 100644 --- a/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift +++ b/apple/TableProMobile/TableProMobile/Views/InsertRowView.swift @@ -12,6 +12,7 @@ struct InsertRowView: View { var onInserted: (() -> Void)? @Environment(\.dismiss) private var dismiss + @Environment(ConnectionCoordinator.self) private var coordinator /// A column absent from this dictionary is left out of the `INSERT` so the database applies /// its own default. Keying by name rather than by position is what keeps the state from @@ -295,7 +296,7 @@ struct InsertRowView: View { defer { isSaving = false } do { - try await session.driver.executeWrite([sql]) + _ = try await coordinator.track { try await session.driver.executeWrite([sql]) } hapticSuccess.toggle() onInserted?() dismiss() diff --git a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift index 28e0bf935d..534eee6324 100644 --- a/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift +++ b/apple/TableProMobile/TableProMobile/Views/QueryEditorView.swift @@ -548,7 +548,9 @@ struct QueryEditorView: View { let progressUpdater = startActivityProgressUpdater(token: token) - await viewModel.run(driver: session.driver, script: script) + await coordinator.track(failed: { _ in QueryExecutionOutcome(phase: viewModel.phase) == .failed }) { + await viewModel.run(driver: session.driver, script: script) + } progressUpdater.cancel() let phase = viewModel.phase diff --git a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift index 22464d71d6..fb70d31bc7 100644 --- a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -3,6 +3,7 @@ import TableProDatabase import TableProModels struct RowDetailView: View { + @Environment(ConnectionCoordinator.self) private var coordinator @State private var viewModel: RowDetailViewModel @State private var fkPreviewItem: FKPreviewItem? @State private var showShareSheet = false @@ -76,6 +77,8 @@ struct RowDetailView: View { rowNavigator } } + .transactionBar(for: coordinator) + .leavesAfterRollBack(of: coordinator, isTableRow: viewModel.table != nil, isEditing: viewModel.isEditing) .navigationBarBackButtonHidden(viewModel.isEditing) .holdsScene(withUnsavedChanges: viewModel.hasUnsavedEdits) .navigationTitle(viewModel.table?.name ?? String(format: String(localized: "Row %d of %d"), viewModel.currentIndex + 1, viewModel.rows.count)) @@ -357,7 +360,9 @@ struct RowDetailView: View { } private func handleSave() async { - let success = await viewModel.saveChanges() + let success = await coordinator.track(failed: { _ in viewModel.operationError != nil }) { + await viewModel.saveChanges() + } if viewModel.pendingWriteConfirmation { showSaveConfirmation = true return @@ -370,7 +375,9 @@ struct RowDetailView: View { } private func executePendingSave() async { - let success = await viewModel.executePendingSave() + let success = await coordinator.track(failed: { _ in viewModel.operationError != nil }) { + await viewModel.executePendingSave() + } if success { hapticSuccess.toggle() } else { diff --git a/apple/TableProMobile/TableProMobile/Views/TableListView.swift b/apple/TableProMobile/TableProMobile/Views/TableListView.swift index 697fb8480a..e959ba0bdf 100644 --- a/apple/TableProMobile/TableProMobile/Views/TableListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/TableListView.swift @@ -163,7 +163,7 @@ struct TableListView: View { let quoted = SQLBuilder.qualifiedIdentifier( table: table.name, schema: activeSchema, for: connection.type ) - try await driver.executeWrite(["TRUNCATE TABLE \(quoted)"]) + _ = try await coordinator.track { try await driver.executeWrite(["TRUNCATE TABLE \(quoted)"]) } await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription @@ -190,7 +190,7 @@ struct TableListView: View { let quoted = SQLBuilder.qualifiedIdentifier( table: table.name, schema: activeSchema, for: connection.type ) - try await driver.executeWrite(["DROP TABLE \(quoted)"]) + _ = try await coordinator.track { try await driver.executeWrite(["DROP TABLE \(quoted)"]) } await coordinator.refreshTables() } catch { errorMessage = error.localizedDescription diff --git a/apple/TableProMobile/TableProMobile/Views/TransactionIndicatorContent.swift b/apple/TableProMobile/TableProMobile/Views/TransactionIndicatorContent.swift new file mode 100644 index 0000000000..9fa2503199 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/TransactionIndicatorContent.swift @@ -0,0 +1,81 @@ +import Foundation +import TableProModels + +nonisolated struct TransactionIndicatorContent: Equatable, Sendable { + nonisolated enum CommitOffer: Equatable, Sendable { + case hidden + case runs + case asksFirst + } + + static let openSymbolName = "hourglass" + static let abortedSymbolName = "exclamationmark.triangle.fill" + static let commitSymbolName = "checkmark" + static let rollBackSymbolName = "arrow.uturn.backward" + + let isAborted: Bool + let title: String + let subtitle: String + let accessibilityLabel: String + let commit: CommitOffer + + var symbolName: String { + isAborted ? Self.abortedSymbolName : Self.openSymbolName + } + + init( + isAborted: Bool, + connectionName: String, + activeDatabase: String, + switchesDatabases: Bool, + safeModeLevel: SafeModeLevel + ) { + self.isAborted = isAborted + // SQLite, DuckDB and Oracle keep a restored or empty activeDatabase that names nothing on the server. + let database = switchesDatabases && !activeDatabase.isEmpty ? activeDatabase : nil + + if isAborted { + title = String(localized: "Transaction Failed") + subtitle = String(localized: "Roll it back to continue.") + if let database { + accessibilityLabel = String( + format: String(localized: "Transaction failed on %1$@, %2$@. Roll it back to continue."), + database, + connectionName + ) + } else { + accessibilityLabel = String( + format: String(localized: "Transaction failed on %@. Roll it back to continue."), + connectionName + ) + } + commit = .hidden + return + } + + title = String(localized: "Transaction Open") + subtitle = database ?? connectionName + if let database { + accessibilityLabel = String( + format: String(localized: "Transaction open on %1$@, %2$@"), + database, + connectionName + ) + } else { + accessibilityLabel = String(format: String(localized: "Transaction open on %@"), connectionName) + } + switch safeModeLevel.writePermission { + case .proceed: + commit = .runs + case .requiresConfirmation: + commit = .asksFirst + case .blocked: + commit = .hidden + } + } + + // Mirrors ConnectionCoordinator.switchDatabase: only these engines dial a new session to switch. + static func databaseSwitchEndsSession(for type: DatabaseType) -> Bool { + type == .postgresql || type == .redshift + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/TransactionIndicatorView.swift b/apple/TableProMobile/TableProMobile/Views/TransactionIndicatorView.swift new file mode 100644 index 0000000000..9c2105b91a --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/TransactionIndicatorView.swift @@ -0,0 +1,250 @@ +import SwiftUI +import TableProModels + +extension View { + func transactionAccessory(for coordinator: ConnectionCoordinator) -> some View { + modifier(TransactionAccessoryPlacement(coordinator: coordinator)) + } + + func transactionBar(for coordinator: ConnectionCoordinator, isEnabled: Bool = true) -> some View { + modifier(TransactionBarPlacement(coordinator: coordinator, isEnabled: isEnabled)) + } + + // A table row is a copy of the grid page, which reloads after a Roll Back; an edit in progress finishes first. + func leavesAfterRollBack(of coordinator: ConnectionCoordinator, isTableRow: Bool, isEditing: Bool) -> some View { + modifier(RollBackDismissal(coordinator: coordinator, isTableRow: isTableRow, isEditing: isEditing)) + } +} + +private struct RollBackDismissal: ViewModifier { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + let coordinator: ConnectionCoordinator + let isTableRow: Bool + let isEditing: Bool + + @State private var rolledBack = false + + func body(content: Content) -> some View { + content + .onChange(of: appState.sessionTransactions.rollBackCount(for: coordinator.connection.id)) { + guard isTableRow else { return } + rolledBack = true + if !isEditing { dismiss() } + } + .onChange(of: isEditing) { _, editing in + if !editing, rolledBack { dismiss() } + } + } +} + +// The 26.0 tabViewBottomAccessory(content:) overload is avoided: it draws an empty capsule while nothing is held. +private struct TransactionAccessoryPlacement: ViewModifier { + @Environment(AppState.self) private var appState + let coordinator: ConnectionCoordinator + + func body(content: Content) -> some View { + if #available(iOS 26.1, *) { + content.tabViewBottomAccessory(isEnabled: coordinator.transactionHold != nil) { + TransactionAccessory(coordinator: coordinator, transactions: appState.sessionTransactions) + } + } else { + content + } + } +} + +// Goes on each screen, outside its own bar: on a navigation container it covers a pushed screen's last rows. +private struct TransactionBarPlacement: ViewModifier { + @Environment(AppState.self) private var appState + let coordinator: ConnectionCoordinator + let isEnabled: Bool + + func body(content: Content) -> some View { + if #available(iOS 26.1, *) { + content + } else { + content.bottomSafeAreaBar { + if isEnabled, coordinator.transactionHold != nil { + TransactionIndicatorView(coordinator: coordinator, transactions: appState.sessionTransactions) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + } + } + } +} + +@available(iOS 26.1, *) +private struct TransactionAccessory: View { + @Environment(\.tabViewBottomAccessoryPlacement) private var placement + let coordinator: ConnectionCoordinator + let transactions: SessionTransactionMonitor + + var body: some View { + TransactionIndicatorView( + coordinator: coordinator, + transactions: transactions, + prefersCompact: placement == .inline + ) + .padding(.horizontal, 16) + } +} + +struct TransactionIndicatorView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let coordinator: ConnectionCoordinator + let transactions: SessionTransactionMonitor + var prefersCompact = false + + // What each dialog was opened for, so a swapped session or newer work refuses rather than acts. + @State private var rollBackTarget: SessionTransactionMonitor.HoldToken? + @State private var commitTarget: SessionTransactionMonitor.HoldToken? + + private var connectionId: UUID { coordinator.connection.id } + private var isBusy: Bool { transactions.isBusy(connectionId) } + private var isCompact: Bool { prefersCompact || dynamicTypeSize.isAccessibilitySize } + + var body: some View { + if let hold = coordinator.transactionHold { + bar(TransactionIndicatorContent( + isAborted: hold == .aborted, + connectionName: coordinator.displayName, + activeDatabase: coordinator.activeDatabase, + switchesDatabases: coordinator.supportsDatabaseSwitching, + safeModeLevel: coordinator.connection.safeModeLevel + )) + } + } + + // A button title never truncates: where the titles do not fit, as on an iPhone in portrait, the buttons show symbols. + private func bar(_ content: TransactionIndicatorContent) -> some View { + ViewThatFits(in: .horizontal) { + row(content, iconOnly: isCompact) + row(content, iconOnly: true) + } + .confirmationDialog( + String(localized: "Roll Back the Transaction?"), + isPresented: isPresenting($rollBackTarget), + titleVisibility: .visible, + presenting: rollBackTarget + ) { target in + Button(String(localized: "Roll Back"), role: .destructive) { + perform { try await coordinator.rollBackTransaction(as: target) } + } + } message: { _ in + Text("Its uncommitted changes are discarded.") + } + .confirmationDialog( + String(localized: "Commit the Transaction?"), + isPresented: isPresenting($commitTarget), + titleVisibility: .visible, + presenting: commitTarget + ) { target in + Button(String(localized: "Commit")) { + // The level can change on another device while the dialog is up. + guard !coordinator.connection.safeModeLevel.blocksWrites else { return } + perform { try await coordinator.commitTransaction(as: target) } + } + } message: { _ in + Text("Its changes are saved to the database.") + } + } + + private func row(_ content: TransactionIndicatorContent, iconOnly: Bool) -> some View { + HStack(spacing: 12) { + summary(content) + Spacer(minLength: 8) + HStack(spacing: 8) { + rollBackButton + if content.commit != .hidden { + commitButton(content.commit) + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .modifier(TransactionButtonLabels(isCompact: iconOnly)) + .disabled(isBusy) + } + } + + private func summary(_ content: TransactionIndicatorContent) -> some View { + HStack(spacing: 8) { + if isBusy { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: content.symbolName) + .foregroundStyle(content.isAborted ? Color.red : Color.orange) + } + VStack(alignment: .leading, spacing: 1) { + Text(content.title) + .font(.subheadline.weight(.semibold)) + if !isCompact { + Text(content.subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(content.accessibilityLabel) + } + + // Never gated by Safe Mode: Roll Back is the one way out that keeps nothing. + private var rollBackButton: some View { + Button(role: .destructive) { + rollBackTarget = coordinator.transactionToken + } label: { + Label("Roll Back", systemImage: TransactionIndicatorContent.rollBackSymbolName) + } + } + + private func commitButton(_ offer: TransactionIndicatorContent.CommitOffer) -> some View { + Button { + guard let target = coordinator.transactionToken else { return } + if offer == .asksFirst { + commitTarget = target + } else { + perform { try await coordinator.commitTransaction(as: target) } + } + } label: { + Label("Commit", systemImage: TransactionIndicatorContent.commitSymbolName) + } + } + + private func isPresenting(_ target: Binding) -> Binding { + Binding( + get: { target.wrappedValue != nil }, + set: { if !$0 { target.wrappedValue = nil } } + ) + } + + // The coordinator's alert lives on the TabView, so it outlasts this bar when a failure ends the hold. + private func perform(_ action: @escaping @MainActor () async throws -> Void) { + Task { + do { + try await action() + } catch is CancellationError { + return + } catch { + guard !coordinator.showFailureAlert else { return } + coordinator.failureAlertMessage = error.localizedDescription + coordinator.showFailureAlert = true + } + } + } +} + +private struct TransactionButtonLabels: ViewModifier { + let isCompact: Bool + + func body(content: Content) -> some View { + if isCompact { + content.labelStyle(.iconOnly) + } else { + content.labelStyle(.titleOnly) + } + } +} diff --git a/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift b/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift index 0eaa082e94..3b27921ff3 100644 --- a/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift +++ b/apple/TableProMobile/TableProMobileTests/BackgroundReleaseCoordinatorTests.swift @@ -26,19 +26,6 @@ private final class SpyBackgroundTaskAsserter: BackgroundTaskAsserting { } } -private final class StubDriverFactory: DriverFactory, @unchecked Sendable { - var drivers: [String: any DatabaseDriver] = [:] - - func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { - guard let driver = drivers[connection.type.rawValue] else { - throw ConnectionError.driverNotFound(connection.type.rawValue) - } - return driver - } - - func supportedTypes() -> [DatabaseType] { [] } -} - private func makeConnection(_ type: String) -> DatabaseConnection { DatabaseConnection(name: "Test", type: DatabaseType(rawValue: type)) } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift new file mode 100644 index 0000000000..cf6dd05252 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift @@ -0,0 +1,344 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels +import Testing + +@MainActor +@Suite("Connection coordinator reconnect", .timeLimit(.minutes(1))) +struct ConnectionCoordinatorReconnectTests { + private let fixture: AppStateFixture + private let factory = StubDriverFactory() + private let appState: AppState + private let connection = DatabaseConnection( + name: "Prod", + type: .mysql, + host: "db.example.com", + port: 3_306, + database: "app_prod" + ) + + init() throws { + fixture = try AppStateFixture() + appState = fixture.makeState(syncEnabled: false, driverFactory: factory) + } + + private var monitor: SessionTransactionMonitor { appState.sessionTransactions } + + private func driver(tables: [String] = [], state: DriverTransactionState = .idle) -> MockDatabaseDriver { + let driver = MockDatabaseDriver() + driver.scriptedTables = tables.map { TableInfo(name: $0) } + driver.scriptedTransactionState = state + return driver + } + + private func dial(_ driver: MockDatabaseDriver, database: String? = nil) async throws { + var target = connection + if let database { target.database = database } + factory.queue(driver) + _ = try await appState.connectionManager.connect(target) + } + + private func attachedCoordinator() async -> ConnectionCoordinator { + let coordinator = ConnectionCoordinator(connection: connection, appState: appState) + await coordinator.connect() + return coordinator + } + + private func isConnected(_ coordinator: ConnectionCoordinator) -> Bool { + if case .connected = coordinator.phase { return true } + return false + } + + private func tableNames(_ coordinator: ConnectionCoordinator) -> [String] { + coordinator.tables.map { $0.name } + } + + @Test("A redial dials the database the toolbar shows and reloads its tables") + func redialDialsTheActiveDatabase() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let coordinator = await attachedCoordinator() + await coordinator.switchDatabase(to: "app_staging") + #expect(coordinator.activeDatabase == "app_staging") + + first.pingError = MockDatabaseDriver.MockError.scripted + let second = driver(tables: ["staging_orders"]) + factory.queue(second) + await coordinator.reconnectIfNeeded() + + let dialedDatabase = factory.dialed.last?.database + #expect(dialedDatabase == "app_staging") + #expect(coordinator.session?.driver === second) + #expect(coordinator.activeDatabase == "app_staging") + #expect(tableNames(coordinator) == ["staging_orders"]) + #expect(isConnected(coordinator)) + } + + @Test("A session another window dialed is taken without a ping, with its database and tables") + func newerSessionIsAdoptedWithItsDatabase() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let coordinator = await attachedCoordinator() + #expect(coordinator.activeDatabase == "app_prod") + + let other = driver(tables: ["staging_orders"]) + try await dial(other, database: "app_staging") + let dialsBefore = factory.dialed.count + await coordinator.reconnectIfNeeded() + + #expect(first.pingCount == 0) + #expect(factory.dialed.count == dialsBefore) + #expect(coordinator.session?.driver === other) + #expect(coordinator.activeDatabase == "app_staging") + #expect(tableNames(coordinator) == ["staging_orders"]) + } + + @Test("Two windows coming back at once dial the connection once") + func twoWindowsDialOnce() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let left = await attachedCoordinator() + let right = await attachedCoordinator() + let dialsBefore = factory.dialed.count + + first.pingError = MockDatabaseDriver.MockError.scripted + let second = driver(tables: ["orders"]) + factory.queue(second) + async let leftDone: Void = left.reconnectIfNeeded() + async let rightDone: Void = right.reconnectIfNeeded() + _ = await (leftDone, rightDone) + + #expect(factory.dialed.count == dialsBefore + 1) + #expect(left.session?.driver === second) + #expect(right.session?.driver === second) + #expect(isConnected(left)) + #expect(isConnected(right)) + } + + @Test("A redial over an open transaction tells the user the server rolled it back") + func redialOverAHoldShowsTheAlert() async throws { + let first = driver(tables: ["orders"], state: .explicitTransaction) + try await dial(first) + let coordinator = await attachedCoordinator() + #expect(monitor.hold(for: connection.id) == .open) + + first.pingError = MockDatabaseDriver.MockError.scripted + factory.queue(driver(tables: ["orders"])) + await coordinator.reconnectIfNeeded() + + #expect(coordinator.showFailureAlert) + #expect(coordinator.failureAlertMessage == ConnectionCoordinator.lostTransactionMessage(.whileIdle, connectionName: "Prod")) + #expect(monitor.hold(for: connection.id) == nil) + } + + @Test("A redial with nothing held reconnects quietly") + func redialWithoutAHoldIsQuiet() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let coordinator = await attachedCoordinator() + + first.pingError = MockDatabaseDriver.MockError.scripted + factory.queue(driver(tables: ["orders"])) + await coordinator.reconnectIfNeeded() + + #expect(!coordinator.showFailureAlert) + #expect(isConnected(coordinator)) + } + + @Test("A Commit whose reply is lost redials and says its outcome is not known") + func failedCommitOnADeadSessionRedials() async throws { + let first = driver(tables: ["orders"], state: .explicitTransaction) + try await dial(first) + let coordinator = await attachedCoordinator() + #expect(monitor.hold(for: connection.id) == .open) + let token = try #require(coordinator.transactionToken) + + first.commitError = MockDatabaseDriver.MockError.scripted + first.pingError = MockDatabaseDriver.MockError.scripted + let second = driver(tables: ["orders"]) + factory.queue(second) + try await coordinator.commitTransaction(as: token) + await ObservedCondition.wait { monitor.hold(for: connection.id) == nil } + + #expect(coordinator.session?.driver === second) + #expect(coordinator.showFailureAlert) + #expect(coordinator.failureAlertMessage == ConnectionCoordinator.lostTransactionMessage(.duringWork, connectionName: "Prod")) + #expect(isConnected(coordinator)) + } + + @Test("A foreground check while tracked work is out says the outcome is not known") + func foregroundCheckDuringWorkIsAmbiguous() async throws { + let first = driver(tables: ["orders"], state: .explicitTransaction) + try await dial(first) + let coordinator = await attachedCoordinator() + let gate = TestGate() + let work = Task { await coordinator.track { await gate.enter() } } + await gate.waitUntilEntered() + + first.pingError = MockDatabaseDriver.MockError.scripted + factory.queue(driver(tables: ["orders"])) + await coordinator.reconnectIfNeeded() + await gate.open() + await work.value + + #expect(coordinator.failureAlertMessage == ConnectionCoordinator.lostTransactionMessage(.duringWork, connectionName: "Prod")) + } + + @Test("A Roll Back that loses the connection redials without an alert: the transaction is gone as asked") + func lostRollBackRedialsQuietly() async throws { + let first = driver(tables: ["orders"], state: .explicitTransaction) + try await dial(first) + let coordinator = await attachedCoordinator() + let token = try #require(coordinator.transactionToken) + + first.rollbackError = MockDatabaseDriver.MockError.scripted + first.pingError = MockDatabaseDriver.MockError.scripted + let second = driver(tables: ["orders"]) + factory.queue(second) + try await coordinator.rollBackTransaction(as: token) + await ObservedCondition.wait { monitor.hold(for: connection.id) == nil } + + #expect(coordinator.session?.driver === second) + #expect(!coordinator.showFailureAlert) + } + + @Test("A window left on a replaced session shows no hold, and its Commit refuses rather than commit the newer one") + func staleWindowRefusesTheNewerSessionsCommit() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let coordinator = await attachedCoordinator() + let other = driver(tables: ["staging_orders"], state: .explicitTransaction) + try await dial(other, database: "app_staging") + await monitor.refresh(connection.id) + + #expect(monitor.hold(for: connection.id) == .open) + #expect(coordinator.transactionHold == nil) + #expect(coordinator.transactionToken == nil) + await #expect(throws: TransactionActionError.sessionReplaced) { + try await coordinator.commitTransaction(as: SessionTransactionMonitor.HoldToken(driver: first, workGeneration: 0)) + } + + #expect(!other.didCommitTransaction) + #expect(coordinator.session?.driver === other) + #expect(coordinator.activeDatabase == "app_staging") + #expect(coordinator.transactionHold == .open) + } + + @Test("Two windows sharing a redial both read the schema it restored") + func sharedRedialRestoresTheSchemaFirst() async throws { + let warehouse = DatabaseConnection( + name: "Warehouse", + type: .postgresql, + host: "pg.example.com", + port: 5_432, + database: "analytics" + ) + let first = driver(tables: ["facts"]) + first.currentSchema = "public" + factory.queue(first) + _ = try await appState.connectionManager.connect(warehouse) + let left = ConnectionCoordinator(connection: warehouse, appState: appState) + await left.connect() + await left.switchSchema(to: "sales") + let right = ConnectionCoordinator(connection: warehouse, appState: appState) + await right.connect() + #expect(right.activeSchema == "sales") + let dialsBefore = factory.dialed.count + + first.pingError = MockDatabaseDriver.MockError.scripted + let second = driver(tables: ["facts"]) + second.currentSchema = "public" + factory.queue(second) + async let leftDone: Void = left.reconnectIfNeeded() + async let rightDone: Void = right.reconnectIfNeeded() + _ = await (leftDone, rightDone) + + #expect(factory.dialed.count == dialsBefore + 1) + #expect(second.currentSchema == "sales") + #expect(left.activeSchema == "sales") + #expect(right.activeSchema == "sales") + } + + @Test("A Roll Back reloads the table list, so a dropped table it undid comes back") + func rollBackReloadsTables() async throws { + let first = driver(tables: ["orders"], state: .explicitTransaction) + try await dial(first) + let coordinator = await attachedCoordinator() + first.scriptedTables = [] + await coordinator.refreshTables() + #expect(coordinator.tables.isEmpty) + + first.scriptedTables = [TableInfo(name: "orders")] + await coordinator.reloadAfterRollBack() + + #expect(tableNames(coordinator) == ["orders"]) + } + + @Test("Refreshing the table list shows a transaction a read opened") + func tableRefreshPicksUpAReadTransaction() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let coordinator = await attachedCoordinator() + #expect(coordinator.transactionHold == nil) + + first.scriptedTransactionState = .implicitTransaction + await coordinator.refreshTables() + + #expect(coordinator.transactionHold == .open) + } + + @Test("A Commit refused on a live session reports the error and keeps the session") + func refusedCommitOnALiveSessionThrows() async throws { + let first = driver(tables: ["orders"], state: .explicitTransaction) + try await dial(first) + let coordinator = await attachedCoordinator() + first.scriptedTransactionState = .abortedTransaction + let token = try #require(coordinator.transactionToken) + + await #expect(throws: TransactionActionError.cannotCommit) { + try await coordinator.commitTransaction(as: token) + } + + #expect(coordinator.session?.driver === first) + #expect(monitor.hold(for: connection.id) == .aborted) + #expect(!coordinator.showFailureAlert) + } + + @Test("Tracked work that fails on a dead session redials") + func failedWorkRedials() async throws { + let first = driver(tables: ["orders"]) + try await dial(first) + let coordinator = await attachedCoordinator() + + first.pingError = MockDatabaseDriver.MockError.scripted + let second = driver(tables: ["orders"]) + factory.queue(second) + let ranIntoAnError = await coordinator.track(failed: { $0 }) { true } + + #expect(ranIntoAnError) + #expect(coordinator.session?.driver === second) + } + + @Test("A reattached session whose catalog an aborted transaction refuses is kept, and reloads after Roll Back") + func refusedMetadataReloadsAfterRollBack() async throws { + let first = driver(state: .abortedTransaction) + first.fetchTablesError = MockDatabaseDriver.MockError.scripted + try await dial(first) + let coordinator = await attachedCoordinator() + + #expect(isConnected(coordinator)) + #expect(coordinator.session?.driver === first) + #expect(monitor.hold(for: connection.id) == .aborted) + #expect(coordinator.tables.isEmpty) + + first.fetchTablesError = nil + first.scriptedTables = [TableInfo(name: "orders")] + first.scriptedTransactionState = .idle + try await coordinator.rollBackTransaction(as: #require(coordinator.transactionToken)) + + #expect(first.didRollbackTransaction) + #expect(monitor.hold(for: connection.id) == nil) + #expect(tableNames(coordinator) == ["orders"]) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift index c645247f4b..85fdcc89d5 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionListRowModelTests.swift @@ -56,4 +56,25 @@ struct ConnectionListRowModelTests { #expect(row.title == "db.acme.io") #expect(!row.accessibilityLabel.contains("·")) } + + @Test("A row whose session holds a transaction says so after Favorite, since its mark is not read on its own") + func transactionIsSpokenAfterFavorite() { + let connection = DatabaseConnection(name: "Prod", type: .postgresql, isFavorite: true) + + let holding = ConnectionListRowModel( + connection: connection, + section: .connections, + tags: [], + groups: [], + holdsTransaction: true + ) + let idle = ConnectionListRowModel(connection: connection, section: .connections, tags: [], groups: []) + + #expect(holding.holdsTransaction) + #expect(holding.accessibilityLabel.hasSuffix( + [String(localized: "Favorite"), String(localized: "Transaction open")].joined(separator: ", ") + )) + #expect(!idle.holdsTransaction) + #expect(!idle.accessibilityLabel.contains(String(localized: "Transaction open"))) + } } diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift index cdcf4ded22..732a17dd9a 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift @@ -128,6 +128,40 @@ final class DuckDBDriverTests: XCTestCase { XCTAssertTrue(alive) } + func testPingInsideAnAbortedTransactionKeepsTheSession() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: "CREATE TABLE keep (x INTEGER)") + _ = try await driver.execute(query: "INSERT INTO keep VALUES (1)") + _ = try await driver.execute(query: "CREATE TABLE u (x INTEGER PRIMARY KEY)") + _ = try await driver.execute(query: "BEGIN TRANSACTION") + _ = try await driver.execute(query: "INSERT INTO u VALUES (1)") + do { + _ = try await driver.execute(query: "INSERT INTO u VALUES (1)") + XCTFail("A duplicate key must fail") + } catch {} + do { + _ = try await driver.execute(query: "SELECT 1") + XCTFail("An aborted DuckDB transaction must refuse a statement") + } catch {} + + let alive = try await driver.ping() + XCTAssertTrue(alive) + + _ = try await driver.execute(query: "ROLLBACK") + let kept = try await driver.execute(query: "SELECT x FROM keep") + XCTAssertEqual(kept.rows.first?.first ?? nil, "1") + } + + func testPingAfterDisconnectFails() async throws { + let closed = DuckDBDriver(source: .inMemory) + try await closed.connect() + try await closed.disconnect() + do { + _ = try await closed.ping() + XCTFail("A closed DuckDB session must fail its ping") + } catch DuckDBDriverError.notConnected {} + } + func testServerVersionIsReported() async throws { let driver = try XCTUnwrap(driver) XCTAssertNotNil(driver.serverVersion) diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift index 2493d2a2da..3299ed8c72 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/MySQLDriverTransactionTests.swift @@ -97,6 +97,21 @@ struct MySQLSessionTransactionTests { #expect(MySQLSessionTransaction.state(infoResult: 0, serverStatus: 0) == .idle) } + @Test("MySQL, MariaDB and TiDB read the session from the reply's status flags") + func measuredFlavorsReportTheStatusFlags() { + #expect(MySQLDriver.reportsSessionStatusFlags(for: .mysql, banner: "8.4.11")) + #expect(MySQLDriver.reportsSessionStatusFlags(for: .mariadb, banner: "10.6.28-MariaDB-ubu2204")) + #expect(MySQLDriver.reportsSessionStatusFlags(for: .tidb, banner: "8.0.11-TiDB-v8.5.0")) + #expect(MySQLDriver.reportsSessionStatusFlags(for: .mysql, banner: nil)) + } + + @Test("OceanBase and Databend report an unknown session, since their status flags were never measured") + func unmeasuredFlavorsReportNothing() { + #expect(!MySQLDriver.reportsSessionStatusFlags(for: .oceanbase, banner: "5.7.25-OceanBase_CE-v4.2.1")) + #expect(!MySQLDriver.reportsSessionStatusFlags(for: .oceanbase, banner: nil)) + #expect(!MySQLDriver.reportsSessionStatusFlags(for: .mysql, banner: "8.0.17-v1.2.615-nightly")) + } + @Test("Unrelated status bits do not change the answer") func unrelatedBitsAreIgnored() { let noBackslashEscapes = UInt32(SERVER_STATUS_NO_BACKSLASH_ESCAPES) diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift index dc3a19533a..a066876055 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/PostgreSQLTransactionStatementTests.swift @@ -25,15 +25,79 @@ struct PostgreSQLSessionTransactionTests { #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_IDLE) == .idle) } - @Test("Every transaction block reports the user's own transaction") - func openTransactionBlocks() { + @Test("An open transaction block reports the user's own transaction") + func openTransactionBlock() { #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_INTRANS) == .explicitTransaction) - #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_INERROR) == .explicitTransaction) - #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE) == .explicitTransaction) + } + + @Test("A block a failed statement aborted reports a transaction that can no longer commit") + func abortedTransactionBlock() { + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_INERROR) == .abortedTransaction) + } + + @Test("A statement in flight reports the transaction it was sent in") + func statementInFlightReportsStatusAtSend() { + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE, statusAtSend: PQTRANS_IDLE) == .idle) + #expect( + PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE, statusAtSend: PQTRANS_INTRANS) + == .explicitTransaction + ) + #expect( + PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE, statusAtSend: PQTRANS_INERROR) + == .abortedTransaction + ) + } + + @Test("A statement in flight with no status recorded at send reports an unknown state") + func statementInFlightWithoutStatusAtSend() { + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE) == .unknown) + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE, statusAtSend: PQTRANS_ACTIVE) == .unknown) + #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_ACTIVE, statusAtSend: PQTRANS_UNKNOWN) == .unknown) } @Test("A connection libpq cannot read reports an unknown state") func unknownStatus() { #expect(PostgreSQLSessionTransaction.state(from: PQTRANS_UNKNOWN) == .unknown) } + + @Test("Ping sends a statement only on an idle session") + func pingSendsStatementOnlyWhenIdle() { + #expect(PostgreSQLSessionTransaction.pingSendsStatement(in: PQTRANS_IDLE)) + for status in [PQTRANS_INTRANS, PQTRANS_INERROR, PQTRANS_ACTIVE, PQTRANS_UNKNOWN] { + #expect(!PostgreSQLSessionTransaction.pingSendsStatement(in: status)) + } + } + + @Test("A COMMIT the server answered with ROLLBACK in an aborted block says nothing was kept") + func commitInAbortedBlockSaysItRolledBack() { + #expect( + PostgreSQLSessionTransaction.statusMessage( + statusAtSend: PQTRANS_INERROR, commandTag: "ROLLBACK", statusAfter: PQTRANS_IDLE + ) == "The failed transaction was rolled back. None of its changes were kept." + ) + } + + @Test("A rollback to a savepoint, of a live transaction, or any other command adds no message") + func otherCommandsAddNoMessage() { + #expect( + PostgreSQLSessionTransaction.statusMessage( + statusAtSend: PQTRANS_INERROR, commandTag: "ROLLBACK", statusAfter: PQTRANS_INTRANS + ) == nil + ) + #expect( + PostgreSQLSessionTransaction.statusMessage( + statusAtSend: PQTRANS_INTRANS, commandTag: "ROLLBACK", statusAfter: PQTRANS_IDLE + ) == nil + ) + #expect( + PostgreSQLSessionTransaction.statusMessage( + statusAtSend: PQTRANS_INTRANS, commandTag: "COMMIT", statusAfter: PQTRANS_IDLE + ) == nil + ) + #expect( + PostgreSQLSessionTransaction.statusMessage( + statusAtSend: PQTRANS_IDLE, commandTag: "INSERT 0 1", statusAfter: PQTRANS_IDLE + ) == nil + ) + } } diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift index 91bb1b3866..6906dca2bd 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/RowInserterTests.swift @@ -98,19 +98,39 @@ struct RowInserterTests { #expect(!driver.didCommitTransaction) } - @Test("wraps and commits when autocommit is off, so the rows persist") - func implicitTransactionWraps() async throws { + @Test("joins the transaction autocommit off opened and commits it, so the rows persist") + func implicitTransactionIsCommitted() async throws { let driver = makeDriver(results: [ok(), ok()]) driver.scriptedTransactionState = .implicitTransaction let rows = [ PayloadRow(values: ["name": .text("Ada")]), PayloadRow(values: ["name": .text("Grace")]) ] - _ = try await RowInserter.insert( - driver: driver, table: "people", type: .postgresql, schema: nil, qualifier: nil, rows: rows + let affected = try await RowInserter.insert( + driver: driver, table: "people", type: .mysql, schema: nil, qualifier: nil, rows: rows ) - #expect(driver.beganTransactionModes == [.readWrite]) + #expect(affected == 2) + #expect(!driver.didBeginTransaction) #expect(driver.didCommitTransaction) + #expect(!driver.didRollbackTransaction) + } + + @Test("rolls back the autocommit-off transaction when a row fails") + func implicitTransactionRollsBackOnFailure() async throws { + let driver = makeDriver(results: [ok(), .failure(MockDatabaseDriver.MockError.scripted)]) + driver.scriptedTransactionState = .implicitTransaction + let rows = [ + PayloadRow(values: ["name": .text("Ada")]), + PayloadRow(values: ["name": .text("Grace")]) + ] + await #expect(throws: (any Error).self) { + _ = try await RowInserter.insert( + driver: driver, table: "people", type: .mysql, schema: nil, qualifier: nil, rows: rows + ) + } + #expect(!driver.didBeginTransaction) + #expect(!driver.didCommitTransaction) + #expect(driver.didRollbackTransaction) } @Test("does not open a transaction for a single row on a driver that cannot report its session") diff --git a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 1ae0151d33..9863f52a07 100644 --- a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -18,6 +18,10 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var scriptedTransactionStates: [DriverTransactionState] = [] /// Answered in order, one per call, then nil. var scriptedLexicalFacts: [SQLSessionLexicalFacts?] = [] + var pingError: Error? + var fetchTablesError: Error? + var commitError: Error? + var rollbackError: Error? private(set) var executedQueries: [String] = [] private(set) var fetchColumnsCalls: Int = 0 @@ -26,6 +30,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { private(set) var didBeginTransaction = false private(set) var didCommitTransaction = false private(set) var didRollbackTransaction = false + private(set) var pingCount = 0 var supportsSchemas: Bool = false var currentSchema: String? @@ -43,12 +48,17 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var beforeDisconnect: (@Sendable () async -> Void)? var beforeExecute: (@Sendable () async -> Void)? + var beforeTransactionState: (@Sendable () async -> Void)? func connect() async throws {} func disconnect() async throws { await beforeDisconnect?() } - func ping() async throws -> Bool { true } + func ping() async throws -> Bool { + pingCount += 1 + if let pingError { throw pingError } + return true + } func cancelCurrentQuery() async throws {} func execute(query: String) async throws -> QueryResult { @@ -63,7 +73,10 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { } } - func fetchTables(schema: String?) async throws -> [TableInfo] { scriptedTables } + func fetchTables(schema: String?) async throws -> [TableInfo] { + if let fetchTablesError { throw fetchTablesError } + return scriptedTables + } func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { fetchColumnsCalls += 1 @@ -80,7 +93,9 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func fetchDatabases() async throws -> [String] { scriptedDatabases } func fetchSchemas() async throws -> [String] { scriptedSchemas } func switchDatabase(to name: String) async throws {} - func switchSchema(to name: String) async throws {} + func switchSchema(to name: String) async throws { + currentSchema = name + } func beginTransaction() async throws { didBeginTransaction = true } func beginTransaction(mode: PluginTransactionAccessMode) async throws { @@ -88,14 +103,23 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { didBeginTransaction = true } - func commitTransaction() async throws { didCommitTransaction = true } - func rollbackTransaction() async throws { didRollbackTransaction = true } + func commitTransaction() async throws { + if let commitError { throw commitError } + didCommitTransaction = true + } + + func rollbackTransaction() async throws { + if let rollbackError { throw rollbackError } + didRollbackTransaction = true + } + func sessionLexicalFacts() async -> SQLSessionLexicalFacts? { guard !scriptedLexicalFacts.isEmpty else { return nil } return scriptedLexicalFacts.removeFirst() } func sessionTransactionState() async -> DriverTransactionState { + await beforeTransactionState?() guard !scriptedTransactionStates.isEmpty else { return scriptedTransactionState } return scriptedTransactionStates.removeFirst() } diff --git a/apple/TableProMobile/TableProMobileTests/Mocks/StubDriverFactory.swift b/apple/TableProMobile/TableProMobileTests/Mocks/StubDriverFactory.swift new file mode 100644 index 0000000000..6e2d15af7e --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Mocks/StubDriverFactory.swift @@ -0,0 +1,39 @@ +import Foundation +import TableProDatabase +import TableProModels + +final class StubDriverFactory: DriverFactory, @unchecked Sendable { + private let lock = NSLock() + private var driversByType: [String: any DatabaseDriver] = [:] + private var queuedDrivers: [any DatabaseDriver] = [] + private var dialedConnections: [DatabaseConnection] = [] + + var drivers: [String: any DatabaseDriver] { + get { lock.withLock { driversByType } } + set { lock.withLock { driversByType = newValue } } + } + + var dialed: [DatabaseConnection] { + lock.withLock { dialedConnections } + } + + // Handed out one per dial, ahead of `drivers`, so each redial gets a driver of its own. + func queue(_ driver: any DatabaseDriver) { + lock.withLock { queuedDrivers.append(driver) } + } + + func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { + try lock.withLock { + dialedConnections.append(connection) + if !queuedDrivers.isEmpty { + return queuedDrivers.removeFirst() + } + guard let driver = driversByType[connection.type.rawValue] else { + throw ConnectionError.driverNotFound(connection.type.rawValue) + } + return driver + } + } + + func supportedTypes() -> [DatabaseType] { [] } +} diff --git a/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift b/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift index 3bbb45d740..ecc4a7db8e 100644 --- a/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift +++ b/apple/TableProMobile/TableProMobileTests/Onboarding/AppStateFixture.swift @@ -49,7 +49,8 @@ struct AppStateFixture { func makeState( syncEnabled: Bool, secureStore: any SecureStore = MockSecureStore(), - libraryPublisher: ConnectionLibraryPublisher? = nil + libraryPublisher: ConnectionLibraryPublisher? = nil, + driverFactory: (any DriverFactory)? = nil ) -> AppState { let coordinator = IOSSyncCoordinator( metadata: metadata, @@ -73,7 +74,8 @@ struct AppStateFixture { searchIndex: RecordingSearchIndex(), writeWidgetItems: { _ in }, refreshShortcutParameters: {} - ) + ), + driverFactory: driverFactory ) } diff --git a/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift new file mode 100644 index 0000000000..e04e78714f --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift @@ -0,0 +1,243 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import Testing + +@MainActor +private final class LiveSessions { + var drivers: [UUID: any DatabaseDriver] = [:] +} + +@MainActor +@Suite("Session transaction monitor", .timeLimit(.minutes(1))) +struct SessionTransactionMonitorTests { + private let id = UUID() + private let live: LiveSessions + private let monitor: SessionTransactionMonitor + + init() { + let live = LiveSessions() + self.live = live + monitor = SessionTransactionMonitor { live.drivers[$0] } + } + + @discardableResult + private func attach(_ state: DriverTransactionState = .idle) -> MockDatabaseDriver { + let driver = MockDatabaseDriver() + driver.scriptedTransactionState = state + live.drivers[id] = driver + return driver + } + + @Test("A run that leaves a transaction open shows it, explicit or implicit") + func runLeavesTheTransactionOpen() async { + let driver = attach(.explicitTransaction) + await monitor.track(id) {} + #expect(monitor.hold(for: id) == .open) + + driver.scriptedTransactionState = .implicitTransaction + await monitor.track(id) {} + #expect(monitor.hold(for: id) == .open) + } + + @Test("A typed COMMIT clears it on the next settle") + func typedCommitClears() async { + let driver = attach(.explicitTransaction) + await monitor.track(id) {} + + driver.scriptedTransactionState = .idle + await monitor.track(id) {} + + #expect(monitor.hold(for: id) == nil) + } + + @Test("Work that throws still refreshes") + func throwingWorkRefreshes() async { + attach(.abortedTransaction) + + await #expect(throws: MockDatabaseDriver.MockError.self) { + try await monitor.track(id) { throw MockDatabaseDriver.MockError.scripted } + } + + #expect(monitor.hold(for: id) == .aborted) + } + + @Test("Commit and Roll Back run on the live driver and clear the indicator") + func commitAndRollBackHitTheLiveDriver() async throws { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + driver.scriptedTransactionStates = [.explicitTransaction, .idle] + + try await monitor.commit(id, as: #require(monitor.token(for: id, on: driver))) + + #expect(driver.didCommitTransaction) + #expect(monitor.hold(for: id) == nil) + + driver.scriptedTransactionState = .explicitTransaction + await monitor.refresh(id) + driver.scriptedTransactionState = .idle + + try await monitor.rollBack(id, as: #require(monitor.token(for: id, on: driver))) + + #expect(driver.didRollbackTransaction) + #expect(monitor.hold(for: id) == nil) + } + + @Test("An aborted transaction refuses Commit without sending it and still rolls back") + func abortedRefusesCommit() async throws { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + driver.scriptedTransactionState = .abortedTransaction + let commitToken = try #require(monitor.token(for: id, on: driver)) + + await #expect(throws: TransactionActionError.cannotCommit) { + try await monitor.commit(id, as: commitToken) + } + #expect(!driver.didCommitTransaction) + #expect(monitor.hold(for: id) == .aborted) + + driver.scriptedTransactionStates = [.idle] + try await monitor.rollBack(id, as: #require(monitor.token(for: id, on: driver))) + + #expect(driver.didRollbackTransaction) + #expect(monitor.hold(for: id) == nil) + } + + @Test("Commit on a session that replaced the one holding the transaction is refused and clears it") + func replacedSessionRefusesCommit() async throws { + let old = attach(.explicitTransaction) + await monitor.refresh(id) + let token = try #require(monitor.token(for: id, on: old)) + let replacement = attach(.idle) + + await #expect(throws: TransactionActionError.sessionReplaced) { + try await monitor.commit(id, as: token) + } + + #expect(!old.didCommitTransaction) + #expect(!replacement.didCommitTransaction) + #expect(monitor.hold(for: id) == nil) + } + + @Test("A window on a replaced session sees no hold and cannot act on the newer session's transaction") + func staleWindowCannotActOnTheNewerSession() async { + let old = attach(.idle) + let newer = attach(.explicitTransaction) + await monitor.refresh(id) + + #expect(monitor.hold(for: id, on: old) == nil) + #expect(monitor.token(for: id, on: old) == nil) + #expect(monitor.hold(for: id, on: newer) == .open) + await #expect(throws: TransactionActionError.sessionReplaced) { + try await monitor.rollBack(id, as: SessionTransactionMonitor.HoldToken(driver: old, workGeneration: 0)) + } + + #expect(!newer.didRollbackTransaction) + #expect(monitor.hold(for: id) == .open) + } + + @Test("A confirmation goes stale once other work runs on the session") + func workAfterTheTokenRefusesTheAction() async throws { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + let token = try #require(monitor.token(for: id, on: driver)) + + await monitor.track(id) {} + + await #expect(throws: TransactionActionError.transactionChanged) { + try await monitor.rollBack(id, as: token) + } + #expect(!driver.didRollbackTransaction) + #expect(monitor.hold(for: id) == .open) + } + + @Test("Only a Roll Back that went through counts") + func rollBackCountsOnlySuccess() async throws { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + driver.rollbackError = MockDatabaseDriver.MockError.scripted + + let failing = try #require(monitor.token(for: id, on: driver)) + await #expect(throws: MockDatabaseDriver.MockError.self) { + try await monitor.rollBack(id, as: failing) + } + #expect(monitor.rollBackCount(for: id) == 0) + + driver.rollbackError = nil + let token = try #require(monitor.token(for: id, on: driver)) + driver.scriptedTransactionState = .idle + try await monitor.rollBack(id, as: token) + + #expect(monitor.rollBackCount(for: id) == 1) + } + + @Test("A late answer from a driver that was replaced while it answered is dropped") + func lateAnswerFromReplacedDriverIsDropped() async { + let gate = TestGate() + let old = attach(.explicitTransaction) + old.beforeTransactionState = { await gate.enter() } + + let pending = Task { await monitor.refresh(id) } + await gate.waitUntilEntered() + attach(.unknown) + await gate.open() + await pending.value + + #expect(monitor.hold(for: id) == nil) + } + + @Test("A session's end clears only the entry its own driver holds") + func sessionEndClearsOnlyItsDriver() async { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + + monitor.sessionEnded(id, driver: MockDatabaseDriver()) + #expect(monitor.hold(for: id) == .open) + + monitor.sessionEnded(id, driver: driver) + #expect(monitor.hold(for: id) == nil) + } + + @Test("An unknown answer keeps what the session last reported") + func unknownKeepsTheEntry() async { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + + driver.scriptedTransactionState = .unknown + await monitor.refresh(id) + + #expect(monitor.hold(for: id) == .open) + } + + @Test("The connection is busy while tracked work runs, and free once it settles") + func busyDuringTrack() async { + attach() + let gate = TestGate() + + let work = Task { await monitor.track(id) { await gate.enter() } } + await gate.waitUntilEntered() + #expect(monitor.isBusy(id)) + + await gate.open() + await work.value + #expect(!monitor.isBusy(id)) + } + + @Test("A real SQLite session shows BEGIN as open and Commit clears it") + func sqliteBeginThenCommit() async throws { + let driver = SQLiteDriver(source: .inMemory) + try await driver.connect() + live.drivers[id] = driver + + _ = try await monitor.track(id) { try await driver.execute(query: "BEGIN") } + let afterBegin = monitor.hold(for: id) + try await monitor.commit(id, as: #require(monitor.token(for: id, on: driver))) + let afterCommit = monitor.hold(for: id) + let serverState = await driver.sessionTransactionState() + try await driver.disconnect() + + #expect(afterBegin == .open) + #expect(afterCommit == nil) + #expect(serverState == .idle) + } +} diff --git a/apple/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift b/apple/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift index 2114a7970b..135f8de346 100644 --- a/apple/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/QueryEditorViewModelTests.swift @@ -247,6 +247,25 @@ struct QueryEditorViewModelTests { #expect(vm.interruption?.standingWork == .openTransaction) } + @Test("a failure that aborted the script's transaction says to roll it back, not to commit") + func failureInsideAbortedTransaction() async { + let driver = MockDatabaseDriver() + driver.scriptedTransactionStates = [.explicitTransaction, .explicitTransaction, .abortedTransaction] + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0)), + .success(QueryResult(columns: [], rows: [], rowsAffected: 1, executionTime: 0)), + .failure(MockDatabaseDriver.MockError.scripted), + ] + + let vm = QueryEditorViewModel(windowCapacity: 100) + await vm.run(driver: driver, script: .statements("BEGIN", "INSERT INTO t VALUES (1)", "SELECT 1/0")) + + #expect(vm.interruption?.standingWork == .abortedTransaction) + #expect( + vm.standingWorkNote == "The transaction on this connection can no longer be committed. Roll it back." + ) + } + @Test("a failing first statement leaves nothing standing behind it") func failureOfTheFirstStatement() async { let driver = MockDatabaseDriver() diff --git a/apple/TableProMobile/TableProMobileTests/Support/TestGate.swift b/apple/TableProMobile/TableProMobileTests/Support/TestGate.swift new file mode 100644 index 0000000000..15a4431237 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Support/TestGate.swift @@ -0,0 +1,28 @@ +import Foundation + +// Holds work at a known point until the test lets it go. +actor TestGate { + private var isOpen = false + private var hasEntered = false + private var blocked: [CheckedContinuation] = [] + private var observers: [CheckedContinuation] = [] + + func enter() async { + hasEntered = true + for observer in observers { observer.resume() } + observers.removeAll() + guard !isOpen else { return } + await withCheckedContinuation { blocked.append($0) } + } + + func open() { + isOpen = true + for waiter in blocked { waiter.resume() } + blocked.removeAll() + } + + func waitUntilEntered() async { + guard !hasEntered else { return } + await withCheckedContinuation { observers.append($0) } + } +} diff --git a/apple/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift b/apple/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift index e61dd4db0d..11edfbfb8d 100644 --- a/apple/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Views/BottomSafeAreaBarLayoutTests.swift @@ -22,6 +22,27 @@ struct BottomSafeAreaBarLayoutTests { #expect(hit.map { !$0.isDescendant(of: tabBar) } ?? false) } + @Test("A pushed detail screen's bar sits under the screen's own bar and above the tab bar, and both clear its rows") + func pushedScreenBarStacksUnderItsOwnBar() throws { + guard UIDevice.current.userInterfaceIdiom == .phone else { return } + let probe = LayoutProbe() + let host = try HostedTree(probe: probe, variant: .pushedScreenBars) + defer { host.tearDown() } + + let tabBar = try host.settledTabBar() + let marker = probe.markerFrame + let screenBar = probe.screenBarFrame + + #expect(probe.reachedPushedScreen) + #expect(!marker.isEmpty) + #expect(!screenBar.isEmpty) + #expect(!marker.intersects(tabBar.convert(tabBar.bounds, to: host.window))) + #expect(screenBar.maxY <= marker.minY + 0.5) + #expect(probe.listInsets.bottom >= host.window.bounds.maxY - screenBar.minY - 0.5) + let hit = host.window.hitTest(CGPoint(x: marker.midX, y: marker.midY), with: nil) + #expect(hit.map { !$0.isDescendant(of: tabBar) } ?? false) + } + @Test("A hidden bar leaves no blank strip above the tab bar") func emptyBarAddsNoInset() throws { guard UIDevice.current.userInterfaceIdiom == .phone else { return } @@ -42,7 +63,9 @@ struct BottomSafeAreaBarLayoutTests { @MainActor private final class LayoutProbe { var markerFrame: CGRect = .zero + var screenBarFrame: CGRect = .zero var listInsets = EdgeInsets() + var reachedPushedScreen = false } private struct UnsettledLayout: Error, CustomStringConvertible { @@ -55,6 +78,7 @@ private struct HostedTree { case bar case emptyBar case noBar + case pushedScreenBars } private static let layoutTurns = 300 @@ -143,10 +167,7 @@ private struct ProbeTabs: View { var body: some View { TabView { Tab("Tables", systemImage: "tablecells") { - NavigationStack { - content - .navigationTitle("Rows") - } + tables } Tab("Query", systemImage: "terminal") { Text(verbatim: "Query") @@ -155,6 +176,34 @@ private struct ProbeTabs: View { .tabViewStyle(.sidebarAdaptable) } + // The app's Tables tab: a row screen with its own bar is pushed in the detail column and carries the bar too. + @ViewBuilder + private var tables: some View { + if variant == .pushedScreenBars { + NavigationSplitView(preferredCompactColumn: .constant(.detail)) { + List { + Text(verbatim: "albums") + } + } detail: { + NavigationStack(path: .constant([0])) { + Text(verbatim: "Rows") + .navigationDestination(for: Int.self) { _ in + rows + .bottomSafeAreaBar { screenBar } + .bottomSafeAreaBar { marker } + .navigationTitle("Row") + .onAppear { probe.reachedPushedScreen = true } + } + } + } + } else { + NavigationStack { + content + .navigationTitle("Rows") + } + } + } + @ViewBuilder private var content: some View { switch variant { @@ -166,7 +215,7 @@ private struct ProbeTabs: View { marker } } - case .noBar: + case .noBar, .pushedScreenBars: rows } } @@ -192,4 +241,15 @@ private struct ProbeTabs: View { probe.markerFrame = frame } } + + private var screenBar: some View { + Color.blue + .frame(height: 44) + .frame(maxWidth: .infinity) + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .global) + } action: { frame in + probe.screenBarFrame = frame + } + } } diff --git a/apple/TableProMobile/TableProMobileTests/Views/TransactionIndicatorContentTests.swift b/apple/TableProMobile/TableProMobileTests/Views/TransactionIndicatorContentTests.swift new file mode 100644 index 0000000000..faf09da866 --- /dev/null +++ b/apple/TableProMobile/TableProMobileTests/Views/TransactionIndicatorContentTests.swift @@ -0,0 +1,139 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing +import UIKit + +struct TransactionIndicatorContentTests { + @Test("An open transaction shows the database it runs in, and VoiceOver also names the connection") + func openNamesDatabaseAndConnection() { + let content = TransactionIndicatorContent( + isAborted: false, + connectionName: "Prod", + activeDatabase: "orders", + switchesDatabases: true, + safeModeLevel: .off + ) + + #expect(content.title == String(localized: "Transaction Open")) + #expect(content.subtitle == "orders") + #expect(content.accessibilityLabel == String( + format: String(localized: "Transaction open on %1$@, %2$@"), + "orders", + "Prod" + )) + #expect(content.symbolName == TransactionIndicatorContent.openSymbolName) + #expect(content.commit == .runs) + } + + @Test("The Sample has no database to name, so the bar and VoiceOver name the connection once") + func sampleNamesTheConnectionOnce() { + let sample = SampleDatabaseInstaller.connectionName + // A SQLite session can restore a stale lastDB, which names nothing on the server. + let content = TransactionIndicatorContent( + isAborted: false, + connectionName: sample, + activeDatabase: SampleDatabaseInstaller.fileName, + switchesDatabases: false, + safeModeLevel: .off + ) + + #expect(content.subtitle == sample) + #expect(content.accessibilityLabel == String(format: String(localized: "Transaction open on %@"), sample)) + #expect(!content.accessibilityLabel.contains(SampleDatabaseInstaller.fileName)) + } + + @Test("An engine that switches databases but has not named one yet falls back to the connection") + func emptyDatabaseFallsBackToConnection() { + let content = TransactionIndicatorContent( + isAborted: false, + connectionName: "Prod", + activeDatabase: "", + switchesDatabases: true, + safeModeLevel: .off + ) + + #expect(content.subtitle == "Prod") + #expect(content.accessibilityLabel == String(format: String(localized: "Transaction open on %@"), "Prod")) + } + + @Test("A failed transaction says to roll it back and offers no Commit") + func abortedOffersOnlyRollBack() { + let content = TransactionIndicatorContent( + isAborted: true, + connectionName: "Prod", + activeDatabase: "orders", + switchesDatabases: true, + safeModeLevel: .off + ) + + #expect(content.title == String(localized: "Transaction Failed")) + #expect(content.subtitle == String(localized: "Roll it back to continue.")) + #expect(content.accessibilityLabel == String( + format: String(localized: "Transaction failed on %1$@, %2$@. Roll it back to continue."), + "orders", + "Prod" + )) + #expect(content.symbolName == TransactionIndicatorContent.abortedSymbolName) + #expect(content.commit == .hidden) + } + + @Test("A failed transaction with no database names the connection once") + func abortedWithoutDatabase() { + let content = TransactionIndicatorContent( + isAborted: true, + connectionName: "Local", + activeDatabase: "", + switchesDatabases: false, + safeModeLevel: .off + ) + + #expect(content.accessibilityLabel == String( + format: String(localized: "Transaction failed on %@. Roll it back to continue."), + "Local" + )) + } + + @Test( + "Commit follows the Safe Mode write permission", + arguments: zip( + [SafeModeLevel.off, .confirmWrites, .readOnly], + [TransactionIndicatorContent.CommitOffer.runs, .asksFirst, .hidden] + ) + ) + func commitFollowsSafeMode(level: SafeModeLevel, offer: TransactionIndicatorContent.CommitOffer) { + let content = TransactionIndicatorContent( + isAborted: false, + connectionName: "Prod", + activeDatabase: "orders", + switchesDatabases: true, + safeModeLevel: level + ) + + #expect(content.commit == offer) + } + + @Test("Only a PostgreSQL or Redshift database switch dials a new session") + func databaseSwitchEndsSession() { + #expect(TransactionIndicatorContent.databaseSwitchEndsSession(for: .postgresql)) + #expect(TransactionIndicatorContent.databaseSwitchEndsSession(for: .redshift)) + #expect(!TransactionIndicatorContent.databaseSwitchEndsSession(for: .mysql)) + #expect(!TransactionIndicatorContent.databaseSwitchEndsSession(for: .mssql)) + #expect(!TransactionIndicatorContent.databaseSwitchEndsSession(for: .sqlite)) + } + + @MainActor + @Test("Every symbol the bar and the connection list draw exists on this system") + func symbolsExist() { + let names = [ + TransactionIndicatorContent.openSymbolName, + TransactionIndicatorContent.abortedSymbolName, + TransactionIndicatorContent.commitSymbolName, + TransactionIndicatorContent.rollBackSymbolName, + ] + + for name in names { + #expect(UIImage(systemName: name) != nil, "\(name)") + } + } +} From a8fb24a5c171a53ba162b6b64e5268a8307a5599 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:12:41 +0700 Subject: [PATCH 004/151] docs(changelog): add fragments for #3688 --- apple/changelog.d/3688.added.md | 1 + apple/changelog.d/3688.fixed.1.md | 1 + apple/changelog.d/3688.fixed.2.md | 1 + apple/changelog.d/3688.fixed.3.md | 1 + apple/changelog.d/3688.fixed.4.md | 1 + apple/changelog.d/3688.fixed.md | 1 + 6 files changed, 6 insertions(+) create mode 100644 apple/changelog.d/3688.added.md create mode 100644 apple/changelog.d/3688.fixed.1.md create mode 100644 apple/changelog.d/3688.fixed.2.md create mode 100644 apple/changelog.d/3688.fixed.3.md create mode 100644 apple/changelog.d/3688.fixed.4.md create mode 100644 apple/changelog.d/3688.fixed.md diff --git a/apple/changelog.d/3688.added.md b/apple/changelog.d/3688.added.md new file mode 100644 index 0000000000..3ce372343c --- /dev/null +++ b/apple/changelog.d/3688.added.md @@ -0,0 +1 @@ +iPhone and iPad show an open transaction on every section of its connection, with Commit and Roll Back. diff --git a/apple/changelog.d/3688.fixed.1.md b/apple/changelog.d/3688.fixed.1.md new file mode 100644 index 0000000000..81ca7edcce --- /dev/null +++ b/apple/changelog.d/3688.fixed.1.md @@ -0,0 +1 @@ +Returning to the app on iPhone and iPad dropped an open PostgreSQL transaction or an in-memory DuckDB database. diff --git a/apple/changelog.d/3688.fixed.2.md b/apple/changelog.d/3688.fixed.2.md new file mode 100644 index 0000000000..d53182a2ab --- /dev/null +++ b/apple/changelog.d/3688.fixed.2.md @@ -0,0 +1 @@ +Saving a row on iPhone and iPad with MySQL autocommit off committed the session's pending changes. diff --git a/apple/changelog.d/3688.fixed.3.md b/apple/changelog.d/3688.fixed.3.md new file mode 100644 index 0000000000..a036abcfe0 --- /dev/null +++ b/apple/changelog.d/3688.fixed.3.md @@ -0,0 +1 @@ +A reconnect on iPhone and iPad went back to the connection's saved database and schema instead of the one in use. diff --git a/apple/changelog.d/3688.fixed.4.md b/apple/changelog.d/3688.fixed.4.md new file mode 100644 index 0000000000..b427e7f1d3 --- /dev/null +++ b/apple/changelog.d/3688.fixed.4.md @@ -0,0 +1 @@ +Oracle DDL such as CREATE TABLE left the connection reporting an open transaction that the DDL had already committed. diff --git a/apple/changelog.d/3688.fixed.md b/apple/changelog.d/3688.fixed.md new file mode 100644 index 0000000000..c0371e18a4 --- /dev/null +++ b/apple/changelog.d/3688.fixed.md @@ -0,0 +1 @@ +A typed COMMIT after a failed PostgreSQL statement on iPhone and iPad reported success while the server kept nothing. From 60f65ce7a400198e31a8a628cf599b4cf234f5ea Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:23:03 +0700 Subject: [PATCH 005/151] fix(ios): recheck Commit after its state read and reload views when a redial drops a transaction --- .../Coordinators/ConnectionCoordinator.swift | 1 + .../Platform/SessionTransactionMonitor.swift | 18 +++++++++++++---- .../ConnectionCoordinatorReconnectTests.swift | 3 +++ .../SessionTransactionMonitorTests.swift | 20 +++++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 681f52a0d7..fa4fb42f78 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -291,6 +291,7 @@ final class ConnectionCoordinator { guard attemptToken == token else { return false } await takeSession(outcome.session, token: token, dialedHere: outcome.dialedHere) guard attemptToken == token, heldTransaction else { return false } + appState.sessionTransactions.transactionLost(connection.id) let settledLoss: SessionLoss = workWasRunning || statementFailedDuringCheck ? .duringWork : loss if let message = Self.lostTransactionMessage(settledLoss, connectionName: displayName) { failureAlertMessage = message diff --git a/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift b/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift index 23d2d83c32..5fd3d75b8a 100644 --- a/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift +++ b/apple/TableProMobile/TableProMobile/Platform/SessionTransactionMonitor.swift @@ -88,10 +88,15 @@ final class SessionTransactionMonitor { func commit(_ connectionId: UUID, as token: HoldToken) async throws { let driver = try actionDriver(for: connectionId, token: token) try await track(connectionId) { - if await driver.sessionTransactionState() == .abortedTransaction { - if liveDriver(connectionId) === driver { - entries[connectionId] = Entry(driver: driver, hold: .aborted) - } + let generation = workGenerations[connectionId, default: 0] + let state = await driver.sessionTransactionState() + guard liveDriver(connectionId) === driver else { throw TransactionActionError.sessionReplaced } + // Work another window ran during the read may have ended this transaction and opened another. + guard workGenerations[connectionId, default: 0] == generation else { + throw TransactionActionError.transactionChanged + } + if state == .abortedTransaction { + entries[connectionId] = Entry(driver: driver, hold: .aborted) throw TransactionActionError.cannotCommit } try await driver.commitTransaction() @@ -106,6 +111,11 @@ final class SessionTransactionMonitor { } } + // Rows a view read inside a transaction its session lost are as stale as after a Roll Back. + func transactionLost(_ connectionId: UUID) { + rollBackCounts[connectionId, default: 0] += 1 + } + func sessionEnded(_ connectionId: UUID, driver: any DatabaseDriver) { guard let entry = entries[connectionId], entry.driver === driver else { return } entries[connectionId] = nil diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift index cf6dd05252..282388cf53 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorReconnectTests.swift @@ -144,6 +144,7 @@ struct ConnectionCoordinatorReconnectTests { #expect(!coordinator.showFailureAlert) #expect(isConnected(coordinator)) + #expect(monitor.rollBackCount(for: connection.id) == 0) } @Test("A Commit whose reply is lost redials and says its outcome is not known") @@ -201,6 +202,8 @@ struct ConnectionCoordinatorReconnectTests { #expect(coordinator.session?.driver === second) #expect(!coordinator.showFailureAlert) + // Row screens and grids leave or reload on this, as after a Roll Back that went through. + #expect(monitor.rollBackCount(for: connection.id) == 1) } @Test("A window left on a replaced session shows no hold, and its Commit refuses rather than commit the newer one") diff --git a/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift b/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift index e04e78714f..d4e18cf25b 100644 --- a/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Platform/SessionTransactionMonitorTests.swift @@ -151,6 +151,26 @@ struct SessionTransactionMonitorTests { #expect(monitor.hold(for: id) == .open) } + @Test("Work that runs while Commit reads the session's state refuses the Commit") + func workDuringTheCommitReadRefusesIt() async throws { + let driver = attach(.explicitTransaction) + await monitor.refresh(id) + let token = try #require(monitor.token(for: id, on: driver)) + let gate = TestGate() + driver.beforeTransactionState = { await gate.enter() } + + let commit = Task { try await monitor.commit(id, as: token) } + await gate.waitUntilEntered() + driver.beforeTransactionState = nil + await monitor.track(id) {} + await gate.open() + + await #expect(throws: TransactionActionError.transactionChanged) { + try await commit.value + } + #expect(!driver.didCommitTransaction) + } + @Test("Only a Roll Back that went through counts") func rollBackCountsOnlySuccess() async throws { let driver = attach(.explicitTransaction) From 8e0636aed4ad31ef56fe25bfd2cb1eab6f819a88 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 10:28:19 +0700 Subject: [PATCH 006/151] fix(ios): keep the row screen's session coordinator apart from its foreign key opener --- .../TableProMobile/Views/RowDetailView.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift index 6d09f637fa..574a554bc7 100644 --- a/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift +++ b/apple/TableProMobile/TableProMobile/Views/RowDetailView.swift @@ -3,7 +3,8 @@ import TableProDatabase import TableProModels struct RowDetailView: View { - @Environment(ConnectionCoordinator.self) private var coordinator + // Tracks writes and shows the transaction; `coordinator` is set only where a referenced table can be opened. + @Environment(ConnectionCoordinator.self) private var connectionCoordinator @State private var viewModel: RowDetailViewModel @State private var fkPreviewItem: FKPreviewItem? @State private var showShareSheet = false @@ -80,8 +81,8 @@ struct RowDetailView: View { rowNavigator } } - .transactionBar(for: coordinator) - .leavesAfterRollBack(of: coordinator, isTableRow: viewModel.table != nil, isEditing: viewModel.isEditing) + .transactionBar(for: connectionCoordinator) + .leavesAfterRollBack(of: connectionCoordinator, isTableRow: viewModel.table != nil, isEditing: viewModel.isEditing) .navigationBarBackButtonHidden(viewModel.isEditing) .holdsScene(withUnsavedChanges: viewModel.hasUnsavedEdits) .navigationTitle(viewModel.table?.name ?? String(format: String(localized: "Row %d of %d"), viewModel.currentIndex + 1, viewModel.rows.count)) @@ -366,7 +367,7 @@ struct RowDetailView: View { } private func handleSave() async { - let success = await coordinator.track(failed: { _ in viewModel.operationError != nil }) { + let success = await connectionCoordinator.track(failed: { _ in viewModel.operationError != nil }) { await viewModel.saveChanges() } if viewModel.pendingWriteConfirmation { @@ -381,7 +382,7 @@ struct RowDetailView: View { } private func executePendingSave() async { - let success = await coordinator.track(failed: { _ in viewModel.operationError != nil }) { + let success = await connectionCoordinator.track(failed: { _ in viewModel.operationError != nil }) { await viewModel.executePendingSave() } if success { From fc8fa5b16dcd3b90ac06fc8452e22cdd6f936adc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 12:09:43 +0700 Subject: [PATCH 007/151] fix(ios): compare filter and row-key values the way each engine stores them --- .../TableProQuery/FilterColumnKind.swift | 82 +++++++++- .../TableProQuery/FilterSQLGenerator.swift | 87 +++++++---- .../TableProQuery/SQLStatementGenerator.swift | 66 --------- .../TableProQuery/TableQueryBuilder.swift | 140 ------------------ .../FilterSQLGeneratorTests.swift | 128 ++++++++++++++++ .../TableQueryBuilderTests.swift | 85 ----------- .../TableProMobile/Drivers/SQLiteDriver.swift | 21 ++- .../TableProMobile/Helpers/SQLBuilder.swift | 54 ++++--- .../ViewModels/DataBrowserViewModel.swift | 32 ++-- .../ViewModels/ForeignKeyPreviewModel.swift | 10 ++ .../ViewModels/RowDetailViewModel.swift | 3 +- .../DataBrowserViewModelTests.swift | 33 ++++- .../Drivers/SQLiteDriverFileTests.swift | 77 ++++++++++ .../ForeignKeyPreviewModelTests.swift | 34 ++++- .../SQLBuilderFilterLiteralTests.swift | 28 ++++ .../SQLBuilderSchemaQualificationTests.swift | 4 +- .../SQLBuilderSingleRowLookupTests.swift | 62 +++++++- .../Mocks/MockDatabaseDriver.swift | 2 + 18 files changed, 574 insertions(+), 374 deletions(-) delete mode 100644 apple/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift delete mode 100644 apple/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift delete mode 100644 apple/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift index 3705c80a7a..f32d0b7427 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterColumnKind.swift @@ -1,17 +1,25 @@ import Foundation +import TableProCoreTypes import TableProModels import TableProPluginKit // Unknown types fall to `.text`: `int_col = '5'` still matches everywhere, `text_col = 02134` finds the wrong row. nonisolated public enum FilterColumnKind { - public static func kind(forTypeName typeName: String) -> PluginColumnKind { + // A nil engine keeps the table every engine shares. + public static func kind(forTypeName typeName: String, databaseType: DatabaseType? = nil) -> PluginColumnKind { let base = typeName.lowercased().prefix { $0 != "(" } let words = base.split(whereSeparator: \.isWhitespace).filter { $0 != "unsigned" } // PostgreSQL's `bit varying` is a bit string, and `= 101` has no operator there. guard let first = words.first.map(String.init), !words.contains("varying") else { return .text } - if integerNames.contains(first) { return .integer } - if decimalNames.contains(first) { return .decimal } - return .text + let kind: PluginColumnKind + if integerNames.contains(first) { + kind = .integer + } else if decimalNames.contains(first) { + kind = .decimal + } else { + return .text + } + return engineKind(kind, typeName: first, databaseType: databaseType) } public static func column(named name: String, in columns: [ColumnInfo]) -> ColumnInfo? { @@ -22,13 +30,69 @@ nonisolated public enum FilterColumnKind { // A SQLite column with no affinity (typeless, any BLOB, ANY in a STRICT table) does not match a // stored 5 to `= '5'`: keep the untyped rule there. - public static func kind(of column: ColumnInfo?) -> PluginColumnKind? { + public static func kind(of column: ColumnInfo?, databaseType: DatabaseType? = nil) -> PluginColumnKind? { guard let column else { return nil } let typeName = column.typeName.trimmingCharacters(in: .whitespaces).lowercased() guard !typeName.isEmpty, !typeName.contains("blob"), typeName != "any" else { return nil } - return kind(forTypeName: typeName) + return kind(forTypeName: typeName, databaseType: databaseType) } + // An unread column counts as not character data, since a text cast is valid on every column. + public static func isCharacterType(_ column: ColumnInfo?) -> Bool { + guard let column else { return false } + let base = column.typeName.lowercased().prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces) + return base.contains("char") || base.hasSuffix("text") || characterNames.contains(base) + } + + // SQLite keeps 5 and '5' apart in a column with no affinity, and the text the grid read cannot say which one it was. + public static func comparesEitherStorageClass(_ column: ColumnInfo?, databaseType: DatabaseType?) -> Bool { + databaseType == .sqlite && kind(of: column, databaseType: databaseType) == nil + } + + // How SQLite spells a stored number, so `IN (5, '5')` never also matches a row that reads `05` or `5.50`. + public static func isCanonicalSQLiteNumber(_ value: String) -> Bool { + if let integer = Int64(value) { return String(integer) == value } + guard value.contains("."), !value.contains(where: { $0 == "e" || $0 == "E" }), + let real = Double(value), real.isFinite + else { return false } + return "\(real)" == value + } + + // Character.isNumber takes digits of every script, and no engine parses those as a number. + // An integer column takes a fraction bare too: PostgreSQL and SQL Server reject `int_col > '5.5'`. + public static func isBareNumber(_ value: String, kind: PluginColumnKind?) -> Bool { + guard value.unicodeScalars.allSatisfy(\.isASCII) else { return false } + return kind != .text && PluginNumericLiteral.isValid(value) + } + + // The literal that identifies a row by one key value. Off SQLite a column the caller could not read stays quoted, + // which every typed column accepts except MySQL BIT. + public static func keyLiteral(_ value: String, column: ColumnInfo?, databaseType: DatabaseType) -> ComparisonLiteral { + if comparesEitherStorageClass(column, databaseType: databaseType) { + return isCanonicalSQLiteNumber(value) ? .numberOrText(value) : .text(value) + } + guard let kind = kind(of: column, databaseType: databaseType) else { return .text(value) } + return isBareNumber(value, kind: kind) ? .number(value) : .text(value) + } + + // PostgreSQL resolves a quoted literal to the column's own type, so only an integer column needs a bare + // number: `bit = 101` and `boolean = 1` have no operator, and a `real` never equals the numeric 0.1. + // DuckDB runs `bit = 101` and finds no row; `bit = '101'` finds it. + private static func engineKind(_ kind: PluginColumnKind, typeName: String, databaseType: DatabaseType?) -> PluginColumnKind { + switch databaseType { + case .postgresql?, .redshift?: + return kind == .integer && !nonNumericIntegerNames.contains(typeName) ? .integer : .text + case .duckdb?: + return typeName == "bit" ? .text : kind + default: + return kind + } + } + + private static let characterNames: Set = ["string", "clob", "nclob", "name", "citext"] + + private static let nonNumericIntegerNames: Set = ["bit", "bool", "boolean"] + private static let integerNames: Set = [ "int", "integer", "tinyint", "smallint", "mediumint", "bigint", "int1", "int2", "int3", "int4", "int8", @@ -42,3 +106,9 @@ nonisolated public enum FilterColumnKind { "float4", "float8", "binary_float", "binary_double" ] } + +nonisolated public enum ComparisonLiteral: Equatable, Sendable { + case number(String) + case text(String) + case numberOrText(String) +} diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift index 24852fc1fe..21b9a7a16f 100644 --- a/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift +++ b/apple/Packages/TableProCore/Sources/TableProQuery/FilterSQLGenerator.swift @@ -1,12 +1,15 @@ import Foundation +import TableProCoreTypes import TableProModels import TableProPluginKit public struct FilterSQLGenerator: Sendable { private let dialect: SQLDialectDescriptor + private let databaseType: DatabaseType? - public init(dialect: SQLDialectDescriptor) { + public init(dialect: SQLDialectDescriptor, databaseType: DatabaseType? = nil) { self.dialect = dialect + self.databaseType = databaseType } public func generateWhereClause( @@ -31,15 +34,22 @@ public struct FilterSQLGenerator: Sendable { } let quotedColumn = quoteIdentifier(filter.columnName) - let kind = FilterColumnKind.kind(of: FilterColumnKind.column(named: filter.columnName, in: columns)) + let column = FilterColumnKind.column(named: filter.columnName, in: columns) + let kind = FilterColumnKind.kind(of: column, databaseType: databaseType) + let eitherStorageClass = FilterColumnKind.comparesEitherStorageClass(column, databaseType: databaseType) let escapedValue = escapeValue(filter.value, kind: kind) let folding = caseFolding(for: filter) + let operand = textOperand(quotedColumn, for: column) switch filter.filterOperator { + case .equal where eitherStorageClass: + return eitherStorageComparison(quotedColumn, filter.value, negated: false) + case .notEqual where eitherStorageClass: + return eitherStorageComparison(quotedColumn, filter.value, negated: true) case .equal: - return comparisonCondition(quotedColumn, "=", escapedValue, folding: folding) + return comparisonCondition(quotedColumn, operand, "=", escapedValue, folding: folding) case .notEqual: - return comparisonCondition(quotedColumn, "!=", escapedValue, folding: folding) + return comparisonCondition(quotedColumn, operand, "!=", escapedValue, folding: folding) case .greaterThan: return "\(quotedColumn) > \(escapedValue)" case .greaterThanOrEqual: @@ -49,29 +59,33 @@ public struct FilterSQLGenerator: Sendable { case .lessThanOrEqual: return "\(quotedColumn) <= \(escapedValue)" case .like: - return likeCondition(quotedColumn, escapedValue, negated: false, folding: folding) + return likeCondition(operand, escapedValue, negated: false, folding: folding) case .notLike: - return likeCondition(quotedColumn, escapedValue, negated: true, folding: folding) + return likeCondition(operand, escapedValue, negated: true, folding: folding) case .isNull: return "\(quotedColumn) IS NULL" case .isNotNull: return "\(quotedColumn) IS NOT NULL" + case .in where eitherStorageClass: + return eitherStorageList(quotedColumn, listItems(filter.value), negated: false) + case .notIn where eitherStorageClass: + return eitherStorageList(quotedColumn, listItems(filter.value), negated: true) case .in: - return listCondition(quotedColumn, filter.value, kind: kind, negated: false, folding: folding) + return listCondition(quotedColumn, operand, filter.value, kind: kind, negated: false, folding: folding) case .notIn: - return listCondition(quotedColumn, filter.value, kind: kind, negated: true, folding: folding) + return listCondition(quotedColumn, operand, filter.value, kind: kind, negated: true, folding: folding) case .between: let escapedSecond = escapeValue(filter.secondValue, kind: kind) return "\(quotedColumn) BETWEEN \(escapedValue) AND \(escapedSecond)" case .contains: let pattern = escapeLikePattern(filter.value) - return likeCondition(quotedColumn, "'%\(pattern)%'", negated: false, folding: folding) + return likeCondition(operand, "'%\(pattern)%'", negated: false, folding: folding) case .startsWith: let pattern = escapeLikePattern(filter.value) - return likeCondition(quotedColumn, "'\(pattern)%'", negated: false, folding: folding) + return likeCondition(operand, "'\(pattern)%'", negated: false, folding: folding) case .endsWith: let pattern = escapeLikePattern(filter.value) - return likeCondition(quotedColumn, "'%\(pattern)'", negated: false, folding: folding) + return likeCondition(operand, "'%\(pattern)'", negated: false, folding: folding) } } @@ -83,13 +97,39 @@ public struct FilterSQLGenerator: Sendable { ) } + // A plain comparison keeps the column's own type and its index; only a case fold reads it as text. private func comparisonCondition( - _ column: String, _ operatorText: String, _ literal: String, folding: PluginSQLCaseFolding + _ column: String, _ operand: String, _ operatorText: String, _ literal: String, folding: PluginSQLCaseFolding ) -> String { guard folding.foldsComparisonOperands, literal.hasPrefix("'") else { return "\(column) \(operatorText) \(literal)" } - return "\(folding.fold(column)) \(operatorText) \(folding.fold(literal))" + return "\(folding.fold(operand)) \(operatorText) \(folding.fold(literal))" + } + + private func eitherStorageComparison(_ column: String, _ value: String, negated: Bool) -> String { + guard FilterColumnKind.isCanonicalSQLiteNumber(value) else { + return "\(column) \(negated ? "!=" : "=") \(escapeValue(value, kind: .text))" + } + return eitherStorageList(column, [value], negated: negated) + } + + private func eitherStorageList(_ column: String, _ values: [String], negated: Bool) -> String { + let literals = values.flatMap { value -> [String] in + let quoted = escapeValue(value, kind: .text) + return FilterColumnKind.isCanonicalSQLiteNumber(value) ? [value, quoted] : [quoted] + } + return "\(column) \(negated ? "NOT IN" : "IN") (\(literals.joined(separator: ", ")))" + } + + // PostgreSQL and DuckDB have no LIKE or lower() for uuid, numbers or enums, and a text cast is valid on every column. + private func textOperand(_ quotedColumn: String, for column: ColumnInfo?) -> String { + guard let castType = dialect.textCastTypeName, !FilterColumnKind.isCharacterType(column) else { return quotedColumn } + return "CAST(\(quotedColumn) AS \(castType))" + } + + private func listItems(_ rawValue: String) -> [String] { + rawValue.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } } private func likeCondition( @@ -100,14 +140,18 @@ public struct FilterSQLGenerator: Sendable { } private func listCondition( - _ column: String, _ rawValue: String, kind: PluginColumnKind?, negated: Bool, folding: PluginSQLCaseFolding + _ column: String, + _ operand: String, + _ rawValue: String, + kind: PluginColumnKind?, + negated: Bool, + folding: PluginSQLCaseFolding ) -> String { let keyword = negated ? "NOT IN" : "IN" - let items = rawValue.split(separator: ",") - .map { escapeValue($0.trimmingCharacters(in: .whitespaces), kind: kind) } + let items = listItems(rawValue).map { escapeValue($0, kind: kind) } let foldable = folding.foldsComparisonOperands && items.allSatisfy { $0.hasPrefix("'") } let rendered = foldable ? items.map { folding.fold($0) } : items - let subject = foldable ? folding.fold(column) : column + let subject = foldable ? folding.fold(operand) : column return "\(subject) \(keyword) (\(rendered.joined(separator: ", ")))" } @@ -128,7 +172,7 @@ public struct FilterSQLGenerator: Sendable { } private func escapeValue(_ value: String, kind: PluginColumnKind?) -> String { - if isBareNumber(value, kind: kind) { + if FilterColumnKind.isBareNumber(value, kind: kind) { return value } var escaped = value.replacingOccurrences(of: "\0", with: "") @@ -139,13 +183,6 @@ public struct FilterSQLGenerator: Sendable { return "'\(escaped)'" } - // Character.isNumber takes digits of every script, and no engine parses those as a number. - // An integer column takes a fraction bare too: PostgreSQL and SQL Server reject `int_col > '5.5'`. - private func isBareNumber(_ value: String, kind: PluginColumnKind?) -> Bool { - guard value.unicodeScalars.allSatisfy(\.isASCII) else { return false } - return kind != .text && PluginNumericLiteral.isValid(value) - } - private func escapeLikePattern(_ value: String) -> String { var result = value .replacingOccurrences(of: "'", with: "''") diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift b/apple/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift deleted file mode 100644 index 4b73ed0c90..0000000000 --- a/apple/Packages/TableProCore/Sources/TableProQuery/SQLStatementGenerator.swift +++ /dev/null @@ -1,66 +0,0 @@ -import Foundation -import TableProPluginKit - -public struct SQLStatementGenerator: Sendable { - private let dialect: SQLDialectDescriptor - - public init(dialect: SQLDialectDescriptor) { - self.dialect = dialect - } - - public func generateInsert(table: String, columns: [String], values: [String?]) -> String { - let quotedTable = quoteIdentifier(table) - let quotedColumns = columns.map { quoteIdentifier($0) }.joined(separator: ", ") - let formattedValues = values.map { formatValue($0) }.joined(separator: ", ") - return "INSERT INTO \(quotedTable) (\(quotedColumns)) VALUES (\(formattedValues))" - } - - public func generateUpdate( - table: String, - changes: [String: String?], - where whereConditions: [String: String] - ) -> String { - let quotedTable = quoteIdentifier(table) - - let setClauses = changes.map { key, value in - "\(quoteIdentifier(key)) = \(formatValue(value))" - }.joined(separator: ", ") - - let whereClauses = whereConditions.map { key, value in - "\(quoteIdentifier(key)) = \(formatWhereValue(value))" - }.joined(separator: " AND ") - - return "UPDATE \(quotedTable) SET \(setClauses) WHERE \(whereClauses)" - } - - public func generateDelete(table: String, where whereConditions: [String: String]) -> String { - let quotedTable = quoteIdentifier(table) - - let whereClauses = whereConditions.map { key, value in - "\(quoteIdentifier(key)) = \(formatWhereValue(value))" - }.joined(separator: " AND ") - - return "DELETE FROM \(quotedTable) WHERE \(whereClauses)" - } - - private func quoteIdentifier(_ name: String) -> String { - let q = dialect.identifierQuote - let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)") - return "\(q)\(escaped)\(q)" - } - - private func formatValue(_ value: String?) -> String { - guard let value else { return "NULL" } - if Int64(value) != nil || Double(value) != nil { - return value - } - let escaped = value - .replacingOccurrences(of: "'", with: "''") - .replacingOccurrences(of: "\0", with: "") - return "'\(escaped)'" - } - - private func formatWhereValue(_ value: String) -> String { - formatValue(value) - } -} diff --git a/apple/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift b/apple/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift deleted file mode 100644 index b6627edc5b..0000000000 --- a/apple/Packages/TableProCore/Sources/TableProQuery/TableQueryBuilder.swift +++ /dev/null @@ -1,140 +0,0 @@ -import Foundation -import TableProModels -import TableProPluginKit - -/// Allows NoSQL drivers to provide custom query building. -public protocol CustomQueryBuilder: Sendable { - func buildBrowseQuery( - table: String, - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - limit: Int, - offset: Int - ) -> String? - - func buildFilteredQuery( - table: String, - filters: [(column: String, op: String, value: String)], - logicMode: String, - sortColumns: [(columnIndex: Int, ascending: Bool)], - columns: [String], - limit: Int, - offset: Int - ) -> String? -} - -public struct TableQueryBuilder: Sendable { - private let dialect: SQLDialectDescriptor? - private let customQueryBuilder: (any CustomQueryBuilder)? - - public init( - dialect: SQLDialectDescriptor? = nil, - customQueryBuilder: (any CustomQueryBuilder)? = nil - ) { - self.dialect = dialect - self.customQueryBuilder = customQueryBuilder - } - - public func buildBrowseQuery( - tableName: String, - sortState: SortState = SortState(), - limit: Int, - offset: Int - ) -> String { - if let builder = customQueryBuilder { - let sortColumns = sortState.columns.enumerated().map { index, col in - (columnIndex: index, ascending: col.ascending) - } - if let query = builder.buildBrowseQuery( - table: tableName, - sortColumns: sortColumns, - columns: [], - limit: limit, - offset: offset - ) { - return query - } - } - - let quoted = quoteIdentifier(tableName) - var sql = "SELECT * FROM \(quoted)" - - if sortState.isSorting { - sql += " " + buildOrderByClause(sortState: sortState) - } - - sql += " " + buildPaginationClause(limit: limit, offset: offset) - return sql - } - - public func buildFilteredQuery( - tableName: String, - filters: [TableFilter], - logicMode: FilterLogicMode = .and, - sortState: SortState = SortState(), - limit: Int, - offset: Int - ) -> String { - if let builder = customQueryBuilder { - let filterTuples = filters.filter { $0.isEnabled && $0.isValid }.map { f in - (column: f.columnName, op: f.filterOperator.sqlSymbol, value: f.value) - } - let sortColumns = sortState.columns.enumerated().map { index, col in - (columnIndex: index, ascending: col.ascending) - } - if let query = builder.buildFilteredQuery( - table: tableName, - filters: filterTuples, - logicMode: logicMode.rawValue, - sortColumns: sortColumns, - columns: [], - limit: limit, - offset: offset - ) { - return query - } - } - - let quoted = quoteIdentifier(tableName) - var sql = "SELECT * FROM \(quoted)" - - if let dialect { - let generator = FilterSQLGenerator(dialect: dialect) - let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) - if !whereClause.isEmpty { - sql += " \(whereClause)" - } - } - - if sortState.isSorting { - sql += " " + buildOrderByClause(sortState: sortState) - } - - sql += " " + buildPaginationClause(limit: limit, offset: offset) - return sql - } - - private func buildOrderByClause(sortState: SortState) -> String { - let parts = sortState.columns.map { col in - "\(quoteIdentifier(col.name)) \(col.ascending ? "ASC" : "DESC")" - } - return "ORDER BY \(parts.joined(separator: ", "))" - } - - private func buildPaginationClause(limit: Int, offset: Int) -> String { - let style = dialect?.paginationStyle ?? .limit - switch style { - case .limit: - return "LIMIT \(limit) OFFSET \(offset)" - case .offsetFetch: - let orderBy = dialect?.offsetFetchOrderBy ?? "ORDER BY (SELECT NULL)" - return "\(orderBy) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" - } - } - - private func quoteIdentifier(_ name: String) -> String { - let q = dialect?.identifierQuote ?? "\"" - let escaped = name.replacingOccurrences(of: q, with: "\(q)\(q)") - return "\(q)\(escaped)\(q)" - } -} diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift index 189a9dad26..bcca76ff0a 100644 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProQueryTests/FilterSQLGeneratorTests.swift @@ -315,4 +315,132 @@ struct FilterSQLGeneratorTests { func typeNameKind(typeName: String, kind: PluginColumnKind) { #expect(FilterColumnKind.kind(forTypeName: typeName) == kind) } + + private func engineClause( + _ type: DatabaseType, + _ column: ColumnInfo, + _ value: String, + op filterOperator: FilterOperator = .equal, + isCaseSensitive: Bool? = nil, + dialect: SQLDialectDescriptor? = nil + ) -> String { + FilterSQLGenerator(dialect: dialect ?? self.dialect, databaseType: type).generateWhereClause( + from: [TableFilter( + columnName: column.name, filterOperator: filterOperator, value: value, isCaseSensitive: isCaseSensitive + )], + logicMode: .and, + columns: [column] + ) + } + + // Measured on PostgreSQL 17: `bit = 101` and `boolean = 1` have no operator, and `real = 0.1` finds no row. + @Test("PostgreSQL compares every column but an integer one to a quoted literal", + arguments: ["bit(3)", "bit", "boolean", "real", "double precision", "numeric(10,2)"]) + func postgresQuotesNonIntegerColumns(typeName: String) { + let column = ColumnInfo(name: "v", typeName: typeName) + #expect(engineClause(.postgresql, column, "1") == "WHERE \"v\" = '1'") + #expect(engineClause(.redshift, column, "1") == "WHERE \"v\" = '1'") + } + + @Test("A PostgreSQL integer column keeps a bare number, a fraction included") + func postgresIntegerStaysBare() { + let column = ColumnInfo(name: "id", typeName: "integer") + #expect(engineClause(.postgresql, column, "5") == "WHERE \"id\" = 5") + #expect(engineClause(.postgresql, column, "5.5", op: .greaterThan) == "WHERE \"id\" > 5.5") + } + + // DuckDB runs `bit = 101` and returns no row; MySQL 8.4 and MariaDB 13 return no row for `bit = '5'`. + @Test("A BIT column is a bit string on DuckDB and a number on MySQL") + func bitFollowsTheEngine() { + let bit = ColumnInfo(name: "b", typeName: "BIT") + #expect(engineClause(.duckdb, bit, "101") == "WHERE \"b\" = '101'") + #expect(engineClause(.mysql, ColumnInfo(name: "b", typeName: "bit(8)"), "5") == "WHERE \"b\" = 5") + #expect(engineClause(.duckdb, ColumnInfo(name: "f", typeName: "FLOAT"), "0.1") == "WHERE \"f\" = 0.1") + } + + // SQLite keeps integer 5 and text '5' apart in a column with no affinity, and the grid shows both as 5. + @Test("A SQLite column with no affinity matches a number either way it was stored", arguments: ["", "BLOB", "ANY"]) + func sqliteUntypedMatchesEitherStorageClass(typeName: String) { + let column = ColumnInfo(name: "v", typeName: typeName) + #expect(engineClause(.sqlite, column, "5") == "WHERE \"v\" IN (5, '5')") + #expect(engineClause(.sqlite, column, "5", op: .notEqual) == "WHERE \"v\" NOT IN (5, '5')") + #expect(engineClause(.sqlite, column, "5, abc, 05", op: .in) == "WHERE \"v\" IN (5, '5', 'abc', '05')") + #expect(engineClause(.sqlite, column, "-2.5", op: .notIn) == "WHERE \"v\" NOT IN (-2.5, '-2.5')") + #expect(engineClause(.sqlite, column, "02134") == "WHERE \"v\" = '02134'") + #expect(engineClause(.sqlite, column, "5", op: .greaterThan) == "WHERE \"v\" > 5") + } + + @Test("A typed SQLite column keeps its literal") + func sqliteTypedColumnUnchanged() { + #expect(engineClause(.sqlite, ColumnInfo(name: "id", typeName: "INTEGER"), "5") == "WHERE \"id\" = 5") + #expect(engineClause(.sqlite, ColumnInfo(name: "code", typeName: "TEXT"), "5") == "WHERE \"code\" = '5'") + } + + @Test("A number SQLite prints the same way is canonical", arguments: [ + ("5", true), ("-5", true), ("0", true), ("2.5", true), ("7.0", true), ("0.1", true), + ("05", false), ("+5", false), ("-0", false), ("5.50", false), ("1e3", false), (".5", false), + ("99999999999999999999", false), ("abc", false), ("", false) + ]) + func canonicalSQLiteNumber(value: String, isCanonical: Bool) { + #expect(FilterColumnKind.isCanonicalSQLiteNumber(value) == isCanonical) + } + + private var castingDialect: SQLDialectDescriptor { + SQLDialectDescriptor( + identifierQuote: "\"", keywords: [], functions: [], dataTypes: [], + likeEscapeStyle: .explicit, caseSensitivityStyle: .ilikeOperator, textCastTypeName: "TEXT" + ) + } + + // PostgreSQL 17 and DuckDB 1.5: `numeric ~~* unknown`, `lower(uuid)` and `~~(INTEGER, ...)` do not exist. + @Test("Pattern and case-folded matches read a non-character column as text") + func nonCharacterColumnIsCastForPatterns() { + let uuid = ColumnInfo(name: "u", typeName: "uuid") + let dialect = castingDialect + #expect(engineClause(.postgresql, uuid, "a0", op: .contains, dialect: dialect) + == "WHERE CAST(\"u\" AS TEXT) ILIKE '%a0%' ESCAPE '!'") + #expect(engineClause(.postgresql, uuid, "A0", isCaseSensitive: false, dialect: dialect) + == "WHERE LOWER(CAST(\"u\" AS TEXT)) = LOWER('A0')") + #expect(engineClause(.postgresql, uuid, "A0", dialect: dialect) == "WHERE \"u\" = 'A0'") + #expect(engineClause(.postgresql, ColumnInfo(name: "mood", typeName: "USER-DEFINED"), "a, b", op: .in, + isCaseSensitive: false, dialect: dialect) + == "WHERE LOWER(CAST(\"mood\" AS TEXT)) IN (LOWER('a'), LOWER('b'))") + #expect(engineClause(.postgresql, ColumnInfo(name: "n", typeName: "numeric"), "5", op: .startsWith, dialect: dialect) + == "WHERE CAST(\"n\" AS TEXT) ILIKE '5%' ESCAPE '!'") + } + + @Test("A character column keeps its own operand", arguments: ["character varying", "text", "citext", "bpchar", "name"]) + func characterColumnIsNotCast(typeName: String) { + let column = ColumnInfo(name: "c", typeName: typeName) + #expect(engineClause(.postgresql, column, "x", op: .contains, dialect: castingDialect) + == "WHERE \"c\" ILIKE '%x%' ESCAPE '!'") + } + + @Test("Engine type names map to a column kind", arguments: [ + ("bit(3)", DatabaseType.postgresql, PluginColumnKind.text), + ("boolean", .postgresql, .text), + ("real", .postgresql, .text), + ("bigint", .postgresql, .integer), + ("smallint", .redshift, .integer), + ("BIT", .duckdb, .text), + ("BOOLEAN", .duckdb, .integer), + ("FLOAT", .duckdb, .decimal), + ("bit(8)", .mysql, .integer), + ("bit", .mssql, .integer), + ("REAL", .sqlite, .decimal) + ]) + func engineTypeNameKind(typeName: String, type: DatabaseType, kind: PluginColumnKind) { + #expect(FilterColumnKind.kind(forTypeName: typeName, databaseType: type) == kind) + } + + @Test("A key value takes the literal its column's engine compares exactly") + func keyLiterals() { + #expect(FilterColumnKind.keyLiteral("5", column: nil, databaseType: .postgresql) == .text("5")) + #expect(FilterColumnKind.keyLiteral("5", column: ColumnInfo(name: "id", typeName: "int"), databaseType: .postgresql) + == .number("5")) + #expect(FilterColumnKind.keyLiteral("5", column: ColumnInfo(name: "b", typeName: "bit(8)"), databaseType: .mysql) + == .number("5")) + #expect(FilterColumnKind.keyLiteral("5", column: nil, databaseType: .sqlite) == .numberOrText("5")) + #expect(FilterColumnKind.keyLiteral("05", column: nil, databaseType: .sqlite) == .text("05")) + } } diff --git a/apple/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift b/apple/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift deleted file mode 100644 index ee359fcc37..0000000000 --- a/apple/Packages/TableProCore/Tests/TableProQueryTests/TableQueryBuilderTests.swift +++ /dev/null @@ -1,85 +0,0 @@ -import Foundation -@testable import TableProModels -@testable import TableProPluginKit -@testable import TableProQuery -import Testing - -@Suite("TableQueryBuilder Tests") -struct TableQueryBuilderTests { - private var dialect: SQLDialectDescriptor { - SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [], - functions: [], - dataTypes: [], - paginationStyle: .limit - ) - } - - @Test("Basic browse query") - func basicBrowse() { - let builder = TableQueryBuilder(dialect: dialect) - let query = builder.buildBrowseQuery(tableName: "users", limit: 100, offset: 0) - #expect(query == "SELECT * FROM \"users\" LIMIT 100 OFFSET 0") - } - - @Test("Browse query with sort") - func browseWithSort() { - let builder = TableQueryBuilder(dialect: dialect) - let sort = SortState(columns: [SortColumn(name: "name", ascending: true)]) - let query = builder.buildBrowseQuery(tableName: "users", sortState: sort, limit: 50, offset: 10) - #expect(query.contains("ORDER BY \"name\" ASC")) - #expect(query.contains("LIMIT 50 OFFSET 10")) - } - - @Test("Browse query with descending sort") - func browseWithDescSort() { - let builder = TableQueryBuilder(dialect: dialect) - let sort = SortState(columns: [SortColumn(name: "created_at", ascending: false)]) - let query = builder.buildBrowseQuery(tableName: "posts", sortState: sort, limit: 20, offset: 0) - #expect(query.contains("ORDER BY \"created_at\" DESC")) - } - - @Test("Offset-fetch pagination style") - func offsetFetchPagination() { - let offsetDialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [], - functions: [], - dataTypes: [], - paginationStyle: .offsetFetch, - offsetFetchOrderBy: "ORDER BY (SELECT NULL)" - ) - let builder = TableQueryBuilder(dialect: offsetDialect) - let query = builder.buildBrowseQuery(tableName: "users", limit: 50, offset: 100) - #expect(query.contains("OFFSET 100 ROWS FETCH NEXT 50 ROWS ONLY")) - } - - @Test("Filtered query generates WHERE clause") - func filteredQuery() { - let builder = TableQueryBuilder(dialect: dialect) - let filters = [TableFilter(columnName: "active", filterOperator: .equal, value: "1")] - let query = builder.buildFilteredQuery( - tableName: "users", - filters: filters, - limit: 100, - offset: 0 - ) - #expect(query.contains("WHERE")) - #expect(query.contains("\"active\"")) - } - - @Test("No dialect falls back to LIMIT pagination") - func noDialectFallback() { - let builder = TableQueryBuilder() - let query = builder.buildBrowseQuery(tableName: "test", limit: 10, offset: 5) - #expect(query.contains("LIMIT 10 OFFSET 5")) - } - - @Test("Table name with special characters is quoted") - func specialTableName() { - let builder = TableQueryBuilder(dialect: dialect) - let query = builder.buildBrowseQuery(tableName: "my table", limit: 10, offset: 0) - #expect(query.contains("\"my table\"")) - } -} diff --git a/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift index 783123db5d..30449db31f 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift @@ -421,20 +421,25 @@ private actor SQLiteActor { var rows: [[String?]] = [] let maxRows = 100_000 + var storageClasses = Array(repeating: Set(), count: Int(colCount)) let totalChangesBefore = sqlite3_total_changes64(db) var stepResult = sqlite3_step(stmt) while stepResult == SQLITE_ROW { if rows.count >= maxRows { - return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, + return RawResult(columns: columns, columnTypes: Self.reportedTypes(columnTypes, storageClasses), rows: rows, rowsAffected: 0, executionTime: Date().timeIntervalSince(start), isTruncated: true) } var row: [String?] = [] for i in 0.. 0, let ptr = sqlite3_column_blob(stmt, i) { row.append(Data(bytes: ptr, count: bytes).base64EncodedString()) @@ -455,10 +460,18 @@ private actor SQLiteActor { } let affected = columns.isEmpty ? changes(of: db, sinceTotal: totalChangesBefore) : 0 - return RawResult(columns: columns, columnTypes: columnTypes, rows: rows, + return RawResult(columns: columns, columnTypes: Self.reportedTypes(columnTypes, storageClasses), rows: rows, rowsAffected: affected, executionTime: Date().timeIntervalSince(start), isTruncated: false) } + // A blob reaches the caller as base64 text, so a column with no declared type says BLOB when every value in it is one. + private static func reportedTypes(_ declared: [String], _ storageClasses: [Set]) -> [String] { + declared.enumerated().map { index, type in + guard type.isEmpty, index < storageClasses.count, storageClasses[index] == [SQLITE_BLOB] else { return type } + return "BLOB" + } + } + /// `sqlite3_changes64` keeps the count of the last INSERT, UPDATE or DELETE, so a statement that /// wrote nothing, DDL included, would report it again. The total moves only when this one wrote. private func changes(of db: OpaquePointer, sinceTotal totalBefore: Int64) -> Int { diff --git a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index a1daab8045..daa51288c0 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -54,21 +54,36 @@ nonisolated enum SQLBuilder { table: String, schema: String?, key: [(column: String, value: String)], + columns: [ColumnInfo], type: DatabaseType, driver: any DatabaseDriver ) -> String { let quotedTable = qualifiedIdentifier(table: table, schema: schema, for: type) - let predicate = key.isEmpty ? "1 = 0" : keyPredicate(key, type: type, driver: driver) + let predicate = key.isEmpty ? "1 = 0" : keyPredicate(key, columns: columns, type: type, driver: driver) let pagination = paginationClause(orderBy: "", limit: 1, offset: 0, for: type) return "SELECT * FROM \(quotedTable) WHERE \(predicate) \(pagination)" } - // Through the driver's escaper: on MySQL a backslash escapes the next quote, so a hand-doubled `a\' OR 1=1 -- ` gets out. - private static func keyPredicate( - _ key: [(column: String, value: String)], type: DatabaseType, driver: any DatabaseDriver + // The literal rule the table browser's filter uses, so a row the lookup finds is the row Open Table lists. + // Text goes through the driver's escaper: on MySQL a backslash escapes the next quote, so a hand-doubled + // `a\' OR 1=1 -- ` gets out. + static func keyPredicate( + _ key: [(column: String, value: String)], + columns: [ColumnInfo], + type: DatabaseType, + driver: any DatabaseDriver ) -> String { - key.map { - "\(quoteIdentifier($0.column, for: type)) = '\(driver.escapeStringLiteral($0.value))'" + key.map { pair in + let column = quoteIdentifier(pair.column, for: type) + let info = FilterColumnKind.column(named: pair.column, in: columns) + switch FilterColumnKind.keyLiteral(pair.value, column: info, databaseType: type) { + case .number(let value): + return "\(column) = \(value)" + case .text(let value): + return "\(column) = '\(driver.escapeStringLiteral(value))'" + case .numberOrText(let value): + return "\(column) IN (\(value), '\(value)')" + } }.joined(separator: " AND ") } @@ -77,10 +92,11 @@ nonisolated enum SQLBuilder { schema: String?, type: DatabaseType, driver: any DatabaseDriver, - primaryKeys: [(column: String, value: String)] + primaryKeys: [(column: String, value: String)], + columns: [ColumnInfo] ) -> String { let quotedTable = qualifiedIdentifier(table: table, schema: schema, for: type) - let predicate = keyPredicate(primaryKeys, type: type, driver: driver) + let predicate = keyPredicate(primaryKeys, columns: columns, type: type, driver: driver) return "DELETE FROM \(quotedTable) WHERE \(predicate)" } @@ -90,7 +106,8 @@ nonisolated enum SQLBuilder { type: DatabaseType, driver: any DatabaseDriver, changes: [(column: String, value: String?)], - primaryKeys: [(column: String, value: String)] + primaryKeys: [(column: String, value: String)], + columns: [ColumnInfo] ) -> String { let quotedTable = qualifiedIdentifier(table: table, schema: schema, for: type) let assignments = changes.map { col, val in @@ -98,7 +115,7 @@ nonisolated enum SQLBuilder { if let val { return "\(qcol) = '\(driver.escapeStringLiteral(val))'" } return "\(qcol) = NULL" }.joined(separator: ", ") - let predicate = keyPredicate(primaryKeys, type: type, driver: driver) + let predicate = keyPredicate(primaryKeys, columns: columns, type: type, driver: driver) return "UPDATE \(quotedTable) SET \(assignments) WHERE \(predicate)" } @@ -163,7 +180,7 @@ nonisolated enum SQLBuilder { limit: Int, offset: Int ) -> String { let dialect = dialectDescriptor(for: type) - let generator = FilterSQLGenerator(dialect: dialect) + let generator = FilterSQLGenerator(dialect: dialect, databaseType: type) let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) let pagination = paginationClause(orderBy: "", limit: limit, offset: offset, for: type) @@ -181,7 +198,7 @@ nonisolated enum SQLBuilder { limit: Int, offset: Int ) -> String { let dialect = dialectDescriptor(for: type) - let generator = FilterSQLGenerator(dialect: dialect) + let generator = FilterSQLGenerator(dialect: dialect, databaseType: type) let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) let orderBy = buildOrderByClause(sortState, for: type) let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) @@ -198,7 +215,7 @@ nonisolated enum SQLBuilder { columns: [ColumnInfo] = [] ) -> String { let dialect = dialectDescriptor(for: type) - let generator = FilterSQLGenerator(dialect: dialect) + let generator = FilterSQLGenerator(dialect: dialect, databaseType: type) let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) let quoted = qualifiedIdentifier(table: table, schema: schema, for: type) if whereClause.isEmpty { @@ -272,7 +289,7 @@ nonisolated enum SQLBuilder { filters: [TableFilter], logicMode: FilterLogicMode, columns: [ColumnInfo], type: DatabaseType ) -> String? { let dialect = dialectDescriptor(for: type) - let generator = FilterSQLGenerator(dialect: dialect) + let generator = FilterSQLGenerator(dialect: dialect, databaseType: type) let clause = generator.generateWhereClause(from: filters, logicMode: logicMode, columns: columns) guard !clause.isEmpty else { return nil } let wherePrefix = "WHERE " @@ -373,7 +390,8 @@ nonisolated enum SQLBuilder { functions: [], dataTypes: [], likeEscapeStyle: .explicit, - caseSensitivityStyle: .ilikeOperator + caseSensitivityStyle: .ilikeOperator, + textCastTypeName: "TEXT" ) case .redshift: return SQLDialectDescriptor( @@ -382,7 +400,8 @@ nonisolated enum SQLBuilder { functions: [], dataTypes: [], likeEscapeStyle: .explicit, - caseSensitivityStyle: .caseFoldFunction + caseSensitivityStyle: .caseFoldFunction, + textCastTypeName: "TEXT" ) case .mssql: return SQLDialectDescriptor( @@ -421,7 +440,8 @@ nonisolated enum SQLBuilder { booleanLiteralStyle: .truefalse, likeEscapeStyle: .explicit, paginationStyle: .limit, - caseSensitivityStyle: .ilikeOperator + caseSensitivityStyle: .ilikeOperator, + textCastTypeName: "VARCHAR" ) default: return SQLDialectDescriptor( diff --git a/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 0351de3f79..10378dbf39 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -54,7 +54,6 @@ final class DataBrowserViewModel { @ObservationIgnored private var keyReadTask: Task? @ObservationIgnored private var loadGeneration = 0 @ObservationIgnored private var hasSeededFilters = false - @ObservationIgnored private var seededKeyColumns: [String] = [] @ObservationIgnored private var singleRowMatch = SingleRowMatch.inactive private enum SingleRowMatch { @@ -115,7 +114,6 @@ final class DataBrowserViewModel { guard !hasSeededFilters else { return } hasSeededFilters = true filters = seeded - seededKeyColumns = seeded.map(\.columnName) filterLogicMode = .and singleRowMatch = hasActiveFilters ? .awaitingFirstLoad : .inactive } @@ -171,15 +169,6 @@ final class DataBrowserViewModel { singleRowMatch = loadError == nil && legacyRows.count == 1 ? .found : .inactive } - // The preview quotes every key value, and a column without a type (a typeless SQLite key) keeps 5 and '5' - // apart, so such a seeded key column compares as text here too and the browser lists the previewed row. - private var filterColumns: [ColumnInfo] { - let untyped = seededKeyColumns.filter { - FilterColumnKind.kind(of: FilterColumnKind.column(named: $0, in: columnDetails)) == nil - } - return untyped.map { ColumnInfo(name: $0, typeName: "TEXT") } + columnDetails - } - private func loadRows(session: ConnectionSession, table: TableInfo, isInitial: Bool, generation: Int) async { do { if columnDetails.isEmpty || isInitial { @@ -282,7 +271,7 @@ final class DataBrowserViewModel { return SQLBuilder.buildSearchSelect( table: table.name, schema: schema, type: databaseType, searchText: activeSearchText, searchColumns: searchableColumns(), - filters: filters, logicMode: filterLogicMode, columns: filterColumns, + filters: filters, logicMode: filterLogicMode, columns: columnDetails, sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) @@ -290,7 +279,7 @@ final class DataBrowserViewModel { if hasActiveFilters { return SQLBuilder.buildFilteredSelect( table: table.name, schema: schema, type: databaseType, - filters: filters, logicMode: filterLogicMode, columns: filterColumns, + filters: filters, logicMode: filterLogicMode, columns: columnDetails, sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) @@ -334,12 +323,12 @@ final class DataBrowserViewModel { countQuery = SQLBuilder.buildSearchCount( table: table.name, schema: schema, type: databaseType, searchText: activeSearchText, searchColumns: searchableColumns(), - filters: filters, logicMode: filterLogicMode, columns: filterColumns + filters: filters, logicMode: filterLogicMode, columns: columnDetails ) } else if hasActiveFilters { countQuery = SQLBuilder.buildFilteredCount( table: table.name, schema: schema, type: databaseType, - filters: filters, logicMode: filterLogicMode, columns: filterColumns + filters: filters, logicMode: filterLogicMode, columns: columnDetails ) } else { countQuery = SQLBuilder.buildCount(table: table.name, schema: schema, type: databaseType) @@ -450,7 +439,8 @@ final class DataBrowserViewModel { schema: schema, type: databaseType, driver: session.driver, - primaryKeys: pkValues + primaryKeys: pkValues, + columns: columnDetails ) ]) await load() @@ -475,10 +465,12 @@ final class DataBrowserViewModel { // MARK: - Lazy Cell Loading func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType) async throws -> String? { - let predicates = ref.primaryKey.map { component in - "\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(driver.escapeStringLiteral(component.value))'" - } - let predicate = predicates.joined(separator: " AND ") + let predicate = SQLBuilder.keyPredicate( + ref.primaryKey.map { (column: $0.column, value: $0.value) }, + columns: columnDetails, + type: databaseType, + driver: driver + ) let column = SQLBuilder.quoteIdentifier(ref.column, for: databaseType) let table = SQLBuilder.qualifiedIdentifier(table: ref.table, schema: schema, for: databaseType) let query: String diff --git a/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift index 4703ba4684..d5a624d758 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/ForeignKeyPreviewModel.swift @@ -78,10 +78,20 @@ final class ForeignKeyPreviewModel { defer { isRunning = false } let driver = session.driver + // The key columns' types pick each literal, as they do for the filter Open Table runs. + var keyColumns: [ColumnInfo] = [] + do { + keyColumns = try await driver.fetchColumns(table: reference.table, schema: reference.schema) + } catch is CancellationError { + return + } catch { + Self.logger.warning("Foreign key preview could not read columns: \(error.localizedDescription, privacy: .private)") + } let query = SQLBuilder.buildSingleRowLookup( table: reference.table, schema: reference.schema, key: reference.parts.map { (column: $0.column, value: $0.value) }, + columns: keyColumns, type: databaseType, driver: driver ) diff --git a/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index a18e0f93d0..a544ba363d 100644 --- a/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/apple/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -295,7 +295,8 @@ final class RowDetailViewModel { type: databaseType, driver: session.driver, changes: changes, - primaryKeys: pkValues + primaryKeys: pkValues, + columns: columnDetails ) switch writeGate.submit(sql, under: safeModeLevel) { diff --git a/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index cc306eba75..f0c922af18 100644 --- a/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -220,6 +220,27 @@ struct DataBrowserViewModelTests { #expect(driver.executedQueries == [#"SELECT "body" FROM "crm"."notes" WHERE "id" = '7' LIMIT 1"#]) } + @Test("A full value behind a key column with no type is read whichever way SQLite stored the key") + func fullValueThroughUntypedKey() async throws { + let columns = [ + ColumnInfo(name: "id", typeName: "", isPrimaryKey: true, isNullable: false, ordinalPosition: 0), + ColumnInfo(name: "body", typeName: "TEXT", ordinalPosition: 1) + ] + let driver = MockDatabaseDriver() + driver.scriptedColumns = columns + driver.scriptedExecuteResults = [ + .success(QueryResult(columns: columns, rows: [["5", "short"]], rowsAffected: 0, executionTime: 0)) + ] + let vm = DataBrowserViewModel() + vm.attach(session: makeSession(driver: driver), table: TableInfo(name: "notes"), databaseType: .sqlite, host: "") + await vm.load(isInitial: true) + let ref = CellRef(table: "notes", column: "body", primaryKey: [.init(column: "id", value: "5")]) + + _ = try await vm.loadFullValue(driver: driver, ref: ref, databaseType: .sqlite) + + #expect(driver.executedQueries.last == #"SELECT "body" FROM "notes" WHERE "id" IN (5, '5') LIMIT 1"#) + } + @Test("A seeded filter is already in the first SELECT, and only the first seed counts") func seededFilterShapesFirstSelect() async { let driver = MockDatabaseDriver() @@ -309,8 +330,10 @@ struct DataBrowserViewModelTests { #expect(unseeded.takeSingleRowMatch() == false) } - @Test("A seeded key on a column without a type is quoted as the preview quotes it", arguments: ["", "BLOB", "ANY"]) - func untypedKeyColumnIsQuoted(typeName: String) async { + // The preview's lookup uses the same rule, so Open Table lists the row the preview showed. + @Test("A seeded key and a user filter on a column without a type match the number either way it was stored", + arguments: ["", "BLOB", "ANY"]) + func untypedKeyColumnMatchesEitherStorageClass(typeName: String) async { let columns = [ ColumnInfo(name: "code", typeName: typeName, ordinalPosition: 0), ColumnInfo(name: "label", typeName: "TEXT", ordinalPosition: 1) @@ -325,9 +348,7 @@ struct DataBrowserViewModelTests { vm.seedFilters([TableFilter(columnName: "CODE", value: "5")]) await vm.load(isInitial: true) - let select = driver.executedQueries.first ?? "" - #expect(select.contains("\"CODE\" = '5'")) - #expect(!select.contains("= 5")) + #expect((driver.executedQueries.first ?? "").contains("\"CODE\" IN (5, '5')")) #expect(vm.takeSingleRowMatch()) let userDriver = MockDatabaseDriver() @@ -336,7 +357,7 @@ struct DataBrowserViewModelTests { unseeded.attach(session: makeSession(driver: userDriver), table: TableInfo(name: "parents"), databaseType: .sqlite, host: "") unseeded.filters = [TableFilter(columnName: "code", value: "5")] await unseeded.load(isInitial: true) - #expect((userDriver.executedQueries.first ?? "").contains("\"code\" = 5")) + #expect((userDriver.executedQueries.first ?? "").contains("\"code\" IN (5, '5')")) } @Test("A load overtaken during its row count leaves the newer load's total and loading state alone") diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift index 56fe48cd22..5dcfb8f10b 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/SQLiteDriverFileTests.swift @@ -286,6 +286,83 @@ struct SQLiteDriverFileTests { #expect(reference?.parts == [ForeignKeyReference.Part(column: "id", value: "2")]) } + // A column declared with no type keeps integer 5 and text '6' apart, so a quoted key missed the integer row. + @Test("A key column with no type finds its row whether it holds an integer or text") + func untypedKeyFindsEitherStorageClass() async throws { + let driver = try await connectedDriver("untyped-key.db") + _ = try await driver.execute(query: "CREATE TABLE p (id PRIMARY KEY, name)") + _ = try await driver.execute(query: "INSERT INTO p VALUES (5, 'integer'), ('6', 'text'), ('05', 'padded'), (7, 'seven')") + let columns = try await driver.fetchColumns(table: "p", schema: nil) + + var names: [String: String] = [:] + for value in ["5", "6", "05"] { + let query = SQLBuilder.buildSingleRowLookup( + table: "p", schema: nil, key: [(column: "id", value: value)], + columns: columns, type: .sqlite, driver: driver + ) + let rows = try await driver.execute(query: query).rows + names[value] = rows.count == 1 ? rows[0][1] : nil + } + try await driver.disconnect() + + #expect(names == ["5": "integer", "6": "text", "05": "padded"]) + } + + @Test("Update and delete through a key column with no type reach the integer row and only it") + func untypedKeyUpdatesAndDeletes() async throws { + let driver = try await connectedDriver("untyped-write.db") + _ = try await driver.execute(query: "CREATE TABLE p (id PRIMARY KEY, name)") + _ = try await driver.execute(query: "INSERT INTO p VALUES (5, 'before'), (7, 'kept')") + let columns = try await driver.fetchColumns(table: "p", schema: nil) + let key = [(column: "id", value: "5")] + + try await driver.executeWrite([SQLBuilder.buildUpdate( + table: "p", schema: nil, type: .sqlite, driver: driver, + changes: [(column: "name", value: "after")], primaryKeys: key, columns: columns + )]) + let updated = try await driver.execute(query: "SELECT name FROM p WHERE id = 5") + try await driver.executeWrite([SQLBuilder.buildDelete( + table: "p", schema: nil, type: .sqlite, driver: driver, primaryKeys: key, columns: columns + )]) + let remaining = try await driver.execute(query: "SELECT id, name FROM p") + try await driver.disconnect() + + #expect((updated.rows.first?.first ?? nil) == "after") + #expect(remaining.rows == [["7", "kept"]]) + } + + @Test("A result column with no declared type that holds only blobs reads as BLOB") + func untypedBlobColumnReadsAsBlob() async throws { + let driver = try await connectedDriver("untyped-blob.db") + _ = try await driver.execute(query: "CREATE TABLE p (id INTEGER PRIMARY KEY, tag, note)") + _ = try await driver.execute(query: "INSERT INTO p VALUES (1, x'0102', 'a'), (2, NULL, x'03')") + let result = try await driver.execute(query: "SELECT tag, note, id FROM p ORDER BY id") + try await driver.disconnect() + + #expect(result.columns.map(\.typeName) == ["BLOB", "", "INTEGER"]) + } + + @MainActor + @Test("A preview finds a row behind a key with no type, and a blob in a key column with no type is no link") + func previewOverUntypedKeys() async throws { + let driver = try await connectedDriver("untyped-preview.db") + _ = try await driver.execute(query: "CREATE TABLE q (k PRIMARY KEY)") + _ = try await driver.execute(query: "CREATE TABLE p (id PRIMARY KEY, tag REFERENCES q (k))") + _ = try await driver.execute(query: "INSERT INTO q VALUES (x'0102')") + _ = try await driver.execute(query: "INSERT INTO p VALUES (5, x'0102')") + let model = ForeignKeyPreviewModel( + reference: ForeignKeyReference(table: "p", schema: nil, parts: [.init(column: "id", value: "5")]), + session: ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "main"), + databaseType: .sqlite + ) + await model.load() + try await driver.disconnect() + + #expect(model.isLoaded) + #expect(model.fields.map(\.name) == ["id", "tag"]) + #expect(model.fields.allSatisfy { $0.reference == nil }) + } + // MARK: - Helpers private static let createStudent = """ diff --git a/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift b/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift index 99541cd5a7..64201edb24 100644 --- a/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ForeignKeyPreviewModelTests.swift @@ -72,13 +72,45 @@ struct ForeignKeyPreviewModelTests { #expect(query == SQLBuilder.buildSingleRowLookup( table: "tenant_users", schema: "crm", key: [(column: "tenant_id", value: "2"), (column: "id", value: "5")], - type: .postgresql, driver: driver + columns: [], type: .postgresql, driver: driver )) #expect(query.hasPrefix("SELECT * FROM \"crm\".\"tenant_users\"")) #expect(query.contains("\"tenant_id\" = '2'")) #expect(query.contains("\"id\" = '5'")) } + // MySQL finds no row for `bit_col = '5'`, and the browser's filter compares the same column bare. + @Test("The lookup reads the referenced table's columns first and takes each literal from their types") + func lookupTakesLiteralsFromTheColumns() async throws { + let driver = MockDatabaseDriver() + driver.scriptedColumns = [ + ColumnInfo(name: "flags", typeName: "bit(8)", ordinalPosition: 0), + ColumnInfo(name: "code", typeName: "varchar(10)", ordinalPosition: 1) + ] + let reference = ForeignKeyReference(table: "parents", schema: "crm", parts: [ + ForeignKeyReference.Part(column: "flags", value: "5"), + ForeignKeyReference.Part(column: "code", value: "02134") + ]) + let model = ForeignKeyPreviewModel(reference: reference, session: Self.session(driver), databaseType: .mysql) + await model.load() + + #expect(driver.fetchColumnsSchemas == ["crm"]) + let query = try #require(driver.executedQueries.first) + #expect(query.contains("WHERE `flags` = 5 AND `code` = '02134' ")) + } + + @Test("A failed column read still looks the row up, with every value quoted") + func columnReadFailureQuotesTheKey() async throws { + let driver = Self.driver(rows: [Self.userRow]) + driver.fetchColumnsError = MockDatabaseDriver.MockError.scripted + let model = Self.model(driver) + await model.load() + + #expect(model.isLoaded) + let query = try #require(driver.executedQueries.first) + #expect(query.contains("\"tenant_id\" = '2' AND \"id\" = '5'")) + } + @Test("The referenced table's keys are read in the reference's schema, and chained links resolve against it") func chainedLinksUseTheReferenceSchema() async throws { let driver = Self.driver(rows: [Self.userRow]) diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift index 28f0ec8447..b82ed3779d 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderFilterLiteralTests.swift @@ -69,4 +69,32 @@ struct SQLBuilderFilterLiteralTests { table: "files", schema: nil, type: .mysql, filters: filters, logicMode: .and ) == "SELECT COUNT(*) FROM `files` WHERE `path` = 'C:\\\\temp\\\\'") } + + private func count(_ type: DatabaseType, _ filter: TableFilter, _ column: ColumnInfo) -> String { + SQLBuilder.buildFilteredCount(table: "t", schema: nil, type: type, filters: [filter], logicMode: .and, columns: [column]) + } + + // Open Table on a PostgreSQL bit(3) key ran `"b" = 101`, which has no operator, and the browser showed the error. + @Test("A PostgreSQL bit or boolean filter compares a quoted value") + func postgresBitAndBooleanAreQuoted() { + let bit = ColumnInfo(name: "b", typeName: "bit") + #expect(count(.postgresql, TableFilter(columnName: "b", value: "101"), bit) == "SELECT COUNT(*) FROM \"t\" WHERE \"b\" = '101'") + let flag = ColumnInfo(name: "f", typeName: "boolean") + #expect(count(.postgresql, TableFilter(columnName: "f", value: "1"), flag) == "SELECT COUNT(*) FROM \"t\" WHERE \"f\" = '1'") + } + + @Test("Contains on a non-character column reads it as the engine's text type") + func patternOnNonCharacterColumnIsCast() { + let id = ColumnInfo(name: "id", typeName: "integer") + let contains = TableFilter(columnName: "id", filterOperator: .contains, value: "5") + #expect(count(.postgresql, contains, id).hasSuffix("WHERE CAST(\"id\" AS TEXT) ILIKE '%5%' ESCAPE '!'")) + #expect(count(.redshift, contains, id).hasSuffix("WHERE LOWER(CAST(\"id\" AS TEXT)) LIKE LOWER('%5%') ESCAPE '!'")) + #expect(count(.duckdb, contains, ColumnInfo(name: "id", typeName: "INTEGER")) + .hasSuffix("WHERE CAST(\"id\" AS VARCHAR) ILIKE '%5%' ESCAPE '!'")) + #expect(count(.mysql, contains, id).hasSuffix("WHERE `id` LIKE '%5%'")) + let name = ColumnInfo(name: "name", typeName: "character varying") + #expect(count(.postgresql, TableFilter(columnName: "name", filterOperator: .contains, value: "a"), name) + .hasSuffix("WHERE \"name\" ILIKE '%a%' ESCAPE '!'")) + } } + diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSchemaQualificationTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSchemaQualificationTests.swift index e962d54c4d..9e57a5b478 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSchemaQualificationTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSchemaQualificationTests.swift @@ -23,7 +23,7 @@ struct SQLBuilderSchemaQualificationTests { func deleteIsQualified() { let sql = SQLBuilder.buildDelete( table: "orders", schema: "sales", type: .mssql, driver: driver(), - primaryKeys: [(column: "id", value: "1")] + primaryKeys: [(column: "id", value: "1")], columns: [] ) #expect(sql.hasPrefix("DELETE FROM [sales].[orders] WHERE")) } @@ -33,7 +33,7 @@ struct SQLBuilderSchemaQualificationTests { let sql = SQLBuilder.buildUpdate( table: "orders", schema: "sales", type: .mssql, driver: driver(), changes: [(column: "note", value: "x")], - primaryKeys: [(column: "id", value: "1")] + primaryKeys: [(column: "id", value: "1")], columns: [] ) #expect(sql.hasPrefix("UPDATE [sales].[orders] SET")) } diff --git a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift index f01ef8e440..d40a4e51b8 100644 --- a/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Helpers/SQLBuilderSingleRowLookupTests.swift @@ -15,11 +15,12 @@ struct SQLBuilderSingleRowLookupTests { private func lookup( _ key: [(column: String, value: String)], schema: String? = nil, + columns: [ColumnInfo] = [], type: DatabaseType = .postgresql, backslashEscaping: Bool = false ) -> String { SQLBuilder.buildSingleRowLookup( - table: "parent", schema: schema, key: key, + table: "parent", schema: schema, key: key, columns: columns, type: type, driver: driver(backslashEscaping: backslashEscaping) ) } @@ -92,4 +93,63 @@ struct SQLBuilderSingleRowLookupTests { #expect(ansi.contains("= 'a\\b'")) #expect(backslash.contains("= 'a\\\\b'")) } + + // MySQL 8.4 and MariaDB 13 find no row for `bit_col = '5'`; the grid shows a BIT as its decimal value. + @Test("A MySQL BIT key compares as the number the grid shows") + func mysqlBitKeyIsBare() { + let columns = [ColumnInfo(name: "flags", typeName: "bit(8)")] + #expect(lookup([("flags", "5")], columns: columns, type: .mysql).contains("WHERE `flags` = 5 ")) + } + + // PostgreSQL resolves a quoted literal to the column's type, and has no `bit = integer` or `boolean = integer`. + @Test("PostgreSQL bit, boolean and real keys stay quoted", arguments: ["bit(3)", "boolean", "real"]) + func postgresNonIntegerKeysStayQuoted(typeName: String) { + let columns = [ColumnInfo(name: "k", typeName: typeName)] + #expect(lookup([("k", "1")], columns: columns).contains("WHERE \"k\" = '1' ")) + } + + @Test("An integer key the columns name compares bare") + func integerKeyIsBare() { + let columns = [ColumnInfo(name: "id", typeName: "integer")] + #expect(lookup([("id", "5")], columns: columns).contains("WHERE \"id\" = 5 ")) + } + + @Test("A key column the caller could not read stays quoted off SQLite") + func unreadKeyColumnStaysQuoted() { + #expect(lookup([("id", "5")], type: .mysql).contains("WHERE `id` = '5' ")) + } + + // SQLite keeps integer 5 and text '5' apart in a column with no affinity, and the grid shows both as 5. + @Test("A SQLite key with no affinity matches the number either way it was stored", arguments: ["", "BLOB", "ANY"]) + func sqliteUntypedKeyMatchesEitherStorageClass(typeName: String) { + let columns = [ColumnInfo(name: "id", typeName: typeName)] + #expect(lookup([("id", "5")], columns: columns, type: .sqlite).contains("WHERE \"id\" IN (5, '5') ")) + #expect(lookup([("id", "-5")], type: .sqlite).contains("WHERE \"id\" IN (-5, '-5') ")) + #expect(lookup([("id", "2.5")], type: .sqlite).contains("WHERE \"id\" IN (2.5, '2.5') ")) + } + + @Test("A SQLite value that is not how SQLite prints a number stays quoted", arguments: ["05", "+5", "5.50", "1e3", "abc", "-0"]) + func sqliteNonCanonicalNumberStaysQuoted(value: String) { + #expect(lookup([("id", value)], type: .sqlite).contains("WHERE \"id\" = '\(value)' ")) + } + + @Test("A typed SQLite key keeps its literal") + func sqliteTypedKey() { + let columns = [ColumnInfo(name: "id", typeName: "INTEGER"), ColumnInfo(name: "code", typeName: "TEXT")] + #expect(lookup([("id", "5"), ("code", "5")], columns: columns, type: .sqlite) + .contains("WHERE \"id\" = 5 AND \"code\" = '5' ")) + } + + @Test("Update and delete identify the row with the same literal") + func updateAndDeleteShareTheRule() { + let columns = [ColumnInfo(name: "id", typeName: "")] + let key = [(column: "id", value: "5")] + #expect(SQLBuilder.buildDelete( + table: "t", schema: nil, type: .sqlite, driver: driver(), primaryKeys: key, columns: columns + ) == "DELETE FROM \"t\" WHERE \"id\" IN (5, '5')") + #expect(SQLBuilder.buildUpdate( + table: "t", schema: nil, type: .sqlite, driver: driver(), + changes: [(column: "name", value: "x")], primaryKeys: key, columns: columns + ) == "UPDATE \"t\" SET \"name\" = 'x' WHERE \"id\" IN (5, '5')") + } } diff --git a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift index 8998a650c7..7e8410a7c4 100644 --- a/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift +++ b/apple/TableProMobile/TableProMobileTests/Mocks/MockDatabaseDriver.swift @@ -11,6 +11,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { var scriptedColumns: [ColumnInfo] = [] var scriptedForeignKeys: [ForeignKeyInfo] = [] var fetchForeignKeysError: Error? + var fetchColumnsError: Error? var scriptedTables: [TableInfo] = [] var scriptedDatabases: [String] = [] var scriptedSchemas: [String] = [] @@ -71,6 +72,7 @@ final class MockDatabaseDriver: DatabaseDriver, @unchecked Sendable { func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { fetchColumnsCalls += 1 fetchColumnsSchemas.append(schema) + if let fetchColumnsError { throw fetchColumnsError } return scriptedColumns } From 8149be901c9034fc88fc40a608328014fe11d885 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 12:29:38 +0700 Subject: [PATCH 008/151] fix(ios): finish the connection list follow-ups from #3680 and #3683 --- .../Import/ConnectionImportAnalyzer.swift | 3 +- .../Import/ImportLibrarySnapshot.swift | 10 +- .../TableProModels/DatabaseConnection.swift | 14 +++ .../TableProModels/SSLConfiguration.swift | 42 ++++++- .../TableProSync/SyncRecordMapper.swift | 49 ++++++--- .../ConnectionImportAnalyzerTests.swift | 32 ++++++ .../ImportTestSupport.swift | 6 +- .../DatabaseConnectionSwitchTests.swift | 38 +++++++ .../SSLConfigurationWireTests.swift | 59 ++++++++++ .../SyncRecordMapperSwitchTests.swift | 103 ++++++++++++++++++ .../SyncRecordMapperTests.swift | 22 +++- .../TableProMobile/AppState.swift | 4 +- .../ConnectionCoordinatorStore.swift | 8 +- .../Helpers/ConnectionFormEdits.swift | 3 +- .../Helpers/MacOnlyConnection.swift | 5 +- .../Platform/ConnectionLibraryPublisher.swift | 2 + .../Services/IOSConnectionImportService.swift | 3 +- .../Services/IOSImportLibraryStore.swift | 43 +++++--- .../Sync/IOSSyncCoordinator.swift | 23 ++++ .../Views/MobileConnectionImportSheet.swift | 4 +- .../ConnectionCoordinatorStoreTests.swift | 17 +++ .../ConnectionFormEditsTests.swift | 14 ++- .../ConnectionLibraryPublisherTests.swift | 26 ++++- .../IOSConnectionExportServiceTests.swift | 16 +++ .../IOSConnectionImportServiceTests.swift | 62 +++++++++++ .../MacOnlyImportRowTests.swift | 45 +++++++- .../Sync/IOSSyncCoordinatorTests.swift | 48 ++++++++ .../TableProWidget/QuickConnectEntry.swift | 2 +- 28 files changed, 639 insertions(+), 64 deletions(-) create mode 100644 apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSwitchTests.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSwitchTests.swift diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift b/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift index 84b0c8904b..e3ffd5307b 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Import/ConnectionImportAnalyzer.swift @@ -159,7 +159,8 @@ private extension ConnectionImportAnalyzer { if isMissing(ssh.privateKeyPath, fileExists: fileExists) { warnings.append(String(format: String(localized: "SSH private key not found: %@"), ssh.privateKeyPath)) } - for jump in ssh.jumpHosts ?? [] where isMissing(jump.privateKeyPath, fileExists: fileExists) { + let hopKeyPaths = environment.rules.readsJumpHostKeyFiles ? (ssh.jumpHosts ?? []) : [] + for jump in hopKeyPaths where isMissing(jump.privateKeyPath, fileExists: fileExists) { warnings.append(String(format: String(localized: "Jump host key not found: %@"), jump.privateKeyPath)) } } diff --git a/apple/Packages/TableProCore/Sources/TableProImport/Import/ImportLibrarySnapshot.swift b/apple/Packages/TableProCore/Sources/TableProImport/Import/ImportLibrarySnapshot.swift index 9db458fb56..5197fafb7b 100644 --- a/apple/Packages/TableProCore/Sources/TableProImport/Import/ImportLibrarySnapshot.swift +++ b/apple/Packages/TableProCore/Sources/TableProImport/Import/ImportLibrarySnapshot.swift @@ -26,11 +26,19 @@ public struct ImportRules: Sendable, Equatable { public var maximumGroupDepth: Int public var supportsSavedQueries: Bool public var supportsCredentialProfiles: Bool + // iOS signs in to a jump host with a key kept on the device, so a hop's key path is carried there, never read. + public var readsJumpHostKeyFiles: Bool - public init(maximumGroupDepth: Int, supportsSavedQueries: Bool, supportsCredentialProfiles: Bool) { + public init( + maximumGroupDepth: Int, + supportsSavedQueries: Bool, + supportsCredentialProfiles: Bool, + readsJumpHostKeyFiles: Bool = true + ) { self.maximumGroupDepth = maximumGroupDepth self.supportsSavedQueries = supportsSavedQueries self.supportsCredentialProfiles = supportsCredentialProfiles + self.readsJumpHostKeyFiles = readsJumpHostKeyFiles } } diff --git a/apple/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift b/apple/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift index 684b663d94..d563d89070 100644 --- a/apple/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift +++ b/apple/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift @@ -162,6 +162,7 @@ extension DatabaseConnection: Codable { sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false + adoptConfigurationSwitches() } public func encode(to encoder: Encoder) throws { @@ -194,3 +195,16 @@ extension DatabaseConnection: Codable { } } } + +public extension DatabaseConnection { + // The Mac keeps both switches inside the configurations and never writes these two fields, so a configuration that + // carries a switch decides. A copy stored before this read them the other way and heals on load. + mutating func adoptConfigurationSwitches() { + if let enabled = sshConfiguration?.macEnabled { + sshEnabled = enabled + } + if let ssl = sslConfiguration { + sslEnabled = ssl.mode != .disable + } + } +} diff --git a/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift b/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift index d95c6d531e..518369b84e 100644 --- a/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift +++ b/apple/Packages/TableProCore/Sources/TableProModels/SSLConfiguration.swift @@ -3,13 +3,20 @@ import TableProPluginKit public struct SSLConfiguration: Codable, Hashable, Sendable { public var mode: SSLMode { - didSet { if mode != oldValue { isMacPreferred = false } } + didSet { + guard mode != oldValue else { return } + isMacPreferred = false + isLegacyRequire = false + } } public var caCertificatePath: String? public var clientCertificatePath: String? public var clientKeyPath: String? // The Mac's Preferred falls back to plaintext. iOS has no such mode and opens it as .require, but writes Preferred back. public private(set) var isMacPreferred: Bool + // iOS builds before the Mac's mode names stored the Mac's Preferred as "require", so such a copy cannot say which + // of the two the Mac holds. Only the device's own library file keeps that spelling; everything else writes Required. + public private(set) var isLegacyRequire: Bool public enum SSLMode: String, Codable, Sendable { case disable @@ -45,7 +52,9 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) mode = (try? container.decode(SSLMode.self, forKey: .mode)) ?? .disable - isMacPreferred = (try? container.decode(String.self, forKey: .mode)) == TableProPluginKit.SSLMode.preferred.rawValue + let rawMode = try? container.decode(String.self, forKey: .mode) + isMacPreferred = rawMode == TableProPluginKit.SSLMode.preferred.rawValue + isLegacyRequire = rawMode == SSLMode.require.rawValue caCertificatePath = Self.path(in: container, forKey: .caCertificatePath) clientCertificatePath = Self.path(in: container, forKey: .clientCertificatePath) clientKeyPath = Self.path(in: container, forKey: .clientKeyPath) @@ -55,7 +64,8 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { // names, and every path key, an unset one as "". public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(macMode.rawValue, forKey: .mode) + let keepsLegacySpelling = isLegacyRequire && encoder.userInfo[.deviceLibraryFile] as? Bool == true + try container.encode(keepsLegacySpelling ? SSLMode.require.rawValue : macMode.rawValue, forKey: .mode) try container.encode(caCertificatePath ?? "", forKey: .caCertificatePath) try container.encode(clientCertificatePath ?? "", forKey: .clientCertificatePath) try container.encode(clientKeyPath ?? "", forKey: .clientKeyPath) @@ -73,6 +83,23 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { self.clientCertificatePath = clientCertificatePath self.clientKeyPath = clientKeyPath self.isMacPreferred = isMacPreferred && mode == .require + self.isLegacyRequire = false + } + + // Same mode and paths, and the same Preferred, except that a copy an older iOS build stored takes the server's. + public func agrees(withServer server: SSLConfiguration) -> Bool { + let preferredAgrees = server.isMacPreferred == isMacPreferred || (server.isMacPreferred && isLegacyRequire) + return preferredAgrees + && server.mode == mode + && server.caCertificatePath == caCertificatePath + && server.clientCertificatePath == clientCertificatePath + && server.clientKeyPath == clientKeyPath + } + + public mutating func settleLegacyRequire(asMacPreferred macPreferred: Bool) { + guard isLegacyRequire else { return } + isLegacyRequire = false + isMacPreferred = macPreferred } private var macMode: TableProPluginKit.SSLMode { @@ -88,3 +115,12 @@ public struct SSLConfiguration: Codable, Hashable, Sendable { return value } } + +public extension CodingUserInfoKey { + static let deviceLibraryFile: CodingUserInfoKey = { + guard let key = CodingUserInfoKey(rawValue: "com.TablePro.deviceLibraryFile") else { + preconditionFailure("CodingUserInfoKey refused a plain raw value") + } + return key + }() +} diff --git a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index ab6d195eee..8e86df9718 100644 --- a/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/apple/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -13,6 +13,9 @@ public enum SyncRecordMapper { private static let schemaVersion: Int64 = 1 + // RedisDatabaseIndex.fieldName, which this module does not link. + private static let redisDatabaseFieldKey = "redisDatabase" + // MARK: - Record Name Helpers public static func recordID(type: SyncRecordType, id: String, in zone: CKRecordZone.ID) -> CKRecord.ID { @@ -67,7 +70,7 @@ public enum SyncRecordMapper { fields[.tagId] = tagIdStrings.first as CKRecordValue? fields[.queryTimeoutSeconds] = validQueryTimeout(connection.queryTimeoutSeconds).map { Int64($0) } as CKRecordValue? - if let sshConfig = connection.sshConfiguration { + if let sshConfig = macSSHConfiguration(of: connection) { do { let data = try encoder.encode(sshConfig) fields[.sshConfigJson] = data as CKRecordValue @@ -145,16 +148,12 @@ public enum SyncRecordMapper { sshConfig = try? decoder.decode(SSHConfiguration.self, from: sshData) } - // macOS stores SSH enabled inside sshConfigJson ("enabled" field), - // not as a top-level CKRecord field. Fall back to checking the JSON. + // Only iOS writes the top-level switches, so one stays behind after a Mac edit; the configurations decide below. let sshEnabled: Bool if let explicit = fields[.sshEnabled] as? Int64 { sshEnabled = explicit != 0 - } else if let sshData = fields[.sshConfigJson] as? Data, - let json = try? JSONSerialization.jsonObject(with: sshData) as? [String: Any] { - sshEnabled = json["enabled"] as? Bool ?? (sshConfig != nil && !(sshConfig?.host.isEmpty ?? true)) } else { - sshEnabled = false + sshEnabled = sshConfig.map { !$0.host.isEmpty } ?? false } let sslEnabled = (fields[.sslEnabled] as? Int64 ?? 0) != 0 @@ -168,6 +167,11 @@ public enum SyncRecordMapper { if let fieldsData = fields[.additionalFieldsJson] as? Data { additionalFields = (try? decoder.decode([String: String].self, from: fieldsData)) ?? [:] } + // The Mac falls back to this field when the additional field is unset, so copying it in opens the same database. + if DatabaseType(rawValue: typeRaw) == .redis, additionalFields[redisDatabaseFieldKey] == nil, + let redisDatabase = fields[.redisDatabase] as? Int64 { + additionalFields[redisDatabaseFieldKey] = String(redisDatabase) + } let legacyConnectTimeout = additionalFields .removeValue(forKey: DatabaseConnection.connectTimeoutSecondsKey) .flatMap(Int.init) @@ -199,6 +203,7 @@ public enum SyncRecordMapper { isFavorite: isFavorite ) connection.connectTimeoutSeconds = validConnectTimeout(legacyConnectTimeout) + connection.adoptConfigurationSwitches() return connection } @@ -244,7 +249,7 @@ public enum SyncRecordMapper { fields[.queryTimeoutSeconds] = validQueryTimeout(connection.queryTimeoutSeconds).map { Int64($0) } as CKRecordValue? - if let sshConfig = connection.sshConfiguration { + if let sshConfig = macSSHConfiguration(of: connection) { if let data = try? encoder.encode(sshConfig) { fields[.sshConfigJson] = data as CKRecordValue } @@ -273,16 +278,26 @@ public enum SyncRecordMapper { fields[.modifiedAtLocal] = Date() as CKRecordValue } - // A local copy without isMacPreferred may be one an older iOS build stored, so it keeps the server's Preferred. - // A blob an older iOS build wrote ("require", nil paths omitted) is rewritten: the Mac cannot read it. private static func serverSSLMatches(_ data: Data?, _ local: SSLConfiguration) -> Bool { - guard let data, SSLConfiguration.isMacWireForm(data), - let server = try? decoder.decode(SSLConfiguration.self, from: data) else { return false } - return (server.isMacPreferred || !local.isMacPreferred) - && server.mode == local.mode - && server.caCertificatePath == local.caCertificatePath - && server.clientCertificatePath == local.clientCertificatePath - && server.clientKeyPath == local.clientKeyPath + guard let server = serverSSL(in: data) else { return false } + return local.agrees(withServer: server) + } + + // Nil for a blob an older iOS build wrote ("require", nil paths omitted): the Mac cannot read it, so it is rewritten. + private static func serverSSL(in data: Data?) -> SSLConfiguration? { + guard let data, SSLConfiguration.isMacWireForm(data) else { return nil } + return try? decoder.decode(SSLConfiguration.self, from: data) + } + + public static func serverSSL(in record: CKRecord) -> SSLConfiguration? { + serverSSL(in: record.fields(ConnectionSyncField.self)[.sslConfigJson] as? Data) + } + + // The Mac reads a missing `enabled` as off, and an import or an older iOS build leaves it out. + private static func macSSHConfiguration(of connection: DatabaseConnection) -> SSHConfiguration? { + guard var sshConfig = connection.sshConfiguration else { return nil } + sshConfig.macEnabled = connection.sshEnabled + return sshConfig } private static func syncedAdditionalFields(for connection: DatabaseConnection) -> [String: String] { diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift index 8356b41913..0d8e485c4e 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ConnectionImportAnalyzerTests.swift @@ -194,6 +194,38 @@ struct ConnectionImportAnalyzerTests { #expect(warnings.contains { $0.contains("Client key") }) } + @Test("A device that never reads hop key files warns about the server key only") + func hopKeyPathsAreNotCheckedWhereUnread() throws { + var settings = Fixtures.settings() + settings.sshConfig = ExportableSSHConfig( + enabled: true, + host: "bastion", + port: nil, + username: "u", + authMethod: "privateKey", + privateKeyPath: "~/.ssh/missing_key", + agentSocketPath: "", + jumpHosts: [ + ExportableJumpHost(host: "jump", port: nil, username: "u", authMethod: "privateKey", privateKeyPath: "~/.ssh/jump") + ], + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + let preview = Fixtures.makePreview( + try Fixtures.makeBundle(connections: [BundleConnection(ref: "c1", settings: settings)]), + environment: Fixtures.makeEnvironment( + rules: Fixtures.makeRules(readsJumpHostKeyFiles: false), + fileExists: { _ in false } + ) + ) + + let warnings = try #require(preview.connections.first).warnings + #expect(warnings.count == 1) + #expect(warnings.contains { $0.contains("~/.ssh/missing_key") }) + } + @Test("An unrecognized SSL mode warns that the connection imports with SSL required") func unrecognizedSSLModeWarns() throws { var settings = Fixtures.settings() diff --git a/apple/Packages/TableProCore/Tests/TableProImportTests/ImportTestSupport.swift b/apple/Packages/TableProCore/Tests/TableProImportTests/ImportTestSupport.swift index a7a17fd7f2..c8c44280bd 100644 --- a/apple/Packages/TableProCore/Tests/TableProImportTests/ImportTestSupport.swift +++ b/apple/Packages/TableProCore/Tests/TableProImportTests/ImportTestSupport.swift @@ -19,12 +19,14 @@ enum ImportFixtures { static func makeRules( maximumGroupDepth: Int = 3, supportsSavedQueries: Bool = true, - supportsCredentialProfiles: Bool = true + supportsCredentialProfiles: Bool = true, + readsJumpHostKeyFiles: Bool = true ) -> ImportRules { ImportRules( maximumGroupDepth: maximumGroupDepth, supportsSavedQueries: supportsSavedQueries, - supportsCredentialProfiles: supportsCredentialProfiles + supportsCredentialProfiles: supportsCredentialProfiles, + readsJumpHostKeyFiles: readsJumpHostKeyFiles ) } diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSwitchTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSwitchTests.swift new file mode 100644 index 0000000000..99c7860b9a --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSwitchTests.swift @@ -0,0 +1,38 @@ +import Foundation +import Testing + +@testable import TableProModels + +struct DatabaseConnectionSwitchTests { + private func reloaded(_ connection: DatabaseConnection) throws -> DatabaseConnection { + try JSONDecoder().decode(DatabaseConnection.self, from: JSONEncoder().encode(connection)) + } + + @Test("A stored copy whose tunnel switch disagrees with the Mac's takes the Mac's", arguments: [true, false]) + func sshSwitchFollowsTheConfiguration(_ macEnabled: Bool) throws { + var ssh = SSHConfiguration(host: "bastion.example.com", username: "deploy") + ssh.macEnabled = macEnabled + let stored = DatabaseConnection(name: "Orders", sshEnabled: !macEnabled, sshConfiguration: ssh) + + #expect(try reloaded(stored).sshEnabled == macEnabled) + } + + @Test("A tunnel configuration without the Mac's switch keeps the stored one", arguments: [true, false]) + func sshSwitchWithoutMacValueStays(_ enabled: Bool) throws { + let ssh = SSHConfiguration(host: "bastion.example.com", username: "deploy") + let stored = DatabaseConnection(name: "Orders", sshEnabled: enabled, sshConfiguration: ssh) + + #expect(try reloaded(stored).sshEnabled == enabled) + } + + @Test("A stored copy that reads SSL off over a Mac SSL mode takes the mode") + func sslSwitchFollowsTheConfiguration() throws { + let required = DatabaseConnection(name: "Orders", sslEnabled: false, sslConfiguration: SSLConfiguration(mode: .verifyFull)) + let disabled = DatabaseConnection(name: "Orders", sslEnabled: true, sslConfiguration: SSLConfiguration(mode: .disable)) + let unset = DatabaseConnection(name: "Orders", sslEnabled: true) + + #expect(try reloaded(required).sslEnabled) + #expect(try !reloaded(disabled).sslEnabled) + #expect(try reloaded(unset).sslEnabled) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift b/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift index a64c137362..c10efed2e9 100644 --- a/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProModelsTests/SSLConfigurationWireTests.swift @@ -72,6 +72,65 @@ struct SSLConfigurationWireTests { #expect(!TableProModels.SSLConfiguration(mode: .verifyCa, isMacPreferred: true).isMacPreferred) } + @Test("An older iOS build's require keeps its spelling in the library file and goes to the Mac as Required") + func legacyRequireSpelling() throws { + let config = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(#"{"mode":"require"}"#.utf8)) + #expect(config.mode == .require) + #expect(config.isLegacyRequire) + #expect(!config.isMacPreferred) + + let libraryEncoder = JSONEncoder() + libraryEncoder.userInfo[.deviceLibraryFile] = true + let stored = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: libraryEncoder.encode(config)) + #expect(stored.isLegacyRequire) + + let wire = try JSONEncoder().encode(config) + let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: wire) + #expect(mac.mode == .required) + #expect(!(try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: wire)).isLegacyRequire) + } + + @Test("A Required written with the Mac's name, or set on this device, is explicit") + func explicitRequired() throws { + let blob = #"{"mode":"Required","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"# + let decoded = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(blob.utf8)) + #expect(!decoded.isLegacyRequire) + #expect(!TableProModels.SSLConfiguration(mode: .require).isLegacyRequire) + + var changed = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(#"{"mode":"require"}"#.utf8)) + changed.mode = .verifyCa + changed.mode = .require + #expect(!changed.isLegacyRequire) + } + + @Test("Only a copy an older iOS build stored takes the server's Preferred", arguments: [ + (#"{"mode":"require"}"#, true), + (#"{"mode":"Required","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"#, false), + (#"{"mode":"Preferred","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"#, true) + ]) + func agreesWithServerPreferred(_ localJSON: String, _ agrees: Bool) throws { + let server = TableProModels.SSLConfiguration(mode: .require, isMacPreferred: true) + let local = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(localJSON.utf8)) + #expect(local.agrees(withServer: server) == agrees) + } + + @Test("Settling an older copy records what the server holds") + func settleLegacyRequire() throws { + var preferred = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(#"{"mode":"require"}"#.utf8)) + preferred.settleLegacyRequire(asMacPreferred: true) + #expect(preferred.isMacPreferred) + #expect(!preferred.isLegacyRequire) + + var required = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(#"{"mode":"require"}"#.utf8)) + required.settleLegacyRequire(asMacPreferred: false) + #expect(!required.isMacPreferred) + #expect(!required.isLegacyRequire) + + var explicit = TableProModels.SSLConfiguration(mode: .require) + explicit.settleLegacyRequire(asMacPreferred: true) + #expect(!explicit.isMacPreferred) + } + @Test("A blob an older iOS build wrote is not in the Mac's form") func olderIOSBlobIsNotMacForm() { #expect(!TableProModels.SSLConfiguration.isMacWireForm(Data(#"{"mode":"require"}"#.utf8))) diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSwitchTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSwitchTests.swift new file mode 100644 index 0000000000..8a97d74417 --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperSwitchTests.swift @@ -0,0 +1,103 @@ +import CloudKit +import Foundation +import Testing + +@testable import TableProModels +@testable import TableProSync +@testable import TableProSyncTransport + +struct SyncRecordMapperSwitchTests { + private let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + private let macRequired = #"{"mode":"Required","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"# + + private func tunnelled(sshEnabled: Bool, macEnabled: Bool? = nil) -> DatabaseConnection { + var ssh = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + ssh.macEnabled = macEnabled + return DatabaseConnection( + name: "Tunnelled", + type: .postgresql, + host: "10.0.0.5", + port: 5_432, + sshEnabled: sshEnabled, + sshConfiguration: ssh + ) + } + + private func fields(_ record: CKRecord) -> SyncRecordFields { + record.fields(ConnectionSyncField.self) + } + + private func sshJSONObject(in record: CKRecord) throws -> [String: Any] { + let data = try #require(fields(record)[.sshConfigJson] as? Data) + return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + // The Mac writes `enabled` inside the configuration and never touches the top-level field iOS wrote. + private func macEdit(_ record: CKRecord, enabled: Bool) throws { + var json = try sshJSONObject(in: record) + json["enabled"] = enabled + fields(record)[.sshConfigJson] = try JSONSerialization.data(withJSONObject: json) as CKRecordValue + } + + @Test("A tunnel the Mac switches after iOS wrote the top-level field reads as the Mac set it", arguments: [true, false]) + func macTunnelSwitchWins(_ macEnabled: Bool) throws { + let record = SyncRecordMapper.toRecord(tunnelled(sshEnabled: !macEnabled), zoneID: zoneID) + try macEdit(record, enabled: macEnabled) + + let connection = try #require(SyncRecordMapper.toConnection(record)) + + #expect(connection.sshEnabled == macEnabled) + } + + @Test("Every record iOS writes carries the tunnel switch for the Mac", arguments: [true, false]) + func recordCarriesTheSwitch(_ enabled: Bool) throws { + let built = SyncRecordMapper.toRecord(tunnelled(sshEnabled: enabled), zoneID: zoneID) + #expect(try sshJSONObject(in: built)["enabled"] as? Bool == enabled) + #expect(fields(built)[.sshEnabled] as? Int64 == (enabled ? 1 : 0)) + + let updated = SyncRecordMapper.toRecord(tunnelled(sshEnabled: !enabled, macEnabled: !enabled), zoneID: zoneID) + SyncRecordMapper.updateRecord(updated, with: tunnelled(sshEnabled: enabled, macEnabled: !enabled)) + #expect(try sshJSONObject(in: updated)["enabled"] as? Bool == enabled) + } + + @Test("A record an older iOS build wrote, with only the top-level switch, keeps it") + func olderRecordKeepsTopLevelSwitch() throws { + let record = SyncRecordMapper.toRecord(tunnelled(sshEnabled: false), zoneID: zoneID) + var json = try sshJSONObject(in: record) + json.removeValue(forKey: "enabled") + fields(record)[.sshConfigJson] = try JSONSerialization.data(withJSONObject: json) as CKRecordValue + + #expect(try #require(SyncRecordMapper.toConnection(record)).sshEnabled == false) + } + + @Test("A Mac SSL mode reads as on without the top-level field the Mac never writes") + func macSSLModeReadsOn() throws { + let record = SyncRecordMapper.toRecord(DatabaseConnection(name: "Orders", type: .mysql), zoneID: zoneID) + fields(record)[.sslEnabled] = nil + fields(record)[.sslConfigJson] = Data(macRequired.utf8) as CKRecordValue + + let connection = try #require(SyncRecordMapper.toConnection(record)) + + #expect(connection.sslEnabled) + #expect(connection.sslConfiguration?.mode == .require) + } + + @Test("A Redis index the Mac keeps only in its own field opens the same database") + func macRedisIndexIsRead() throws { + let record = SyncRecordMapper.toRecord(DatabaseConnection(name: "Cache", type: .redis, port: 6_379), zoneID: zoneID) + fields(record)[.redisDatabase] = Int64(3) as CKRecordValue + + let connection = try #require(SyncRecordMapper.toConnection(record)) + + #expect(connection.additionalFields["redisDatabase"] == "3") + } + + @Test("A Redis index in the additional fields wins over the Mac's own field") + func additionalRedisIndexWins() throws { + let stored = DatabaseConnection(name: "Cache", type: .redis, port: 6_379, additionalFields: ["redisDatabase": "5"]) + let record = SyncRecordMapper.toRecord(stored, zoneID: zoneID) + fields(record)[.redisDatabase] = Int64(3) as CKRecordValue + + #expect(try #require(SyncRecordMapper.toConnection(record)).additionalFields["redisDatabase"] == "5") + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift index 807910bc2e..8f3688aa43 100644 --- a/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift +++ b/apple/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift @@ -171,7 +171,7 @@ struct SyncRecordMapperSSLTests { func keepsMacPreferredForAnOlderLocalCopy() throws { let record = makeRecord(sslJSON: macPreferred) var connection = try #require(SyncRecordMapper.toConnection(record)) - connection.sslConfiguration = SSLConfiguration(mode: .require) + connection.sslConfiguration = try JSONDecoder().decode(SSLConfiguration.self, from: Data(#"{"mode":"require"}"#.utf8)) connection.name = "Renamed" SyncRecordMapper.updateRecord(record, with: connection) @@ -179,6 +179,23 @@ struct SyncRecordMapperSSLTests { #expect(try sslData(in: record) == Data(macPreferred.utf8)) } + @Test("updateRecord writes an explicit Required over a server Preferred with the same paths") + func explicitRequiredReplacesPreferred() throws { + let record = makeRecord(sslJSON: macPreferred) + var connection = try #require(SyncRecordMapper.toConnection(record)) + connection.sslConfiguration = SSLConfiguration(mode: .require) + + SyncRecordMapper.updateRecord(record, with: connection) + + #expect(try sslJSONObject(in: record)["mode"] as? String == "Required") + } + + @Test("The server's SSL is read only from a blob the Mac can decode") + func serverSSLNeedsMacForm() { + #expect(SyncRecordMapper.serverSSL(in: makeRecord(sslJSON: macPreferred))?.isMacPreferred == true) + #expect(SyncRecordMapper.serverSSL(in: makeRecord(sslJSON: #"{"mode":"require"}"#)) == nil) + } + @Test("updateRecord writes Preferred over a server Required") func writesPreferredOverRequired() throws { let record = makeRecord(sslJSON: #"{"mode":"Required","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"#) @@ -219,7 +236,8 @@ struct SyncRecordMapperSSLTests { func rewritesOlderIOSBlob() throws { let record = makeRecord(sslJSON: #"{"mode":"require"}"#) let connection = try #require(SyncRecordMapper.toConnection(record)) - #expect(connection.sslConfiguration == SSLConfiguration(mode: .require)) + #expect(connection.sslConfiguration?.mode == .require) + #expect(connection.sslConfiguration?.isLegacyRequire == true) SyncRecordMapper.updateRecord(record, with: connection) diff --git a/apple/TableProMobile/TableProMobile/AppState.swift b/apple/TableProMobile/TableProMobile/AppState.swift index 70deca10ba..b3016a89ae 100644 --- a/apple/TableProMobile/TableProMobile/AppState.swift +++ b/apple/TableProMobile/TableProMobile/AppState.swift @@ -564,7 +564,9 @@ private struct ConnectionPersistence { func save(_ connections: [DatabaseConnection], keepingPastedSSHKeys pastedSSHKeys: [UUID: String]) throws { guard let fileURL else { return } - let data = try PastedSSHKeyMigration.libraryFile(JSONEncoder().encode(connections), keeping: pastedSSHKeys) + let encoder = JSONEncoder() + encoder.userInfo[.deviceLibraryFile] = true + let data = try PastedSSHKeyMigration.libraryFile(encoder.encode(connections), keeping: pastedSSHKeys) try data.write(to: fileURL, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } diff --git a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift index 1120e1fd8f..264b5a2543 100644 --- a/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift +++ b/apple/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinatorStore.swift @@ -118,9 +118,13 @@ nonisolated extension DatabaseConnection { && database == other.database && sshEnabled == other.sshEnabled && sshConfiguration == other.sshConfiguration - && sslEnabled == other.sslEnabled - && sslConfiguration == other.sslConfiguration + && sslDialed == other.sslDialed && additionalFields == other.additionalFields && isSample == other.isSample } + + // A Preferred the Mac holds dials as Required here, so a change to that alone keeps the session. + private var sslDialed: DriverSSLConfiguration { + DriverSSLConfiguration(sslEnabled: sslEnabled, configuration: sslConfiguration) + } } diff --git a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift index 59e88761de..14ac8fb422 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/ConnectionFormEdits.swift @@ -93,9 +93,10 @@ nonisolated struct ConnectionFormEdits: Equatable, Sendable { } private func applySSHTunnel(to connection: inout DatabaseConnection, changedSince opening: SSHTunnel?) { + // Switched off, not cleared: the Mac keeps a disabled tunnel's settings, and the record carries them. guard let sshTunnel else { connection.sshEnabled = false - connection.sshConfiguration = nil + connection.sshConfiguration?.macEnabled = false return } func changed(_ field: KeyPath) -> Bool { diff --git a/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift b/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift index 1349318c1c..e96069cf29 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/MacOnlyConnection.swift @@ -1,4 +1,5 @@ import Foundation +import TableProImport import TableProModels nonisolated enum MacOnlyConnection { @@ -14,7 +15,7 @@ nonisolated enum MacOnlyConnection { !IOSDriverFactory.supportedTypeList.contains(type) } - static func importStatus(typeId: String) -> String? { - isMacOnly(type: DatabaseType(rawValue: typeId)) ? status : nil + static func importStatus(for settings: ExportableConnection) -> String? { + isMacOnly(DatabaseConnection(importedSettings: settings)) ? status : nil } } diff --git a/apple/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift b/apple/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift index 1fdbed2403..1c9ff78517 100644 --- a/apple/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift +++ b/apple/TableProMobile/TableProMobile/Platform/ConnectionLibraryPublisher.swift @@ -40,8 +40,10 @@ final class ConnectionLibraryPublisher { ) } + // The widget only opens connections, so one this device cannot open would take a slot and end in an alert. nonisolated static func widgetItems(for connections: [DatabaseConnection]) -> [WidgetConnectionItem] { connections + .filter { !MacOnlyConnection.isMacOnly($0) } .sorted { ($0.sortOrder, $0.name) < ($1.sortOrder, $1.name) } .map { connection in WidgetConnectionItem( diff --git a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index 9bdf289e05..140747e139 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -16,7 +16,8 @@ enum IOSConnectionImportService { rules: ImportRules( maximumGroupDepth: LibraryGroupGraph.maxNestingDepth, supportsSavedQueries: false, - supportsCredentialProfiles: false + supportsCredentialProfiles: false, + readsJumpHostKeyFiles: false ), registeredTypeIds: recognizedTypeIds ) diff --git a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift index 08faaec314..b9b2932aee 100644 --- a/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift +++ b/apple/TableProMobile/TableProMobile/Services/IOSImportLibraryStore.swift @@ -4,6 +4,7 @@ import TableProConnectionLibrary import TableProDatabase import TableProImport import TableProModels +import TableProRedisCore @MainActor struct IOSImportLibraryStore: ImportLibraryStore { @@ -23,7 +24,9 @@ struct IOSImportLibraryStore: ImportLibraryStore { port: connection.port, database: connection.database, username: connection.username, - redisDatabase: nil, + redisDatabase: connection.type == .redis + ? connection.additionalFields[RedisDatabaseIndex.fieldName].flatMap(RedisDatabaseIndex.parse) + : nil, socketPath: MySQLLocalSocket.path(in: connection.additionalFields) ) ) @@ -101,7 +104,12 @@ struct IOSImportLibraryStore: ImportLibraryStore { var added: [UUID] = [] var replaced: [UUID] = [] for resolved in connections { - let connection = DatabaseConnection(importing: resolved) + let connection = DatabaseConnection( + importedSettings: resolved.planned.settings, + id: resolved.planned.id, + groupId: resolved.groupId, + tagIds: resolved.tagIds + ) switch resolved.planned.write { case .add: if appState.addConnection(connection) { @@ -150,7 +158,7 @@ struct IOSImportLibraryStore: ImportLibraryStore { } } -internal extension SSLConfiguration.SSLMode { +nonisolated extension SSLConfiguration.SSLMode { // iOS has no Preferred mode, and an unknown mode must never weaken a connection to plain text. init(importing portable: PortableSSLMode?) { guard let portable else { @@ -166,13 +174,12 @@ internal extension SSLConfiguration.SSLMode { } } -private extension DatabaseConnection { - init(importing resolved: ResolvedConnection) { - let settings = resolved.planned.settings +nonisolated extension DatabaseConnection { + init(importedSettings settings: ExportableConnection, id: UUID = UUID(), groupId: UUID? = nil, tagIds: [UUID] = []) { let safeModeLevel = SafeModeLevel(wireValue: settings.safeModeLevel, isReadOnly: false) let sslMode = settings.sslConfig.map { SSLConfiguration.SSLMode(importing: $0.portableMode) } ?? .disable self.init( - id: resolved.planned.id, + id: id, name: settings.name, type: DatabaseType(rawValue: settings.type), host: settings.host.trimmingCharacters(in: .whitespaces).isEmpty ? "localhost" : settings.host, @@ -184,7 +191,7 @@ private extension DatabaseConnection { isReadOnly: safeModeLevel.blocksWrites, safeModeLevel: safeModeLevel, queryTimeoutSeconds: settings.queryTimeoutSeconds, - additionalFields: settings.additionalFields ?? [:], + additionalFields: Self.importedAdditionalFields(settings), sshEnabled: settings.sshConfig?.enabled == true, sshConfiguration: settings.sshConfig.flatMap { Self.sshConfiguration($0) }, sslEnabled: sslMode != .disable, @@ -197,13 +204,23 @@ private extension DatabaseConnection { isMacPreferred: ssl.portableMode == .preferred ) }, - groupId: resolved.groupId, - tagIds: resolved.tagIds + groupId: groupId, + tagIds: tagIds ) connectTimeoutSeconds = settings.connectTimeoutSeconds } - static func sshConfiguration(_ ssh: ExportableSSHConfig) -> SSHConfiguration? { + // The Mac falls back to its own Redis index when the additional field is unset, so the import reads it the same way. + private static func importedAdditionalFields(_ settings: ExportableConnection) -> [String: String] { + var fields = settings.additionalFields ?? [:] + if DatabaseType(rawValue: settings.type) == .redis, fields[RedisDatabaseIndex.fieldName] == nil, + let redisDatabase = settings.redisDatabase { + fields[RedisDatabaseIndex.fieldName] = String(redisDatabase) + } + return fields + } + + private static func sshConfiguration(_ ssh: ExportableSSHConfig) -> SSHConfiguration? { guard ssh.enabled else { return nil } return SSHConfiguration( host: ssh.host, @@ -223,7 +240,7 @@ private extension DatabaseConnection { ) } - static func sshAuthMethod(_ raw: String) -> SSHConfiguration.SSHAuthMethod { + private static func sshAuthMethod(_ raw: String) -> SSHConfiguration.SSHAuthMethod { switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "privatekey", "publickey", "private key": .privateKey case "sshagent", "agent", "ssh agent": .sshAgent @@ -233,7 +250,7 @@ private extension DatabaseConnection { } } - static func expandedPath(_ path: String?) -> String? { + private static func expandedPath(_ path: String?) -> String? { guard let path else { return nil } let expanded = PathPortability.expandHome(path) return expanded.isEmpty ? nil : expanded diff --git a/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 38b18478e4..cc35307b02 100644 --- a/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/apple/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -214,6 +214,7 @@ final class IOSSyncCoordinator { Self.logger.info("Sync skipped: the library has not loaded") return } + await settleLegacySSLModes() let previousError = status.error let admission = SyncAdmission.decide( for: trigger, @@ -546,6 +547,28 @@ final class IOSSyncCoordinator { // MARK: - Apply + // A copy an older iOS build stored lost the Mac's Preferred, which only the cached server record still holds. + // A run can delete that cache (iCloud reset, account change) before anything pushes, so this goes first. + private func settleLegacySSLModes() async { + guard let connections = getCurrentState?()?.connections, + connections.contains(where: { $0.sslConfiguration?.isLegacyRequire == true }) else { return } + let zoneID = await currentTransport().currentZoneID + guard var settled = getCurrentState?()?.connections else { return } + var changed = false + for index in settled.indices { + guard let local = settled[index].sslConfiguration, local.isLegacyRequire else { continue } + let recordID = SyncRecordMapper.recordID(type: .connection, id: settled[index].id.uuidString, in: zoneID) + guard let cached = recordCache.record(for: recordID), + let server = SyncRecordMapper.serverSSL(in: cached), + local.agrees(withServer: server) else { continue } + settled[index].sslConfiguration?.settleLegacyRequire(asMacPreferred: server.isMacPreferred) + changed = true + } + if changed { + onConnectionsChanged?(settled) + } + } + private func applyRemoteChanges(_ remote: PullChanges) -> Bool { guard let state = getCurrentState?() else { return false } onConnectionsChanged?(mergeConnections(local: state.connections, remote: remote)) diff --git a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift index ac42e781a0..49a377c5dc 100644 --- a/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift +++ b/apple/TableProMobile/TableProMobile/Views/MobileConnectionImportSheet.swift @@ -181,7 +181,7 @@ struct MobileConnectionImportSheet: View { if let duplicate = row.duplicate { return String(format: String(localized: "Matches “%@”"), duplicate.name) } - if let status = MacOnlyConnection.importStatus(typeId: row.settings.type) { + if let status = MacOnlyConnection.importStatus(for: row.settings) { return status } return row.warnings.first ?? "\(row.settings.host):\(row.settings.port)" @@ -189,7 +189,7 @@ struct MobileConnectionImportSheet: View { private static func needsAttention(_ row: ConnectionRow) -> Bool { row.unsupportedTypeId != nil || row.duplicate != nil - || MacOnlyConnection.importStatus(typeId: row.settings.type) != nil || !row.warnings.isEmpty + || MacOnlyConnection.importStatus(for: row.settings) != nil || !row.warnings.isEmpty } private static func label(for resolution: ConnectionResolution) -> String { diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift index a2d6cf65e9..08bc84dc1c 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionCoordinatorStoreTests.swift @@ -81,6 +81,23 @@ struct ConnectionRedialTests { var sampled = original sampled.isSample = true #expect(!sampled.dialsTheSameWay(as: original)) + + var verified = original + verified.sslEnabled = true + verified.sslConfiguration = SSLConfiguration(mode: .verifyFull) + #expect(!verified.dialsTheSameWay(as: secured)) + } + + @Test("The Mac's Preferred over a Required this device dials the same way keeps the session") + func macPreferredKeepsTheSession() { + var required = connection() + required.sslEnabled = true + required.sslConfiguration = SSLConfiguration(mode: .require) + + var preferred = required + preferred.sslConfiguration = SSLConfiguration(mode: .require, isMacPreferred: true) + + #expect(preferred.dialsTheSameWay(as: required)) } } diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift index f897d62974..6abe6636d2 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionFormEditsTests.swift @@ -378,18 +378,24 @@ struct ConnectionFormEditsTests { #expect(saved.port == 2_222) } - @Test("Turning SSH off clears the tunnel") - func sshOffClearsTheTunnel() { + @Test("Turning SSH off keeps the tunnel's settings, switched off for the Mac too") + func sshOffKeepsTheTunnelSwitchedOff() { + var ssh = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + ssh.jumpHosts = [SSHJumpHost(host: "jump.example.com", username: "deploy")] + ssh.macTotpMode = "autoGenerate" var snapshot = storedConnection() snapshot.sshEnabled = true - snapshot.sshConfiguration = SSHConfiguration(host: "bastion.example.com", port: 22, username: "deploy") + snapshot.sshConfiguration = ssh let viewModel = ConnectionFormViewModel(editing: snapshot) viewModel.sshEnabled = false let saved = viewModel.applyingEdits(to: snapshot) #expect(!saved.sshEnabled) - #expect(saved.sshConfiguration == nil) + #expect(saved.sshConfiguration?.macEnabled == false) + #expect(saved.sshConfiguration?.host == "bastion.example.com") + #expect(saved.sshConfiguration?.jumpHosts == ssh.jumpHosts) + #expect(saved.sshConfiguration?.macTotpMode == "autoGenerate") } @Test("An SSL mode change keeps certificate paths set after the form opened") diff --git a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift index 5d154367fe..6ce3a82ee3 100644 --- a/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift +++ b/apple/TableProMobile/TableProMobileTests/ConnectionLibrary/ConnectionLibraryPublisherTests.swift @@ -172,11 +172,10 @@ struct ConnectionLibraryPublisherTests { ]) } - /// The widget kept its own copy of the engine glyph map, which had drifted from the list: a - /// Dameng connection drew a drive in the widget and a cylinder in the list. + /// The widget kept its own copy of the engine glyph map, which had drifted from the list. @Test("A widget item draws the same engine glyph as the connection list") func widgetItemGlyphMatchesTheList() { - let types: [DatabaseType] = [.dameng, .snowflake, .mysql, DatabaseType(rawValue: "FuturePlugin")] + let types = IOSDriverFactory.supportedTypeList let connections = types.enumerated().map { index, type in DatabaseConnection(name: "C\(index)", type: type, sortOrder: index) } @@ -184,9 +183,24 @@ struct ConnectionLibraryPublisherTests { let glyphs = ConnectionLibraryPublisher.widgetItems(for: connections).map(\.glyph) #expect(glyphs == types.map { type -> ConnectionGlyph? in LibraryGlyph.engineGlyph(for: type) }) - #expect(glyphs.first == ConnectionGlyph(source: .symbol, name: "cylinder")) - #expect(glyphs[1] == .fallback) - #expect(glyphs[2] == ConnectionGlyph(source: .asset, name: "mysql-icon")) + #expect(glyphs.contains(ConnectionGlyph(source: .asset, name: "mysql-icon"))) + } + + @Test("The widget leaves out a connection this device cannot open") + func widgetSkipsMacOnlyConnections() { + let mongo = DatabaseConnection(name: "Documents", type: .mongodb, sortOrder: 0) + let socket = DatabaseConnection( + name: "Local", + type: .mysql, + additionalFields: ["localSocketPath": "/tmp/mysql.sock"], + sortOrder: 1 + ) + let plugin = DatabaseConnection(name: "Future", type: DatabaseType(rawValue: "FuturePlugin"), sortOrder: 2) + let postgres = connection("Orders", sortOrder: 3) + + let items = ConnectionLibraryPublisher.widgetItems(for: [mongo, socket, plugin, postgres]) + + #expect(items.map(\.name) == ["Orders"]) } @Test("A Spotlight item keeps the identifier and domain earlier builds indexed under") diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift index 01d03ecea5..44206eddad 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionExportServiceTests.swift @@ -1,7 +1,10 @@ +import CloudKit import Foundation import TableProImport @testable import TableProMobile import TableProModels +import TableProSync +import TableProSyncTransport import Testing @MainActor @@ -126,6 +129,19 @@ struct IOSConnectionExportServiceTests { #expect(IOSConnectionExportService.portableSettings(for: connection).sslConfig == nil) } + @Test("A connection synced from a Mac exports its SSL, which the Mac keeps only in its SSL settings") + func macSyncedSSLExports() throws { + let zoneID = CKRecordZone.ID(zoneName: "TestZone", ownerName: CKCurrentUserDefaultName) + let record = SyncRecordMapper.toRecord(DatabaseConnection(name: "Secure", type: .postgresql), zoneID: zoneID) + let fields = record.fields(ConnectionSyncField.self) + fields[.sslEnabled] = nil + let macBlob = #"{"mode":"Verify Identity","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"# + fields[.sslConfigJson] = Data(macBlob.utf8) as CKRecordValue + let connection = try #require(SyncRecordMapper.toConnection(record)) + + #expect(IOSConnectionExportService.portableSettings(for: connection).sslConfig?.mode == "Verify Identity") + } + @Test("A connection in a nested group exports the whole group path") func exportKeepsNestedGroupPath() async throws { let state = makeState() diff --git a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift index 01d07edd48..534e08b77d 100644 --- a/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift +++ b/apple/TableProMobile/TableProMobileTests/IOSConnectionImportServiceTests.swift @@ -86,6 +86,24 @@ struct IOSConnectionImportServiceTests { #expect(rules.maximumGroupDepth == LibraryGroupGraph.maxNestingDepth) #expect(!rules.supportsSavedQueries) #expect(!rules.supportsCredentialProfiles) + #expect(!rules.readsJumpHostKeyFiles) + } + + @Test("A jump host's Mac key path draws no warning, since this device never reads it") + func hopKeyPathDrawsNoWarning() async throws { + let ssh = ExportableSSHConfig( + enabled: true, host: "bastion.example.com", port: nil, username: "deploy", + authMethod: "SSH Agent", privateKeyPath: "", agentSocketPath: "", + jumpHosts: [ + ExportableJumpHost(host: "jump.example.com", port: nil, username: "deploy", authMethod: "Private Key", privateKeyPath: "~/.ssh/jump") + ], + totpMode: nil, totpAlgorithm: nil, totpDigits: nil, totpPeriod: nil + ) + let bundle = try ConnectionBundle(appVersion: "Tests", connections: [BundleConnection(ref: "c1", settings: settings(ssh: ssh))]) + + let row = try #require(try await analyzed(bundle).connections.first) + + #expect(row.warnings.isEmpty) } // MARK: - Files @@ -436,6 +454,50 @@ struct IOSConnectionImportServiceTests { #expect(connection.sslConfiguration == nil) } + @Test("An explicit Required is not mistaken for a copy an older build stored, so it replaces a server Preferred") + func explicitRequiredIsExplicit() async throws { + let connection = try await importedConnection(settings(ssl: ExportableSSLConfig(mode: "Required"))) + let ssl = try #require(connection.sslConfiguration) + + #expect(!ssl.isLegacyRequire) + #expect(!ssl.isMacPreferred) + #expect(!ssl.agrees(withServer: SSLConfiguration(mode: .require, isMacPreferred: true))) + } + + @Test("A Redis index the Mac keeps outside the additional fields opens the same database") + func redisIndexImports() async throws { + let redis = ExportableConnection( + name: "Cache", host: "cache.example.com", port: 6_379, database: "", username: "", + type: DatabaseType.redis.rawValue, redisDatabase: 3 + ) + let connection = try await importedConnection(redis) + + #expect(connection.additionalFields["redisDatabase"] == "3") + } + + @Test("A Redis connection on another index is not taken for a duplicate") + func redisIndexSeparatesDuplicates() async throws { + let existing = DatabaseConnection( + name: "Cache", type: .redis, host: "cache.example.com", port: 6_379, + additionalFields: ["redisDatabase": "3"] + ) + #expect(appState.addConnection(existing)) + let incoming = { (index: Int) in + ExportableConnection( + name: "Cache", host: "cache.example.com", port: 6_379, database: "", username: "", + type: DatabaseType.redis.rawValue, redisDatabase: index + ) + } + let bundle = try ConnectionBundle(appVersion: "Tests", connections: [ + BundleConnection(ref: "c1", settings: incoming(3)), + BundleConnection(ref: "c2", settings: incoming(4)) + ]) + + let rows = try await analyzed(bundle).connections + + #expect(rows.map { $0.duplicate?.id } == [existing.id, nil]) + } + @Test("A Mac Preferred mode opens as required here and exports back as Preferred") func preferredSSLModeSurvivesImport() async throws { let connection = try await importedConnection(settings(ssl: ExportableSSLConfig(mode: "Preferred"))) diff --git a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift index 77e2e196c0..f9abc9272f 100644 --- a/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift +++ b/apple/TableProMobile/TableProMobileTests/MacOnlyImportRowTests.swift @@ -8,14 +8,17 @@ import Testing @MainActor struct MacOnlyImportRowTests { private func analyzed(type: String) throws -> ImportPreview { - let settings = ExportableConnection( + try analyzed(ExportableConnection( name: "Search", host: "db.example.com", port: 9_200, database: "", username: "app", type: type - ) + )) + } + + private func analyzed(_ settings: ExportableConnection) throws -> ImportPreview { let bundle = try ConnectionBundle(appVersion: "Tests", connections: [BundleConnection(ref: "c1", settings: settings)]) return ConnectionImportAnalyzer.analyze( CollectedImport(bundle: bundle, source: .file(name: "Tests.tablepro")), @@ -36,7 +39,7 @@ struct MacOnlyImportRowTests { #expect(row.unsupportedTypeId == nil) #expect(row.settings.type == typeId) #expect(isPreselected(row, in: preview)) - #expect(MacOnlyConnection.importStatus(typeId: row.settings.type) == MacOnlyConnection.status) + #expect(MacOnlyConnection.importStatus(for: row.settings) == MacOnlyConnection.status) } @Test("A MongoDB row stays preselected and reads the Mac only status") @@ -46,7 +49,7 @@ struct MacOnlyImportRowTests { #expect(row.unsupportedTypeId == nil) #expect(isPreselected(row, in: preview)) - #expect(MacOnlyConnection.importStatus(typeId: row.settings.type) == MacOnlyConnection.status) + #expect(MacOnlyConnection.importStatus(for: row.settings) == MacOnlyConnection.status) } @Test("A type this device opens reads no Mac only status") @@ -55,7 +58,39 @@ struct MacOnlyImportRowTests { let row = try #require(preview.connections.first) #expect(isPreselected(row, in: preview)) - #expect(MacOnlyConnection.importStatus(typeId: row.settings.type) == nil) + #expect(MacOnlyConnection.importStatus(for: row.settings) == nil) + } + + @Test("A MySQL row that dials a Mac socket reads the Mac only status") + func socketRowIsMarked() throws { + var settings = ExportableConnection(name: "Local", host: "localhost", port: 3_306, database: "app", username: "root", type: "MySQL") + settings.additionalFields = ["localSocketPath": "/tmp/mysql.sock"] + let row = try #require(try analyzed(settings).connections.first) + + #expect(MacOnlyConnection.importStatus(for: row.settings) == MacOnlyConnection.status) + } + + @Test("A socket row behind an SSH tunnel dials TCP and reads no Mac only status") + func tunnelledSocketRowIsNotMarked() throws { + var settings = ExportableConnection(name: "Local", host: "localhost", port: 3_306, database: "app", username: "root", type: "MySQL") + settings.additionalFields = ["localSocketPath": "/tmp/mysql.sock"] + settings.sshConfig = ExportableSSHConfig( + enabled: true, + host: "bastion.example.com", + port: nil, + username: "deploy", + authMethod: "Private Key", + privateKeyPath: "", + agentSocketPath: "", + jumpHosts: nil, + totpMode: nil, + totpAlgorithm: nil, + totpDigits: nil, + totpPeriod: nil + ) + let row = try #require(try analyzed(settings).connections.first) + + #expect(MacOnlyConnection.importStatus(for: row.settings) == nil) } @Test("A type no TablePro knows stays unsupported and unselected") diff --git a/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift b/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index 634f6a417f..2da4efe017 100644 --- a/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift @@ -886,6 +886,54 @@ struct IOSSyncCoordinatorTests { #expect(coordinator.status == .idle) } + private func legacyRequire() throws -> SSLConfiguration { + try JSONDecoder().decode(SSLConfiguration.self, from: Data(#"{"mode":"require"}"#.utf8)) + } + + private func cacheServerRecord(of connection: DatabaseConnection, ssl: SSLConfiguration) { + var server = connection + server.sslConfiguration = ssl + SyncRecordCache(directory: cacheDirectory, defaults: nil).store([SyncRecordMapper.toRecord(server, zoneID: zoneID)]) + } + + @Test("A Mac Preferred an older iOS build stored as require goes up as Preferred after an iCloud reset") + func legacyPreferredSurvivesReset() async throws { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Orders", type: .postgresql, sslEnabled: true, sslConfiguration: try legacyRequire()) + box.connections = [local] + cacheServerRecord(of: local, ssl: SSLConfiguration(mode: .require, isMacPreferred: true)) + metadata.lastAccountId = "account-a" + metadata.zoneState = .confirmed + let transport = FakeSyncTransport(remoteRecords: [], box: box) + await transport.failNextPulls(with: [CKError(.userDeletedZone)]) + let coordinator = makeCoordinator(box: box, transport: transport) + + await coordinator.sync() + #expect(coordinator.status == .error(.blocked(.dataDeletedFromICloud))) + await coordinator.uploadAgain() + + #expect(box.connections.first?.sslConfiguration?.isMacPreferred == true) + let pushed = try #require(await transport.pushedRecords.first) + #expect(SyncRecordMapper.serverSSL(in: pushed)?.isMacPreferred == true) + } + + @Test("An older copy settles to what the cached record holds, and stays unsettled without one") + func legacyCopiesSettleFromTheCache() async throws { + let box = LibraryStateBox() + let required = DatabaseConnection(name: "Required", type: .mysql, sslEnabled: true, sslConfiguration: try legacyRequire()) + let uncached = DatabaseConnection(name: "Uncached", type: .mysql, sslEnabled: true, sslConfiguration: try legacyRequire()) + box.connections = [required, uncached] + cacheServerRecord(of: required, ssl: SSLConfiguration(mode: .require)) + let coordinator = makeCoordinator(box: box, transport: FakeSyncTransport(remoteRecords: [], box: box)) + + await coordinator.sync() + + let settled = try #require(box.connections.first { $0.id == required.id }?.sslConfiguration) + #expect(!settled.isLegacyRequire) + #expect(!settled.isMacPreferred) + #expect(box.connections.first { $0.id == uncached.id }?.sslConfiguration?.isLegacyRequire == true) + } + /// The block used to live only in memory, so a relaunch forgot it and the first run uploaded /// into a recreated zone without asking. @Test("Data removed from iCloud still waits for the person after a relaunch") diff --git a/apple/TableProMobile/TableProWidget/QuickConnectEntry.swift b/apple/TableProMobile/TableProWidget/QuickConnectEntry.swift index 6295f4a0b0..b32b0518bd 100644 --- a/apple/TableProMobile/TableProWidget/QuickConnectEntry.swift +++ b/apple/TableProMobile/TableProWidget/QuickConnectEntry.swift @@ -11,7 +11,7 @@ struct QuickConnectEntry: TimelineEntry { placeholderItem("Production", type: "PostgreSQL", asset: "postgresql-icon", sortOrder: 0), placeholderItem("Local MySQL", type: "MySQL", asset: "mysql-icon", sortOrder: 1), placeholderItem("Redis Cache", type: "Redis", asset: "redis-icon", sortOrder: 2), - placeholderItem("Analytics", type: "ClickHouse", asset: "clickhouse-icon", sortOrder: 3) + placeholderItem("Orders", type: "SQL Server", asset: "mssql-icon", sortOrder: 3) ] ) } From 0df568eaeb99badff6a78e35f54039beacf49848 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 12:30:04 +0700 Subject: [PATCH 009/151] feat(macos): show an open transaction on every tab and in the connections strip --- .../DatabaseManager+OpenTransactions.swift | 50 ++- .../DatabaseManager+ScopedDriver.swift | 37 ++- .../Database/DatabaseManager+Sessions.swift | 1 + .../Core/Database/DatabaseManager.swift | 8 + .../TablePro/Core/Database/SessionLanes.swift | 13 +- .../Database/SessionTransactionTracker.swift | 125 ++++++++ apple/TablePro/Core/Events/AppEvents.swift | 3 + .../WorkspaceRailCellView.swift | 34 +++ .../Infrastructure/WorkspaceRailStore.swift | 23 +- .../Main/Child/ConnectionTransactionBar.swift | 187 ++++++++++++ .../TablePro/Views/Main/MainContentView.swift | 5 + .../SessionTransactionTrackerTests.swift | 286 ++++++++++++++++++ 12 files changed, 761 insertions(+), 11 deletions(-) create mode 100644 apple/TablePro/Core/Database/SessionTransactionTracker.swift create mode 100644 apple/TablePro/Views/Main/Child/ConnectionTransactionBar.swift create mode 100644 apple/TableProTests/Core/Database/SessionTransactionTrackerTests.swift diff --git a/apple/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift b/apple/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift index b40772d738..aa6ab2db7b 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+OpenTransactions.swift @@ -46,10 +46,41 @@ extension DatabaseManager { return holding } + /// Ends the transaction a hold shows, on the connection that holds it. Refused when that + /// connection was replaced, or when other work ran there after the hold was read: the user + /// confirmed what they saw, not a transaction someone opened since. + internal func endTransaction(_ hold: SessionTransactionTracker.Hold, committing: Bool) async throws { + guard sessionTransactions.isCurrent(hold), let held = sessionTransactions.driver(for: hold) else { + throw SessionTransactionActionError.transactionChanged + } + let heldId = ObjectIdentifier(held) + let scope = DatabaseScope(connectionId: hold.connectionId, database: hold.key.lane ?? "", schema: nil) + try await withScopedDriver(scope: scope, route: .sessionDriver, cancellation: .protectedWrite) { driver in + let unchanged = await MainActor.run { + DatabaseManager.shared.sessionTransactions.isCurrent(hold) + } + guard ObjectIdentifier(driver) == heldId else { throw SessionTransactionActionError.sessionReplaced } + guard unchanged else { throw SessionTransactionActionError.transactionChanged } + guard committing else { + try await driver.rollbackTransaction() + return + } + switch await driver.heldSessionTransactionState() { + case .abortedTransaction: + throw SessionTransactionActionError.cannotCommit + case .idle: + return + default: + try await driver.commitTransaction() + } + } + CatalogChangeService.post(.transactionEnded(connectionId: hold.connectionId)) + } + /// A task group is no bound here: it waits for its child, and the read queues behind any /// statement on the connection's serial queue, which ignores cancellation. The late answer is /// dropped, never cancelled: a driver's cancel can stop the other work queued on the connection. - private static func boundedTransactionState( + internal static func boundedTransactionState( of driver: DatabaseDriver, clock: any Clock ) async -> PluginSessionTransactionState { @@ -69,3 +100,20 @@ extension DatabaseManager { return (try? await gate.wait()) ?? .unknown } } + +internal enum SessionTransactionActionError: Error, LocalizedError, Equatable { + case cannotCommit + case sessionReplaced + case transactionChanged + + var errorDescription: String? { + switch self { + case .cannotCommit: + String(localized: "The transaction on this connection can no longer be committed. Roll it back.") + case .sessionReplaced: + String(localized: "The session that held this transaction has ended.") + case .transactionChanged: + String(localized: "Other work ran on this connection before you confirmed. Check the transaction and try again.") + } + } +} diff --git a/apple/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift b/apple/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift index 0f7abab750..4dfd0827f1 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift @@ -384,18 +384,51 @@ extension DatabaseManager { else { throw DatabaseError.notConnected } - guard let lane else { return try await turn(driver) } + guard let lane else { + return try await recordingTransaction(on: driver, for: .init(connectionId: connectionId, lane: nil)) { + try await turn(driver) + } + } let laneDriver = sessionLanes.isParked(driver, for: connectionId) ? try await verifiedParkedDriver(driver, database: lane, for: connectionId) : driver if sessionLanes.takeTransactionLoss(database: lane, for: connectionId) { throw DatabaseError.connectionFailed(Self.parkedTransactionLostMessage(database: lane)) } - return try await turn(laneDriver) + return try await recordingTransaction(on: laneDriver, for: .init(connectionId: connectionId, lane: lane)) { + try await turn(laneDriver) + } } } } + /// Read inside the turn, after the work and whether or not it threw, so nothing else runs on the + /// connection between the statement and the answer: a failed statement can leave the transaction + /// aborted, and a refused COMMIT can still have ended it. + private func recordingTransaction( + on driver: DatabaseDriver, + for key: SessionTransactionTracker.Key, + _ work: () async throws -> R + ) async throws -> R { + guard driver.supportsTransactions else { return try await work() } + let outcome: Result + do { + outcome = .success(try await work()) + } catch { + outcome = .failure(error) + } + let state = await Self.boundedTransactionState(of: driver, clock: ContinuousClock()) + sessionTransactions.record(state, on: driver, for: key) + return try outcome.get() + } + + /// Whether `driver` is still the connection the key's lane runs on, browsed or parked. + internal func isLiveSessionDriver(_ driver: DatabaseDriver, for key: SessionTransactionTracker.Key) -> Bool { + guard let session = activeSessions[key.connectionId] else { return false } + guard let lane = key.lane else { return session.driver === driver } + return sessionDriver(for: key.connectionId, laneDatabase: lane) === driver + } + /// The turn that guards the browsed connection: per database for a connection that keeps one /// connection per database, per connection otherwise. internal func browsedGateKey(for connectionId: UUID) -> SessionDriverGate.Key { diff --git a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift index b23ecd556d..70ccfcf8e5 100644 --- a/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/apple/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -919,6 +919,7 @@ extension DatabaseManager { } activeSessions[connectionId] = session connectionStatusVersions[connectionId, default: 0] &+= 1 + sessionTransactions.prune(connectionId) AppEvents.shared.connectionStatusChanged.send( ConnectionStatusChange(connectionId: connectionId, status: session.status) ) diff --git a/apple/TablePro/Core/Database/DatabaseManager.swift b/apple/TablePro/Core/Database/DatabaseManager.swift index 2f4c403c06..5eb5f58223 100644 --- a/apple/TablePro/Core/Database/DatabaseManager.swift +++ b/apple/TablePro/Core/Database/DatabaseManager.swift @@ -131,6 +131,11 @@ final class DatabaseManager: ObservableObject { /// The connections a session keeps on databases it is not browsing. See `SessionLanes`. internal let sessionLanes = SessionLanes() + /// Which session connections hold a transaction, recorded after every turn on them. + internal lazy var sessionTransactions = SessionTransactionTracker { [weak self] key, driver in + self?.isLiveSessionDriver(driver, for: key) ?? false + } + /// The driver each connection's health monitor last pinged, so the reconnect that follows a /// failed ping acts only on that driver and never on one a database switch put in its place. /// Held, not just identified: a closed driver that was freed would hand its address, and with it @@ -186,6 +191,9 @@ final class DatabaseManager: ObservableObject { self.pluginManager = pluginManager self.aiAccessApprovals = aiAccessApprovals self.preConnectScriptConsent = preConnectScriptConsent + sessionLanes.onClose = { [weak self] connectionId in + self?.sessionTransactions.prune(connectionId) + } observeConnectionUpdates() observeHealthCheckSetting() } diff --git a/apple/TablePro/Core/Database/SessionLanes.swift b/apple/TablePro/Core/Database/SessionLanes.swift index 257960ac5b..6debc2a838 100644 --- a/apple/TablePro/Core/Database/SessionLanes.swift +++ b/apple/TablePro/Core/Database/SessionLanes.swift @@ -41,6 +41,9 @@ internal final class SessionLanes { /// where plugins never load and no server is there to dial. internal var opener: Opener + /// Told after a parked connection closes, which ends any transaction it held. + internal var onClose: (@MainActor (UUID) -> Void)? + internal init(opener: Opener? = nil) { self.opener = opener ?? { scope in try await MetadataConnectionPool.openServerDriver(for: scope, purpose: .session) @@ -62,10 +65,12 @@ internal final class SessionLanes { /// Keeps a driver for its database. A different driver already parked there is closed, because /// two connections for one database would split the user's session between them. internal func park(_ driver: DatabaseDriver, database: String, for connectionId: UUID) { - if let existing = parked[connectionId]?[database], existing !== driver { - existing.disconnect() - } + let replaced = parked[connectionId]?[database].flatMap { $0 !== driver ? $0 : nil } parked[connectionId, default: [:]][database] = driver + if let replaced { + replaced.disconnect() + onClose?(connectionId) + } } /// Takes a parked driver out to become the browsed one. @@ -90,6 +95,7 @@ internal final class SessionLanes { Self.logger.info("closing parked connection connId=\(connectionId, privacy: .public)") verifiedAt.removeValue(forKey: ObjectIdentifier(driver)) driver.disconnect() + onClose?(connectionId) } /// Closes every parked connection of a session. A disconnect ends them, and so does a rebuilt @@ -108,6 +114,7 @@ internal final class SessionLanes { if !drivers.isEmpty { Self.logger.info("closed \(drivers.count) parked connection(s) connId=\(connectionId, privacy: .public)") } + onClose?(connectionId) } internal func generation(for connectionId: UUID) -> Int { diff --git a/apple/TablePro/Core/Database/SessionTransactionTracker.swift b/apple/TablePro/Core/Database/SessionTransactionTracker.swift new file mode 100644 index 0000000000..fc855be1ef --- /dev/null +++ b/apple/TablePro/Core/Database/SessionTransactionTracker.swift @@ -0,0 +1,125 @@ +// +// SessionTransactionTracker.swift +// TablePro +// + +import Combine +import Foundation +import TableProPluginKit + +/// Which session connections are holding a transaction, as last read after work ran on them. +/// +/// Every statement the app or the user sends down a session connection takes a turn there +/// (`withSessionDriverTurn`), and the turn records what it left behind, so the bar on every tab and +/// the connections strip follow editor runs, grid saves and MCP calls alike. A lane is the database +/// whose own connection holds the transaction; nil is a connection with one driver for all of them. +@MainActor +internal final class SessionTransactionTracker: ObservableObject { + internal struct Key: Hashable, Sendable { + let connectionId: UUID + let lane: String? + } + + internal enum State: Equatable, Sendable { + case open + case aborted + } + + internal struct Hold: Identifiable, Equatable { + let key: Key + let state: State + /// Moves with every turn recorded on the lane, so an action confirmed after other work ran + /// there is refused rather than ending a transaction the user has not seen. + let revision: Int + + var id: Key { key } + var connectionId: UUID { key.connectionId } + } + + private struct Entry { + /// Weak, and compared by identity: a closed driver that was freed hands its address to the + /// next one allocated, which must not inherit this hold. + weak var driver: AnyObject? + var state: State + } + + @Published private(set) var holds: [Key: Hold] = [:] + + private var drivers: [Key: Entry] = [:] + private var revisions: [Key: Int] = [:] + private let isLive: @MainActor (Key, DatabaseDriver) -> Bool + + internal init(isLive: @escaping @MainActor (Key, DatabaseDriver) -> Bool) { + self.isLive = isLive + } + + internal func holds(for connectionId: UUID) -> [Hold] { + holds.values + .filter { $0.connectionId == connectionId } + .sorted { ($0.key.lane ?? "") < ($1.key.lane ?? "") } + } + + /// A one-driver connection holds it for every entry it has; a lane only for its own database. + internal func holdsTransaction(connectionId: UUID, container: String) -> Bool { + holds.keys.contains { $0.connectionId == connectionId && ($0.lane == nil || $0.lane == container) } + } + + internal func driver(for hold: Hold) -> DatabaseDriver? { + guard let entry = drivers[hold.key], let driver = entry.driver as? DatabaseDriver else { return nil } + return driver + } + + internal func isCurrent(_ hold: Hold) -> Bool { + holds[hold.key] == hold + } + + /// An engine that cannot say keeps what an earlier answer from the same driver said, since a + /// refresh never clears what it refreshes; from another driver it says nothing at all. + internal func record(_ state: PluginSessionTransactionState, on driver: DatabaseDriver, for key: Key) { + revisions[key, default: 0] &+= 1 + guard isLive(key, driver) else { + clear(key) + return + } + switch state { + case .inTransaction: + set(.open, on: driver, for: key) + case .abortedTransaction: + set(.aborted, on: driver, for: key) + case .idle, .holdsSessionLocks: + clear(key) + case .unknown: + if drivers[key]?.driver !== driver { + clear(key) + } else if let entry = drivers[key] { + set(entry.state, on: driver, for: key) + } + @unknown default: + clear(key) + } + } + + /// Drops every hold whose connection is not the lane's live one any more: a disconnect, a + /// reconnect onto a new driver, a closed entry or a lost parked connection all end the + /// transaction on the server. + internal func prune(_ connectionId: UUID) { + for (key, entry) in drivers where key.connectionId == connectionId { + if let driver = entry.driver as? DatabaseDriver, isLive(key, driver) { continue } + clear(key) + } + } + + private func set(_ state: State, on driver: DatabaseDriver, for key: Key) { + drivers[key] = Entry(driver: driver, state: state) + let hold = Hold(key: key, state: state, revision: revisions[key, default: 0]) + guard holds[key] != hold else { return } + holds[key] = hold + AppEvents.shared.sessionTransactionsChanged.send(key.connectionId) + } + + private func clear(_ key: Key) { + drivers.removeValue(forKey: key) + guard holds.removeValue(forKey: key) != nil else { return } + AppEvents.shared.sessionTransactionsChanged.send(key.connectionId) + } +} diff --git a/apple/TablePro/Core/Events/AppEvents.swift b/apple/TablePro/Core/Events/AppEvents.swift index b005949558..f4fdd14ac3 100644 --- a/apple/TablePro/Core/Events/AppEvents.swift +++ b/apple/TablePro/Core/Events/AppEvents.swift @@ -93,6 +93,9 @@ final class AppEvents { /// A window put another connections-strip entry in front, so the strip's highlight moves. let visibleContainerChanged = PassthroughSubject() + /// A session connection started or stopped holding a transaction. Payload is the connection's id. + let sessionTransactionsChanged = PassthroughSubject() + // MARK: - Data Sources /// Query history changed (entry added, deleted, or cleared). diff --git a/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift b/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift index c2c8d3f732..ede8d304d8 100644 --- a/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift +++ b/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailCellView.swift @@ -36,11 +36,16 @@ internal final class WorkspaceRailCellView: NSTableCellView { private let icon = NSImageView() private let label = WorkspaceRailCellView.makeLabel() private let identityDot = NSView() + /// The connection holds a transaction behind this entry. A symbol, not a colour, on the corner + /// opposite the identity dot, so neither has to share a channel with the other. + private let transactionBadge = NSImageView() private let content = NSLayoutGuide() private var iconWidthConstraint: NSLayoutConstraint? private var iconHeightConstraint: NSLayoutConstraint? private var dotWidthConstraint: NSLayoutConstraint? private var dotHeightConstraint: NSLayoutConstraint? + private var badgeWidthConstraint: NSLayoutConstraint? + private var badgeHeightConstraint: NSLayoutConstraint? private var appliedTint: NSColor? /// Held as the palette entry rather than a resolved colour, because `systemRed` and the rest /// differ between light and dark: resolving at configure time would freeze the dot at the @@ -80,9 +85,18 @@ internal final class WorkspaceRailCellView: NSTableCellView { identityDot.wantsLayer = true identityDot.layer?.borderWidth = Self.identityDotRimWidth + transactionBadge.translatesAutoresizingMaskIntoConstraints = false + transactionBadge.imageScaling = .scaleProportionallyUpOrDown + transactionBadge.image = NSImage( + systemSymbolName: ConnectionTransactionBarContent.openSymbolName, + accessibilityDescription: nil + ) + transactionBadge.isHidden = true + addSubview(icon) addSubview(label) addSubview(identityDot) + addSubview(transactionBadge) addLayoutGuide(content) /// Not `textField`. In a source list AppKit rewrites that outlet's value on every layout pass /// to one 13pt run, which drops the per-size fonts and the secondary colour and needs 32pt for @@ -101,6 +115,11 @@ internal final class WorkspaceRailCellView: NSTableCellView { dotWidthConstraint = dotWidth dotHeightConstraint = dotHeight + let badgeWidth = transactionBadge.widthAnchor.constraint(equalToConstant: Self.identityDotSize(forIcon: 24)) + let badgeHeight = transactionBadge.heightAnchor.constraint(equalToConstant: Self.identityDotSize(forIcon: 24)) + badgeWidthConstraint = badgeWidth + badgeHeightConstraint = badgeHeight + NSLayoutConstraint.activate([ content.leadingAnchor.constraint(equalTo: leadingAnchor), content.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -120,6 +139,11 @@ internal final class WorkspaceRailCellView: NSTableCellView { dotHeight, identityDot.centerXAnchor.constraint(equalTo: icon.trailingAnchor), identityDot.bottomAnchor.constraint(equalTo: icon.bottomAnchor), + + badgeWidth, + badgeHeight, + transactionBadge.centerXAnchor.constraint(equalTo: icon.trailingAnchor), + transactionBadge.topAnchor.constraint(equalTo: icon.topAnchor), ]) } @@ -156,6 +180,9 @@ internal final class WorkspaceRailCellView: NSTableCellView { dotWidthConstraint?.constant = dotSize dotHeightConstraint?.constant = dotSize identityDot.layer?.cornerRadius = dotSize / 2 + badgeWidthConstraint?.constant = dotSize + badgeHeightConstraint?.constant = dotSize + transactionBadge.isHidden = !entry.holdsTransaction label.attributedStringValue = Self.labelValue(lines: Self.labelLines(for: entry), layout: layout) @@ -253,6 +280,7 @@ internal final class WorkspaceRailCellView: NSTableCellView { let isEmphasized = backgroundStyle == .emphasized effectiveAppearance.performAsCurrentDrawingAppearance { icon.contentTintColor = isEmphasized ? nil : appliedTint + transactionBadge.contentTintColor = isEmphasized ? nil : .systemOrange guard let hue = identityColor?.indicatorColor else { return } identityDot.layer?.backgroundColor = NSColor(hue).cgColor @@ -320,6 +348,9 @@ internal final class WorkspaceRailCellView: NSTableCellView { if let identity = connection.identityColor { parts.append(String(format: String(localized: "Color: %@"), identity.displayName)) } + if entry.holdsTransaction { + parts.append(String(localized: "Transaction open")) + } return parts.joined(separator: ", ") } @@ -329,6 +360,9 @@ internal final class WorkspaceRailCellView: NSTableCellView { parts.append(String(format: containerFormat(for: entry.containerTarget), entry.container)) } parts.append(statusDescription(for: entry.status)) + if entry.holdsTransaction { + parts.append(String(localized: "transaction open")) + } if let identity = entry.connection.identityColor { parts.append(String(format: String(localized: "color %@"), identity.displayName)) } diff --git a/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift b/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift index 5610939c9b..752da49bf3 100644 --- a/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift +++ b/apple/TablePro/Core/Services/Infrastructure/WorkspaceRailStore.swift @@ -13,17 +13,21 @@ internal struct WorkspaceRailEntry: Identifiable, Equatable { /// What the container names on this engine, so the row can say "schema public" rather /// than calling every container a database. internal let containerTarget: ContainerSwitchTarget? + /// A session connection behind this entry holds a transaction, so it shows from other entries. + internal let holdsTransaction: Bool internal init( workspace: WorkspaceID, connection: DatabaseConnection, status: ConnectionStatus, - containerTarget: ContainerSwitchTarget? = .database + containerTarget: ContainerSwitchTarget? = .database, + holdsTransaction: Bool = false ) { self.workspace = workspace self.connection = connection self.status = status self.containerTarget = containerTarget + self.holdsTransaction = holdsTransaction } internal var id: WorkspaceID { workspace } @@ -68,7 +72,13 @@ internal enum WorkspaceRailStore { closingContainers: { workspacesById[$0]?.lazy.compactMap(\.closingContainer).first }, visibleContainers: { Set(coordinatorsById[$0]?.compactMap(\.tabManager.visibleContainer) ?? []) }, openedAt: workspacesById.compactMapValues { $0.map(\.openedAt).min() }, - storedOrder: WorkspaceRailOrderStore.shared.order + storedOrder: WorkspaceRailOrderStore.shared.order, + holdsTransaction: { workspace in + DatabaseManager.shared.sessionTransactions.holdsTransaction( + connectionId: workspace.connectionId, + container: workspace.container + ) + } ) } @@ -89,7 +99,8 @@ internal enum WorkspaceRailStore { closingContainers: (UUID) -> String? = { _ in nil }, visibleContainers: (UUID) -> Set = { _ in [] }, openedAt: [UUID: Date] = [:], - storedOrder: [WorkspaceID] + storedOrder: [WorkspaceID], + holdsTransaction: (WorkspaceID) -> Bool = { _ in false } ) -> [WorkspaceRailEntry] { var connections: [UUID: DatabaseConnection] = [:] var statuses: [UUID: ConnectionStatus] = [:] @@ -137,7 +148,8 @@ internal enum WorkspaceRailStore { workspace: workspace, connection: connection, status: status, - containerTarget: targets[workspace.connectionId] + containerTarget: targets[workspace.connectionId], + holdsTransaction: holdsTransaction(workspace) ) } } @@ -175,7 +187,8 @@ internal enum WorkspaceRailStore { events.visibleContainerChanged.eraseToAnyPublisher(), events.browseContainerChanged.map { _ in () }.eraseToAnyPublisher(), events.connectionStatusChanged.map { _ in () }.eraseToAnyPublisher(), - events.connectionUpdated.map { _ in () }.eraseToAnyPublisher() + events.connectionUpdated.map { _ in () }.eraseToAnyPublisher(), + events.sessionTransactionsChanged.map { _ in () }.eraseToAnyPublisher() ) .eraseToAnyPublisher() } diff --git a/apple/TablePro/Views/Main/Child/ConnectionTransactionBar.swift b/apple/TablePro/Views/Main/Child/ConnectionTransactionBar.swift new file mode 100644 index 0000000000..6106744d43 --- /dev/null +++ b/apple/TablePro/Views/Main/Child/ConnectionTransactionBar.swift @@ -0,0 +1,187 @@ +// +// ConnectionTransactionBar.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// What one hold's row says, kept apart from the view so the wording is pinned by a test. +internal struct ConnectionTransactionBarContent: Equatable { + static let openSymbolName = "hourglass" + static let abortedSymbolName = "exclamationmark.triangle.fill" + + let isAborted: Bool + let title: String + let subtitle: String + let accessibilityLabel: String + let offersCommit: Bool + + var symbolName: String { + isAborted ? Self.abortedSymbolName : Self.openSymbolName + } + + init(isAborted: Bool, connectionName: String, database: String?, safeModeLevel: SafeModeLevel) { + self.isAborted = isAborted + let database = database.flatMap { $0.isEmpty ? nil : $0 } + if isAborted { + title = String(localized: "Transaction Failed") + subtitle = String(localized: "Roll it back to continue.") + accessibilityLabel = database.map { + String( + format: String(localized: "Transaction failed on %1$@, %2$@. Roll it back to continue."), + $0, + connectionName + ) + } ?? String(format: String(localized: "Transaction failed on %@. Roll it back to continue."), connectionName) + offersCommit = false + return + } + title = String(localized: "Transaction Open") + subtitle = database ?? connectionName + accessibilityLabel = database.map { + String(format: String(localized: "Transaction open on %1$@, %2$@"), $0, connectionName) + } ?? String(format: String(localized: "Transaction open on %@"), connectionName) + offersCommit = !safeModeLevel.blocksAllWrites + } +} + +/// Shown above every tab of a connection while one of its session connections holds a transaction. +internal struct ConnectionTransactionBar: View { + let connectionId: UUID + @ObservedObject var transactions: SessionTransactionTracker + let databaseManager: DatabaseManager + + @State private var actingOn: SessionTransactionTracker.Key? + + var body: some View { + let holds = transactions.holds(for: connectionId) + if let session = databaseManager.session(for: connectionId), !holds.isEmpty { + VStack(spacing: 0) { + ForEach(holds) { hold in + row(hold, content: content(for: hold, in: session)) + Divider() + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("connection-transaction-bar") + } + } + + private func content(for hold: SessionTransactionTracker.Hold, in session: ConnectionSession) -> ConnectionTransactionBarContent { + ConnectionTransactionBarContent( + isAborted: hold.state == .aborted, + connectionName: session.connection.name, + database: hold.key.lane ?? namedDatabase(of: session), + safeModeLevel: session.safeModeLevel + ) + } + + // SQLite, DuckDB and other single-file engines name no database the server knows. + private func namedDatabase(of session: ConnectionSession) -> String? { + guard PluginManager.shared.supportsDatabaseSwitching(for: session.connection.type) else { return nil } + return session.resolvedBrowseDatabase + } + + private func row(_ hold: SessionTransactionTracker.Hold, content: ConnectionTransactionBarContent) -> some View { + HStack(spacing: 10) { + HStack(spacing: 8) { + if actingOn == hold.key { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: content.symbolName) + .foregroundStyle(content.isAborted ? Color.red : Color.orange) + } + VStack(alignment: .leading, spacing: 1) { + Text(content.title) + .font(.callout.weight(.semibold)) + Text(content.subtitle) + .font(.callout) + .foregroundStyle(.secondary) + .truncationMode(.middle) + } + .lineLimit(1) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(content.accessibilityLabel) + + Spacer(minLength: 8) + + Button(String(localized: "Roll Back")) { + act(on: hold) { try await rollBack(hold) } + } + if content.offersCommit { + Button(String(localized: "Commit")) { + act(on: hold) { try await commit(hold) } + } + } + } + .controlSize(.small) + .disabled(actingOn != nil) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .paneBarBackground() + } + + // Never gated by Safe Mode: rolling back keeps nothing, so it is the way out at every level. + private func rollBack(_ hold: SessionTransactionTracker.Hold) async throws { + let confirmed = await AlertHelper.confirmDestructive( + title: String(localized: "Roll Back the Transaction?"), + message: String(localized: "Its uncommitted changes are discarded."), + confirmButton: String(localized: "Roll Back"), + window: NSApp.keyWindow + ) + guard confirmed else { return } + try await databaseManager.endTransaction(hold, committing: false) + } + + // A COMMIT is a write, so it asks what a typed one asks: nothing at Silent, a confirmation at + // Alert, Touch ID at Safe Mode. + private func commit(_ hold: SessionTransactionTracker.Hold) async throws { + guard let session = databaseManager.session(for: connectionId) else { return } + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connectionId, + databaseType: session.connection.type, + sql: "COMMIT", + kind: .writeQuery, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Commit the transaction") + ) + ) + switch decision { + case .authorized: + try await databaseManager.endTransaction(hold, committing: true) + case .denied(_, .cancelledByUser): + return + case .denied(let reason, .policy): + throw SessionTransactionDenial(reason: reason) + } + } + + private func act(on hold: SessionTransactionTracker.Hold, _ action: @escaping @MainActor () async throws -> Void) { + actingOn = hold.key + Task { + defer { actingOn = nil } + do { + try await action() + } catch is CancellationError { + return + } catch { + AlertHelper.showErrorSheet( + title: String(localized: "Could Not End the Transaction"), + message: error.localizedDescription, + window: NSApp.keyWindow + ) + } + } + } +} + +private struct SessionTransactionDenial: LocalizedError { + let reason: String + + var errorDescription: String? { reason } +} diff --git a/apple/TablePro/Views/Main/MainContentView.swift b/apple/TablePro/Views/Main/MainContentView.swift index d6fafcdaae..73ac1cd6a5 100644 --- a/apple/TablePro/Views/Main/MainContentView.swift +++ b/apple/TablePro/Views/Main/MainContentView.swift @@ -462,6 +462,11 @@ struct MainContentView: View { } Divider() } + ConnectionTransactionBar( + connectionId: connection.id, + transactions: DatabaseManager.shared.sessionTransactions, + databaseManager: DatabaseManager.shared + ) editorContentView } .onChange(of: toolbarState.connectionWarning) { warning in diff --git a/apple/TableProTests/Core/Database/SessionTransactionTrackerTests.swift b/apple/TableProTests/Core/Database/SessionTransactionTrackerTests.swift new file mode 100644 index 0000000000..6187b859bc --- /dev/null +++ b/apple/TableProTests/Core/Database/SessionTransactionTrackerTests.swift @@ -0,0 +1,286 @@ +// +// SessionTransactionTrackerTests.swift +// TableProTests +// +// The bar on every tab and the connections strip mark read one record of which session connections +// hold a transaction. Every turn on a session connection writes it, so editor runs, grid saves and +// MCP transaction_control calls all move it, and a session that ends clears it. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Session transaction tracking", .serialized) +@MainActor +struct SessionTransactionTrackerTests { + private static func seed( + database: String = "app" + ) -> (connection: DatabaseConnection, driver: TransactionScriptedDriver) { + let connection = TestFixtures.makeConnection(database: database, type: .mysql) + let driver = TransactionScriptedDriver() + var session = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: driver) + ) + session.browseDatabase = database + session.status = .connected + DatabaseManager.shared.injectSession(session, for: connection.id) + return (connection, driver) + } + + private static func run(_ sql: String, on connectionId: UUID, database: String = "app") async throws { + let scope = DatabaseScope(connectionId: connectionId, database: database, schema: nil) + _ = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: .sessionDriver, + cancellation: .protectedWrite + ) { driver in + try await driver.execute(query: sql) + } + } + + private static var tracker: SessionTransactionTracker { DatabaseManager.shared.sessionTransactions } + + private static func states(_ connectionId: UUID, in tracker: SessionTransactionTracker? = nil) -> [SessionTransactionTracker.State] { + (tracker ?? Self.tracker).holds(for: connectionId).map { $0.state } + } + + @Test("A turn that leaves a transaction open records it, and one that ends it clears it") + func turnRecordsAndClears() async throws { + let (connection, _) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + try await Self.run("BEGIN", on: connection.id) + #expect(Self.states(connection.id) == [.open]) + + try await Self.run("COMMIT", on: connection.id) + #expect(Self.tracker.holds(for: connection.id).isEmpty) + } + + @Test("A statement that fails inside the transaction shows it can only be rolled back") + func failedStatementRecordsAbortedState() async throws { + let (connection, driver) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + try await Self.run("BEGIN", on: connection.id) + driver.failsNextStatementAborting = true + await #expect(throws: (any Error).self) { + try await Self.run("INSERT INTO missing VALUES (1)", on: connection.id) + } + + #expect(Self.states(connection.id) == [.aborted]) + } + + @Test("MCP transaction_control moves the record like a typed statement does") + func mcpTransactionControlMovesTheRecord() async throws { + let (connection, driver) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = DatabaseScope(connectionId: connection.id, database: "app", schema: nil) + let bridge = MCPConnectionBridge() + + _ = try await bridge.transactionControl(scope: scope, action: "begin") + #expect(Self.states(connection.id) == [.open]) + + _ = try await bridge.transactionControl(scope: scope, action: "rollback") + #expect(Self.tracker.holds(for: connection.id).isEmpty) + #expect(driver.statements.suffix(1) == ["ROLLBACK"]) + } + + @Test("A session that ends takes its record with it") + func lostSessionClears() async throws { + let (connection, _) = Self.seed() + try await Self.run("BEGIN", on: connection.id) + #expect(!Self.tracker.holds(for: connection.id).isEmpty) + + DatabaseManager.shared.removeSession(for: connection.id) + + #expect(Self.tracker.holds(for: connection.id).isEmpty) + } + + @Test("A reconnect onto a new driver clears the old driver's record") + func replacedDriverClears() async throws { + let (connection, _) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + try await Self.run("BEGIN", on: connection.id) + + DatabaseManager.shared.updateSession(connection.id) { session in + session.driver = PluginDriverAdapter(connection: connection, pluginDriver: TransactionScriptedDriver()) + } + + #expect(Self.tracker.holds(for: connection.id).isEmpty) + } + + @Test("Commit ends the transaction on the connection that holds it") + func commitEndsTheHeldTransaction() async throws { + let (connection, driver) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + try await Self.run("BEGIN", on: connection.id) + let hold = try #require(Self.tracker.holds(for: connection.id).first) + + try await DatabaseManager.shared.endTransaction(hold, committing: true) + + #expect(driver.statements.suffix(1) == ["COMMIT"]) + #expect(Self.tracker.holds(for: connection.id).isEmpty) + } + + @Test("Work that ran after the hold was read refuses the action and sends nothing") + func otherWorkRefusesTheAction() async throws { + let (connection, driver) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + try await Self.run("BEGIN", on: connection.id) + let hold = try #require(Self.tracker.holds(for: connection.id).first) + try await Self.run("INSERT INTO t VALUES (1)", on: connection.id) + let sent = driver.statements.count + + await #expect(throws: SessionTransactionActionError.transactionChanged) { + try await DatabaseManager.shared.endTransaction(hold, committing: false) + } + #expect(driver.statements.count == sent) + } + + @Test("Commit on a transaction that can no longer commit sends nothing") + func abortedCommitSendsNothing() async throws { + let (connection, driver) = Self.seed() + defer { DatabaseManager.shared.removeSession(for: connection.id) } + try await Self.run("BEGIN", on: connection.id) + let hold = try #require(Self.tracker.holds(for: connection.id).first) + driver.state = .abortedTransaction + let sent = driver.statements.count + + await #expect(throws: SessionTransactionActionError.cannotCommit) { + try await DatabaseManager.shared.endTransaction(hold, committing: true) + } + #expect(driver.statements.count == sent) + } + + @Test("A lane holds a transaction for its own database's entry only; one driver holds it for every entry") + func stripMarkFollowsTheLane() { + let laneDriver = PluginDriverAdapter(connection: TestFixtures.makeConnection(), pluginDriver: TransactionScriptedDriver()) + let tracker = SessionTransactionTracker { _, _ in true } + let connectionId = UUID() + + tracker.record(.inTransaction, on: laneDriver, for: .init(connectionId: connectionId, lane: "sales")) + #expect(tracker.holdsTransaction(connectionId: connectionId, container: "sales")) + #expect(!tracker.holdsTransaction(connectionId: connectionId, container: "hr")) + + let other = UUID() + tracker.record(.inTransaction, on: laneDriver, for: .init(connectionId: other, lane: nil)) + #expect(tracker.holdsTransaction(connectionId: other, container: "anything")) + } + + @Test("An engine that cannot say keeps the same driver's earlier answer and drops another driver's") + func unknownKeepsOnlyTheSameDriver() { + let first = PluginDriverAdapter(connection: TestFixtures.makeConnection(), pluginDriver: TransactionScriptedDriver()) + let second = PluginDriverAdapter(connection: TestFixtures.makeConnection(), pluginDriver: TransactionScriptedDriver()) + let tracker = SessionTransactionTracker { _, _ in true } + let key = SessionTransactionTracker.Key(connectionId: UUID(), lane: nil) + + tracker.record(.inTransaction, on: first, for: key) + tracker.record(.unknown, on: first, for: key) + #expect(Self.states(key.connectionId, in: tracker) == [.open]) + + tracker.record(.unknown, on: second, for: key) + #expect(tracker.holds(for: key.connectionId).isEmpty) + } + + @Test("The strip marks the entries the record names") + func railEntriesCarryTheMark() { + let connection = TestFixtures.makeConnection(database: "sales", type: .mysql) + let entries = WorkspaceRailStore.resolveEntries( + openConnectionIds: [connection.id], + sessions: [:], + storedConnections: [connection.id: connection], + containerTarget: { _ in .database }, + tabs: { _ in [] }, + openedContainers: { _ in ["sales", "hr"] }, + storedOrder: [], + holdsTransaction: { $0.container == "sales" } + ) + + let sales = entries.first { $0.container == "sales" } + let hr = entries.first { $0.container == "hr" } + #expect(sales?.holdsTransaction == true) + #expect(hr?.holdsTransaction == false) + let spoken = sales.map { WorkspaceRailCellView.voiceOverLabel(for: $0) } ?? "" + #expect(spoken.contains("transaction open")) + } + + @Test("Read-only hides Commit, an aborted transaction offers only Roll Back") + func barContent() { + let open = ConnectionTransactionBarContent( + isAborted: false, + connectionName: "Prod", + database: "sales", + safeModeLevel: .silent + ) + #expect(open.title == "Transaction Open") + #expect(open.subtitle == "sales") + #expect(open.offersCommit) + #expect(open.accessibilityLabel == "Transaction open on sales, Prod") + + let readOnly = ConnectionTransactionBarContent( + isAborted: false, + connectionName: "Prod", + database: nil, + safeModeLevel: .readOnly + ) + #expect(!readOnly.offersCommit) + #expect(readOnly.subtitle == "Prod") + + let aborted = ConnectionTransactionBarContent( + isAborted: true, + connectionName: "Prod", + database: "", + safeModeLevel: .silent + ) + #expect(!aborted.offersCommit) + #expect(aborted.title == "Transaction Failed") + #expect(aborted.accessibilityLabel == "Transaction failed on Prod. Roll it back to continue.") + } +} + +/// Answers its session state from the statements it saw, like a server that reports the flag in +/// every reply. +private final class TransactionScriptedDriver: PluginDriverStub, @unchecked Sendable { + private let lock = NSLock() + private var current: PluginSessionTransactionState = .idle + private var sent: [String] = [] + private var abortsNext = false + + var state: PluginSessionTransactionState { + get { lock.withLock { current } } + set { lock.withLock { current = newValue } } + } + + var statements: [String] { lock.withLock { sent } } + + var failsNextStatementAborting: Bool { + get { lock.withLock { abortsNext } } + set { lock.withLock { abortsNext = newValue } } + } + + func execute(query: String) async throws -> PluginQueryResult { + let failure: Bool = lock.withLock { + sent.append(query) + if abortsNext { + abortsNext = false + current = .abortedTransaction + return true + } + switch query.uppercased() { + case "BEGIN": current = .inTransaction + case "COMMIT", "ROLLBACK": current = .idle + default: break + } + return false + } + if failure { throw TransactionScriptedFailure() } + return .empty + } + + func sessionTransactionState() async -> PluginSessionTransactionState { state } +} + +private struct TransactionScriptedFailure: Error {} From d792d6fc75fbb6e1a64d3acb818fc81cc25e681b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 12:30:11 +0700 Subject: [PATCH 010/151] feat(ios): show open transactions on DuckDB and SQL Server --- .../MSSQLSessionTransaction.swift | 33 +++++ .../MSSQLSessionTransactionTests.swift | 45 ++++++ .../MSSQLDriverPlugin/MSSQLPlugin.swift | 12 +- .../MSSQLSessionTransaction.swift | 35 ----- .../Drivers/DuckDBDriver+Transaction.swift | 88 ++++++++++++ .../TableProMobile/Drivers/MSSQLDriver.swift | 10 ++ .../Drivers/DuckDBDriverTests.swift | 128 ++++++++++++++++++ 7 files changed, 312 insertions(+), 39 deletions(-) create mode 100644 apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionTransaction.swift create mode 100644 apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionTransactionTests.swift delete mode 100644 apple/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift create mode 100644 apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Transaction.swift diff --git a/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionTransaction.swift b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionTransaction.swift new file mode 100644 index 0000000000..89c8187854 --- /dev/null +++ b/apple/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSessionTransaction.swift @@ -0,0 +1,33 @@ +import Foundation + +/// What the session has open, from one round trip. +/// +/// `@@TRANCOUNT` counts open transactions, explicit and implicit alike. `XACT_STATE()` answers -1 for one that can no +/// longer be committed, which is what a batch-aborting error leaves behind, and telling the user to commit that one +/// silently discards their work. +/// +/// `SET IMPLICIT_TRANSACTIONS ON` alone is not a transaction. Measured on Azure SQL Edge: with the option on and +/// nothing run since, `SELECT @@TRANCOUNT, @@OPTIONS & 2` answered `0 2` and asking again still answered 0, so nothing +/// is pending and a caller's own transaction commits only its own statements. The first statement after that makes +/// `@@TRANCOUNT` 1. +public enum MSSQLSessionTransaction { + public static let probe = "SELECT @@TRANCOUNT, XACT_STATE()" + + public enum State: Sendable, Equatable { + case idle + case open + case uncommittable + } + + public static func state(tranCount: String?, transactionState: String?) -> State? { + guard let count = tranCount.flatMap(integer) else { return nil } + if transactionState.flatMap(integer) == uncommittableXactState { return .uncommittable } + return count > 0 ? .open : .idle + } + + private static let uncommittableXactState = -1 + + private static func integer(_ text: String) -> Int? { + Int(text.trimmingCharacters(in: .whitespaces)) + } +} diff --git a/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionTransactionTests.swift b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionTransactionTests.swift new file mode 100644 index 0000000000..64fa23bf0d --- /dev/null +++ b/apple/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSessionTransactionTests.swift @@ -0,0 +1,45 @@ +import Foundation +import TableProMSSQLCore +import Testing + +struct MSSQLSessionTransactionTests { + @Test("The probe asks for the open transaction count and whether the transaction can still commit") + func probeAsksForBoth() { + #expect(MSSQLSessionTransaction.probe == "SELECT @@TRANCOUNT, XACT_STATE()") + } + + @Test("No open transaction is idle") + func zeroCountIsIdle() { + #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: "0") == .idle) + } + + @Test("Any open transaction, nested or not, is open") + func positiveCountIsOpen() { + #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: "1") == .open) + #expect(MSSQLSessionTransaction.state(tranCount: "2", transactionState: "1") == .open) + } + + @Test("Padding around the count is read past") + func paddedCountIsRead() { + #expect(MSSQLSessionTransaction.state(tranCount: " 1 ", transactionState: " 1 ") == .open) + } + + @Test("XACT_STATE -1 is a transaction that can only roll back") + func uncommittableTransaction() { + #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: "-1") == .uncommittable) + #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: " -1 ") == .uncommittable) + } + + @Test("An unreadable XACT_STATE still reports the count") + func unreadableXactStateFallsBackToTheCount() { + #expect(MSSQLSessionTransaction.state(tranCount: "1", transactionState: nil) == .open) + #expect(MSSQLSessionTransaction.state(tranCount: "0", transactionState: nil) == .idle) + } + + @Test("An unreadable count says nothing") + func unreadableCountIsNil() { + #expect(MSSQLSessionTransaction.state(tranCount: nil, transactionState: "1") == nil) + #expect(MSSQLSessionTransaction.state(tranCount: "", transactionState: "-1") == nil) + #expect(MSSQLSessionTransaction.state(tranCount: "n/a", transactionState: "0") == nil) + } +} diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 0ce548e07c..b829028eae 100644 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/apple/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -511,16 +511,20 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "BEGIN TRANSACTION") } - /// One round trip, and only when a caller is about to own a transaction on this session. It - /// goes through `executeInternal` rather than `execute` so the app's query cancellation and - /// history never see it. + // executeInternal rather than execute, so the app's query cancellation and history never see the probe. func sessionTransactionState() async -> PluginSessionTransactionState { guard let result = try? await executeInternal(MSSQLSessionTransaction.probe) else { return .unknown } let row = result.rows.first - return MSSQLSessionTransaction.state( + let state = MSSQLSessionTransaction.state( tranCount: row?.first?.asText, transactionState: row?.dropFirst().first?.asText ) + switch state { + case .idle: return .idle + case .open: return .inTransaction + case .uncommittable: return .abortedTransaction + case nil: return .unknown + } } // MARK: - Query Execution diff --git a/apple/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift b/apple/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift deleted file mode 100644 index 4dd90b4af6..0000000000 --- a/apple/Plugins/MSSQLDriverPlugin/MSSQLSessionTransaction.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// MSSQLSessionTransaction.swift -// MSSQLDriverPlugin -// - -import Foundation -import TableProPluginKit - -/// How the driver asks SQL Server what the session has open, and how the answer is read. No CFreeTDS -/// import, so TableProTests can exercise the decision without the plugin bundle. -/// -/// `@@TRANCOUNT` is the count of open transactions, explicit and implicit alike. `XACT_STATE()` -/// answers -1 for one that can no longer be committed, which is the state a batch-aborting error -/// leaves behind, and telling the user to commit that one silently discards their work. -/// -/// `SET IMPLICIT_TRANSACTIONS ON` alone is deliberately not read as a transaction. Measured on Azure -/// SQL Edge: with the option on and nothing run since, `SELECT @@TRANCOUNT, @@OPTIONS & 2` answered -/// `0 2` and asking again still answered 0, so nothing is pending and a caller's own transaction -/// commits only its own statements. The first statement after that makes `@@TRANCOUNT` 1, which is -/// what this reports. -enum MSSQLSessionTransaction { - static let probe = "SELECT @@TRANCOUNT, XACT_STATE()" - - static func state(tranCount: String?, transactionState: String?) -> PluginSessionTransactionState { - guard let count = tranCount.flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) else { - return .unknown - } - if transactionState.flatMap({ Int($0.trimmingCharacters(in: .whitespaces)) }) == uncommittable { - return .abortedTransaction - } - return count > 0 ? .inTransaction : .idle - } - - private static let uncommittable = -1 -} diff --git a/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Transaction.swift b/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Transaction.swift new file mode 100644 index 0000000000..aaf8769843 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Drivers/DuckDBDriver+Transaction.swift @@ -0,0 +1,88 @@ +import CDuckDB +import Foundation +import TableProDatabase +import TableProModels + +// No C API call answers this. A BEGIN inside a transaction aborts it, and txid_current() on a catalog DuckDB does not +// own destroys it, so the probe reads the catalog type, then txid_current() twice: the ids match only inside a +// transaction. Measured for the Mac driver: Plugins/DuckDBDriverPlugin/DuckDBTransactionProbe.swift. +nonisolated enum DuckDBTransactionProbe { + nonisolated enum Reading: Equatable, Sendable { + case value(String) + // Refused with DUCKDB_ERROR_TRANSACTION, "Current transaction is aborted (please ROLLBACK)". + case abortedTransaction + case unreadable + } + + static let catalogTypeQuery = + "SELECT type::VARCHAR FROM duckdb_databases() WHERE database_name = current_database()" + + static let transactionIdQuery = "SELECT txid_current()::VARCHAR" + + // Nil when the catalog is DuckDB's own and the transaction id has to be asked. + static func state(catalogType: Reading) -> DriverTransactionState? { + switch catalogType { + case .abortedTransaction: + return .abortedTransaction + case .unreadable: + return .unknown + case .value(let type): + // The Mac driver falls back to the statements it saw open a transaction; this one tracks none. + return type.lowercased() == nativeCatalogType ? nil : .unknown + } + } + + static func state(first: Reading, second: Reading) -> DriverTransactionState { + if first == .abortedTransaction || second == .abortedTransaction { return .abortedTransaction } + guard case .value(let firstId) = first, case .value(let secondId) = second else { return .unknown } + return firstId == secondId ? .explicitTransaction : .idle + } + + private static let nativeCatalogType = "duckdb" +} + +nonisolated extension DuckDBDriver { + func sessionTransactionState() async -> DriverTransactionState { + await actor.transactionState() + } +} + +extension DuckDBActor { + // One actor turn for every reading, so no other statement lands between the two transaction ids. + func transactionState() -> DriverTransactionState { + guard connection != nil else { return .unknown } + // A statement would end the open stream inside DuckDB. Its consumer's own work reads the state once it ends. + guard streamResult == nil else { return .unknown } + let catalogType = probeReading(DuckDBTransactionProbe.catalogTypeQuery) + if let decided = DuckDBTransactionProbe.state(catalogType: catalogType) { + return decided + } + return DuckDBTransactionProbe.state( + first: probeReading(DuckDBTransactionProbe.transactionIdQuery), + second: probeReading(DuckDBTransactionProbe.transactionIdQuery) + ) + } + + // duckdb_query is the one path that reports the error type; the message text is no contract. + private func probeReading(_ sql: String) -> DuckDBTransactionProbe.Reading { + guard let connection else { return .unreadable } + var result = duckdb_result() + defer { duckdb_destroy_result(&result) } + guard duckdb_query(connection, sql, &result) != DuckDBError else { + return duckdb_result_error_type(&result) == DUCKDB_ERROR_TRANSACTION ? .abortedTransaction : .unreadable + } + guard duckdb_column_count(&result) > 0 else { return .unreadable } + var chunk = duckdb_fetch_chunk(result) + guard chunk != nil else { return .unreadable } + defer { duckdb_destroy_data_chunk(&chunk) } + guard duckdb_data_chunk_get_size(chunk) > 0, let vector = duckdb_data_chunk_get_vector(chunk, 0) else { + return .unreadable + } + let type = duckdb_column_type(&result, 0) + let column = DuckDBStreamColumn(name: "", typeName: Self.typeName(for: type), type: type, castToText: false) + guard case .text(let value) = Self.decodeCell(vector: vector, row: 0, column: column, options: .default) else { + return .unreadable + } + return .value(value) + } +} diff --git a/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index f6afc8d65c..a5ce7826c9 100644 --- a/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/apple/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -199,6 +199,16 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { _ = try await runQuery(MSSQLSchemaQueries.rollbackTransaction) } + func sessionTransactionState() async -> DriverTransactionState { + guard let row = try? await runQuery(MSSQLSessionTransaction.probe).rows.first else { return .unknown } + switch MSSQLSessionTransaction.state(tranCount: row[safe: 0] ?? nil, transactionState: row[safe: 1] ?? nil) { + case .idle: return .idle + case .open: return .explicitTransaction + case .uncommittable: return .abortedTransaction + case nil: return .unknown + } + } + // MARK: - Database & Schema Navigation func fetchDatabases() async throws -> [String] { diff --git a/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift b/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift index 732a17dd9a..305182eb02 100644 --- a/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift +++ b/apple/TableProMobile/TableProMobileTests/Drivers/DuckDBDriverTests.swift @@ -1,6 +1,7 @@ import TableProDatabase @testable import TableProMobile import TableProModels +import Testing import XCTest final class DuckDBDriverTests: XCTestCase { @@ -320,3 +321,130 @@ final class DuckDBDriverTests: XCTestCase { XCTAssertEqual(result.rows[0][0], Data("abc".utf8).base64EncodedString()) } } + +struct DuckDBDriverTransactionStateTests { + private func connectedDriver() async throws -> DuckDBDriver { + let driver = DuckDBDriver(source: .inMemory) + try await driver.connect() + return driver + } + + @Test("A new session holds no transaction") + func idleAfterConnect() async throws { + let driver = try await connectedDriver() + #expect(await driver.sessionTransactionState() == .idle) + try await driver.disconnect() + } + + @Test("BEGIN opens a transaction and COMMIT ends it") + func beginThenCommit() async throws { + let driver = try await connectedDriver() + _ = try await driver.execute(query: "BEGIN TRANSACTION") + #expect(await driver.sessionTransactionState() == .explicitTransaction) + _ = try await driver.execute(query: "COMMIT") + #expect(await driver.sessionTransactionState() == .idle) + try await driver.disconnect() + } + + @Test("A statement that fails inside a transaction leaves it aborted until ROLLBACK") + func failureInsideTransactionAborts() async throws { + let driver = try await connectedDriver() + _ = try await driver.execute(query: "CREATE TABLE u (x INTEGER PRIMARY KEY)") + _ = try await driver.execute(query: "BEGIN TRANSACTION") + _ = try await driver.execute(query: "INSERT INTO u VALUES (1)") + await #expect(throws: (any Error).self) { + try await driver.execute(query: "INSERT INTO u VALUES (1)") + } + #expect(await driver.sessionTransactionState() == .abortedTransaction) + _ = try await driver.execute(query: "ROLLBACK") + #expect(await driver.sessionTransactionState() == .idle) + try await driver.disconnect() + } + + @Test("Asking twice inside a transaction keeps it and its row for the COMMIT") + func probingKeepsTheTransactionForCommit() async throws { + let driver = try await connectedDriver() + _ = try await driver.execute(query: "CREATE TABLE kept (x INTEGER)") + _ = try await driver.execute(query: "BEGIN TRANSACTION") + _ = try await driver.execute(query: "INSERT INTO kept VALUES (1)") + #expect(await driver.sessionTransactionState() == .explicitTransaction) + #expect(await driver.sessionTransactionState() == .explicitTransaction) + _ = try await driver.execute(query: "COMMIT") + let rows = try await driver.execute(query: "SELECT x FROM kept").rows + #expect(rows == [["1"]]) + try await driver.disconnect() + } + + @Test("Asking inside a transaction commits nothing, so a ROLLBACK still undoes the row") + func probingCommitsNothing() async throws { + let driver = try await connectedDriver() + _ = try await driver.execute(query: "CREATE TABLE undone (x INTEGER)") + _ = try await driver.execute(query: "BEGIN TRANSACTION") + _ = try await driver.execute(query: "INSERT INTO undone VALUES (1)") + #expect(await driver.sessionTransactionState() == .explicitTransaction) + _ = try await driver.execute(query: "ROLLBACK") + let rows = try await driver.execute(query: "SELECT count(*) FROM undone").rows + #expect(rows == [["0"]]) + try await driver.disconnect() + } + + @Test("An open stream is left running and the state is not guessed") + func openStreamIsNotEnded() async throws { + let driver = try await connectedDriver() + guard case .rowSet(_, let stream) = try await driver.actor.beginStream( + query: "SELECT i FROM range(100000) t(i)", token: DuckDBStreamToken() + ) else { + Issue.record("a SELECT must stream") + return + } + _ = try await driver.actor.fetchStreamChunk(stream: stream, options: .default) + #expect(await driver.sessionTransactionState() == .unknown) + let next = try await driver.actor.fetchStreamChunk(stream: stream, options: .default) + #expect(next?.isEmpty == false) + await driver.actor.endStream(stream) + #expect(await driver.sessionTransactionState() == .idle) + try await driver.disconnect() + } + + @Test("A session that is not open cannot say") + func unopenedSessionIsUnknown() async throws { + #expect(await DuckDBDriver(source: .inMemory).sessionTransactionState() == .unknown) + let closed = try await connectedDriver() + try await closed.disconnect() + #expect(await closed.sessionTransactionState() == .unknown) + } +} + +struct DuckDBTransactionProbeTests { + @Test("Only DuckDB's own catalog goes on to the transaction id") + func nativeCatalogIsProbedFurther() { + #expect(DuckDBTransactionProbe.state(catalogType: .value("duckdb")) == nil) + #expect(DuckDBTransactionProbe.state(catalogType: .value("DuckDB")) == nil) + } + + @Test("Another engine's catalog is never asked for a transaction id") + func foreignCatalogIsUnknown() { + #expect(DuckDBTransactionProbe.state(catalogType: .value("sqlite")) == .unknown) + #expect(DuckDBTransactionProbe.state(catalogType: .value("postgres")) == .unknown) + } + + @Test("A refused catalog read is an aborted transaction, an unreadable one says nothing") + func catalogRefusals() { + #expect(DuckDBTransactionProbe.state(catalogType: .abortedTransaction) == .abortedTransaction) + #expect(DuckDBTransactionProbe.state(catalogType: .unreadable) == .unknown) + } + + @Test("Two ids that differ are two statements' own transactions, the same id is one open transaction") + func transactionIds() { + #expect(DuckDBTransactionProbe.state(first: .value("6"), second: .value("10")) == .idle) + #expect(DuckDBTransactionProbe.state(first: .value("12"), second: .value("12")) == .explicitTransaction) + } + + @Test("An aborted reading wins, and an unreadable one says nothing") + func transactionIdRefusals() { + #expect(DuckDBTransactionProbe.state(first: .abortedTransaction, second: .unreadable) == .abortedTransaction) + #expect(DuckDBTransactionProbe.state(first: .value("12"), second: .abortedTransaction) == .abortedTransaction) + #expect(DuckDBTransactionProbe.state(first: .value("12"), second: .unreadable) == .unknown) + #expect(DuckDBTransactionProbe.state(first: .unreadable, second: .value("12")) == .unknown) + } +} From 859ff9653dec7f12ad58526691e25ad1602df3ac Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 20 Oct 2026 12:30:12 +0700 Subject: [PATCH 011/151] fix(oracle): ask the server after a PL/SQL block whether its transaction is still open --- .../OracleCoreConnection.swift | 2 +- .../OracleSessionTransaction.swift | 16 +++--- .../OracleTransactionRole.swift | 15 +++-- .../OracleSessionTransactionTests.swift | 57 +++++++++++++++++++ .../OracleTransactionRoleTests.swift | 29 +++++++++- 5 files changed, 104 insertions(+), 15 deletions(-) diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift index 19219de1b6..18ec5e8d1d 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleCoreConnection.swift @@ -581,7 +581,7 @@ public final class OracleCoreConnection: @unchecked Sendable { private func recordSuccess(of role: OracleTransactionRole, on session: Int) async { var answer: Bool? - if role == .dataDefinition, holdsTransaction { + if role == .dataDefinition || role == .procedural, holdsTransaction { answer = await serverHoldsTransaction() } let serverHoldsTransaction = answer diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift index 6961481637..1ee82ca30b 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleSessionTransaction.swift @@ -8,7 +8,8 @@ import Foundation /// driver's commit flag and the server commits it in the same round trip. /// /// A transaction opens with ``open()``, which is what the app's own writes call, or with a statement that opens one -/// (``OracleTransactionRole/opensTransaction``). It ends with a `COMMIT`, a `ROLLBACK` or DDL, whoever sends it. +/// (``OracleTransactionRole/opensTransaction``). It ends with a `COMMIT`, a `ROLLBACK` or DDL, whoever sends it, and +/// a PL/SQL block that ran one of them. /// /// The transaction belongs to the connection its first statement ran on. A connection that closes takes the /// transaction with it, so a statement that finds the transaction's connection replaced fails with @@ -31,14 +32,15 @@ struct OracleSessionTransaction: Equatable, Sendable { return false case .opensTransaction, .endsTransaction: guard isOpen else { return false } - case .dataDefinition, .other: + case .dataDefinition, .procedural, .other: guard isOpen else { return true } } try bind(to: session) return false } - // DDL commits around itself, but DDL on a private temporary table can leave the transaction open: the server decides. + // DDL commits around itself, but DDL on a private temporary table can leave the transaction open, and a block may + // or may not run DDL or a COMMIT: the server decides. mutating func statementSucceeded( _ role: OracleTransactionRole, on session: Int, @@ -50,16 +52,16 @@ struct OracleSessionTransaction: Equatable, Sendable { self.session = session case .endsTransaction: close() - case .dataDefinition: + case .dataDefinition, .procedural: if serverHoldsTransaction == false { close() } case .query, .other: break } } - /// A `COMMIT`, `ROLLBACK` or DDL statement the server refused ends the transaction only when the server no longer - /// holds one: a malformed one leaves it open, a commit a deferred constraint rolled back (ORA-02091) ends it, and - /// so does DDL that failed after the commit Oracle runs before it. + /// A `COMMIT`, `ROLLBACK`, DDL statement or block the server refused ends the transaction only when the server no + /// longer holds one: a malformed one leaves it open, a commit a deferred constraint rolled back (ORA-02091) ends + /// it, and so does DDL that failed after the commit Oracle runs before it, directly or inside a block. /// /// `serverHoldsTransaction` is nil when there was no live connection to ask. The transaction is then left as it /// is, and the next write finds out whether its connection survived. diff --git a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift index 5860771ab8..ed225786d5 100644 --- a/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift +++ b/apple/Packages/TableProOracle/Sources/TableProOracleCore/OracleTransactionRole.swift @@ -19,8 +19,12 @@ enum OracleTransactionRole: Equatable, Sendable { // session and system control, which commit nothing, and the schema switch sends ALTER SESSION. case dataDefinition - /// Everything else: DML, PL/SQL, `CALL`, `ROLLBACK TO`, session and system control, and the `FORCE` forms of - /// `COMMIT` and `ROLLBACK`, which settle an in-doubt distributed transaction rather than the session's own. + /// An anonymous block (`BEGIN` or `DECLARE`, after any `<