Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ public enum ConnectionError: Error, LocalizedError, Equatable {
public var errorDescription: String? {
switch self {
case .driverNotFound(let type):
return "No driver available for database type: \(type)"
return String(format: String(localized: "TablePro on iPhone and iPad can't open %@ connections."), type)
case .notConnected:
return "Not connected to database"
return String(localized: "Not connected to database")
case .sshNotSupported:
return "SSH tunneling is not available on this platform"
return String(localized: "SSH tunneling is not available on this platform")
case .localSocketNotSupported:
return String(localized: "This connection uses a socket on a Mac and cannot be opened on iPhone or iPad.")
case .previousSessionStillClosing:
return "The previous session is still closing"
return String(localized: "The previous session is still closing")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,17 +31,13 @@ public final class ConnectionManager: @unchecked Sendable {
self.clock = clock
}

/// Opens a session, waiting out a teardown still running for the same connection first.
///
/// That wait ends on the calling task's cancellation with `CancellationError`, and after
/// `teardownWaitLimit` with `ConnectionError.previousSessionStillClosing`. Neither ending opens a
/// session over resources the old driver still holds, and neither retires the teardown: it keeps
/// running, it keeps counting as a suspension-blocking resource, and the next attempt succeeds as
/// soon as it lands.
// A teardown wait that gives up leaves the teardown running and counting as suspension-blocking.
public func connect(
_ connection: DatabaseConnection,
prompter: (any ConnectionPrompter)? = nil
) async throws -> ConnectionSession {
// Ahead of beginAttempt, so a refusal neither cancels an attempt in flight nor tears down the open session.
if let refusal = driverFactory.refusal(for: connection) { throw refusal }
let generation = beginAttempt(for: connection.id)
switch await awaitTeardown(of: connection.id, bound: .after(teardownWaitLimit)) {
case .cleared:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import Foundation
import TableProModels

/// Creates database drivers for a given connection.
/// macOS: plugin-based implementation. iOS: direct driver creation.
public protocol DriverFactory: Sendable {
func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver
func supportedTypes() -> [DatabaseType]
func refusal(for connection: DatabaseConnection) -> ConnectionError?
}

public extension DriverFactory {
func refusal(for connection: DatabaseConnection) -> ConnectionError? {
supportedTypes().contains(connection.type) ? nil : .driverNotFound(connection.type.rawValue)
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import Foundation
import TableProPluginKit

public struct SSLConfiguration: Codable, Hashable, Sendable {
public var mode: SSLMode
public var mode: SSLMode {
didSet { if mode != oldValue { isMacPreferred = 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

public enum SSLMode: String, Codable, Sendable {
case disable
Expand All @@ -22,6 +27,15 @@ public struct SSLConfiguration: Codable, Hashable, Sendable {
default: self = .disable
}
}

fileprivate var macMode: TableProPluginKit.SSLMode {
switch self {
case .disable: .disabled
case .require: .required
case .verifyCa: .verifyCa
case .verifyFull: .verifyIdentity
}
}
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -31,20 +45,46 @@ 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
caCertificatePath = try? container.decode(String.self, forKey: .caCertificatePath)
clientCertificatePath = try? container.decode(String.self, forKey: .clientCertificatePath)
clientKeyPath = try? container.decode(String.self, forKey: .clientKeyPath)
isMacPreferred = (try? container.decode(String.self, forKey: .mode)) == TableProPluginKit.SSLMode.preferred.rawValue
caCertificatePath = Self.path(in: container, forKey: .caCertificatePath)
clientCertificatePath = Self.path(in: container, forKey: .clientCertificatePath)
clientKeyPath = Self.path(in: container, forKey: .clientKeyPath)
}

// The Mac decodes this as TableProPluginKit.SSLConfiguration and skips a record it cannot read: its mode
// 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)
try container.encode(caCertificatePath ?? "", forKey: .caCertificatePath)
try container.encode(clientCertificatePath ?? "", forKey: .clientCertificatePath)
try container.encode(clientKeyPath ?? "", forKey: .clientKeyPath)
}

public init(
mode: SSLMode = .disable,
caCertificatePath: String? = nil,
clientCertificatePath: String? = nil,
clientKeyPath: String? = nil
clientKeyPath: String? = nil,
isMacPreferred: Bool = false
) {
self.mode = mode
self.caCertificatePath = caCertificatePath
self.clientCertificatePath = clientCertificatePath
self.clientKeyPath = clientKeyPath
self.isMacPreferred = isMacPreferred && mode == .require
}

private var macMode: TableProPluginKit.SSLMode {
isMacPreferred ? .preferred : mode.macMode
}

public static func isMacWireForm(_ data: Data) -> Bool {
(try? JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: data)) != nil
}

private static func path(in container: KeyedDecodingContainer<CodingKeys>, forKey key: CodingKeys) -> String? {
guard let value = try? container.decode(String.self, forKey: key), !value.isEmpty else { return nil }
return value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,8 @@ public enum SyncRecordMapper {
}

if let sslConfig = connection.sslConfiguration {
if let data = try? encoder.encode(sslConfig) {
if !serverSSLMatches(fields[.sslConfigJson] as? Data, sslConfig),
let data = try? encoder.encode(sslConfig) {
fields[.sslConfigJson] = data as CKRecordValue
}
} else {
Expand All @@ -272,6 +273,18 @@ 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
}

private static func syncedAdditionalFields(for connection: DatabaseConnection) -> [String: String] {
var fields = connection.additionalFields
fields.removeValue(forKey: DatabaseConnection.connectTimeoutSecondsKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ final class MockDriverFactory: DriverFactory, @unchecked Sendable {
return driver
}

func supportedTypes() -> [DatabaseType] { [] }
func supportedTypes() -> [DatabaseType] { drivers.keys.map(DatabaseType.init(rawValue:)) }
}

final class MockSecureStore: SecureStore, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ struct ConnectionManagerTests {
}
}

@Test("A type the factory cannot open is refused before any tunnel opens")
func unsupportedTypeIsRefusedBeforeTheTunnel() async throws {
let factory = MockDriverFactory()
factory.drivers["mock"] = MockDatabaseDriver()
let ssh = MockSSHProvider()
let manager = ConnectionManager(
driverFactory: factory,
secureStore: MockSecureStore(),
sshProvider: ssh
)
let connection = DatabaseConnection(
name: "Mac only",
type: .mongodb,
sshEnabled: true,
sshConfiguration: SSHConfiguration(host: "jump.example.com")
)

await #expect(throws: ConnectionError.driverNotFound("MongoDB")) {
_ = try await manager.connect(connection)
}

#expect(ssh.tunnelledConnectionIds.isEmpty)
#expect(ssh.openedTunnelIds.isEmpty)
#expect(manager.session(for: connection.id) == nil)
}

@Test("Connect with SSH but no provider throws error")
func connectSSHNoProvider() async throws {
let factory = MockDriverFactory()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import Foundation
import Testing

@testable import TableProModels
import TableProPluginKit

struct SSLConfigurationWireTests {
@Test(
"Each iOS mode with nil paths decodes as the Mac's SSL configuration",
arguments: [
(TableProModels.SSLConfiguration.SSLMode.disable, TableProPluginKit.SSLMode.disabled),
(TableProModels.SSLConfiguration.SSLMode.require, TableProPluginKit.SSLMode.required),
(TableProModels.SSLConfiguration.SSLMode.verifyCa, TableProPluginKit.SSLMode.verifyCa),
(TableProModels.SSLConfiguration.SSLMode.verifyFull, TableProPluginKit.SSLMode.verifyIdentity)
]
)
func decodesAsMacConfiguration(
_ mode: TableProModels.SSLConfiguration.SSLMode,
_ expected: TableProPluginKit.SSLMode
) throws {
let data = try JSONEncoder().encode(TableProModels.SSLConfiguration(mode: mode))
let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: data)
#expect(mac.mode == expected)
#expect(mac.caCertificatePath.isEmpty)
#expect(mac.clientCertificatePath.isEmpty)
#expect(mac.clientKeyPath.isEmpty)
#expect(TableProModels.SSLConfiguration.isMacWireForm(data))
}

@Test("Nil paths round-trip to nil")
func nilPathsRoundTrip() throws {
let original = TableProModels.SSLConfiguration(mode: .verifyCa)
let data = try JSONEncoder().encode(original)
let decoded = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: data)
#expect(decoded == original)
#expect(decoded.caCertificatePath == nil)
#expect(decoded.clientCertificatePath == nil)
#expect(decoded.clientKeyPath == nil)
}

@Test("Set paths reach the Mac and round-trip")
func setPathsRoundTrip() throws {
let original = TableProModels.SSLConfiguration(
mode: .verifyFull,
caCertificatePath: "/certs/ca.pem",
clientCertificatePath: "/certs/client.crt",
clientKeyPath: "/certs/client.key"
)
let data = try JSONEncoder().encode(original)
let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: data)
#expect(mac.caCertificatePath == "/certs/ca.pem")
#expect(mac.clientCertificatePath == "/certs/client.crt")
#expect(mac.clientKeyPath == "/certs/client.key")
let decoded = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: data)
#expect(decoded == original)
}

@Test("A Mac Preferred reads as require, writes back as Preferred, and a new mode replaces it")
func macPreferredRoundTrips() throws {
let blob = #"{"mode":"Preferred","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"#
var config = try JSONDecoder().decode(TableProModels.SSLConfiguration.self, from: Data(blob.utf8))
#expect(config.mode == .require)
#expect(config.isMacPreferred)
let mac = try JSONDecoder().decode(TableProPluginKit.SSLConfiguration.self, from: JSONEncoder().encode(config))
#expect(mac.mode == .preferred)

config.mode = .require
#expect(config.isMacPreferred)
config.mode = .verifyCa
config.mode = .require
#expect(!config.isMacPreferred)
#expect(!TableProModels.SSLConfiguration(mode: .verifyCa, isMacPreferred: true).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)))
let allKeys = #"{"mode":"Preferred","caCertificatePath":"","clientCertificatePath":"","clientKeyPath":""}"#
#expect(TableProModels.SSLConfiguration.isMacWireForm(Data(allKeys.utf8)))
}
}
Loading
Loading