Skip to content
Open
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation.
- Remote deletions of connections, groups, tags, SSH profiles and table favorites applied with their sync category off.
- **Local only** connections taking edits and deletions made on another device.
- etcd SSL Mode that the driver ignored, and etcd URLs that lost or faked TLS on import and Copy as URL.
- Export dialog offering a SQL row scope on MongoDB, Redis and other engines without SQL.
- CSV, XLSX and MQL exports ignoring a table's row filter, row limit and column choice.
- MQL export rounding 64-bit integers past 2^53 and restoring whole doubles and small 64-bit integers as 32-bit ones.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,25 @@ struct SyncRecordMapperTests {
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .confirmWrites)
}

@Test("An unrecognized wire value keeps the legacy read-only restriction")
func unknownWireValuePreservesReadOnly() throws {
let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel", isReadOnly: true)
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.safeModeLevel == .readOnly)
}

@Test("A rename preserves an unrecognized wire value and requires confirmation")
func renamePreservesUnknownWireValue() throws {
let record = makeRawRecord(safeModeLevelRaw: "someFutureLevel")
var connection = try #require(SyncRecordMapper.toConnection(record))
connection.name = "Renamed"

SyncRecordMapper.updateRecord(record, with: connection)

#expect(record["safeModeLevel"] as? String == "someFutureLevel")
let decoded = try #require(SyncRecordMapper.toConnection(record))
#expect(decoded.name == "Renamed")
#expect(decoded.safeModeLevel == .confirmWrites)
}
}
3 changes: 2 additions & 1 deletion Plugins/EtcdDriverPlugin/EtcdPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ final class EtcdPlugin: NSObject, TableProPlugin, DriverPlugin {
static let databaseTypeId = "etcd"
static let databaseDisplayName = "etcd"
static let iconName = "etcd-icon"
static let defaultPort = 2379
static let defaultPort = 2_379
static let isDownloadable = true

static let navigationModel: NavigationModel = .standard
Expand All @@ -32,6 +32,7 @@ final class EtcdPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsSchemaEditing = false
static let supportsDatabaseSwitching = false
static let supportsImport = false
static let supportsSSL = false
static let tableEntityName = "Keys"
static let supportsForeignKeyDisable = false
static let supportsReadOnlyMode = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,7 @@ extension PluginMetadataRegistry {
supportsImport: false,
supportsExport: true,
supportsSSH: true,
supportsSSL: true,
supportsSSL: false,
supportsCascadeDrop: false,
supportsForeignKeyDisable: false,
supportsReadOnlyMode: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ extension ParsedConnectionURL {
safeModeLevel: overriddenSafeModeLevel ?? safeModeLevel,
useSrv: useSrv,
mongoQueryParams: mongoQueryParams,
multiHost: multiHost
multiHost: multiHost,
additionalFields: additionalFields
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ struct ScannedConnectionFields {
safeModeLevel: safeModeLevel,
useSrv: false,
mongoQueryParams: [:],
multiHost: nil
multiHost: nil,
additionalFields: [:]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -270,9 +270,17 @@ struct ConnectionURLFormatter {
}

private static func sslModeParam(for connection: DatabaseConnection) -> String? {
guard connection.type != .etcd else {
return EtcdTLSModeField.sslMode(forFieldValue: connection.additionalFields[EtcdTLSModeField.fieldId])
.map(sslModeParamValue)
}
let mode = connection.sslConfig.mode
let portImpliesTLS = connection.type.impliedSSLMode(forPort: connection.port) != nil
guard mode != connection.type.defaultSSLMode || portImpliesTLS else { return nil }
return sslModeParamValue(mode)
}

private static func sslModeParamValue(_ mode: SSLMode) -> String {
switch mode {
case .disabled: return "disable"
case .preferred: return "prefer"
Expand Down
43 changes: 35 additions & 8 deletions TablePro/Core/Utilities/Connection/ConnectionURLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct ParsedConnectionURL {
let useSrv: Bool
let mongoQueryParams: [String: String]
let multiHost: String?
let additionalFields: [String: String]

var suggestedName: String {
if let connectionName, !connectionName.isEmpty {
Expand Down Expand Up @@ -149,7 +150,8 @@ struct ConnectionURLParser {
safeModeLevel: nil,
useSrv: false,
mongoQueryParams: [:],
multiHost: nil
multiHost: nil,
additionalFields: [:]
))
}

Expand Down Expand Up @@ -224,6 +226,7 @@ struct ConnectionURLParser {
}
}
let effectivePort = isSrv ? nil : port
let tls = ImportedTLS(sslMode: sslMode, disablesTLS: ext.disablesTLS, type: dbType)

return .success(ParsedConnectionURL(
type: dbType,
Expand All @@ -232,8 +235,8 @@ struct ConnectionURLParser {
database: database,
username: username,
password: password,
sslMode: sslMode,
disablesTLS: ext.disablesTLS,
sslMode: tls.sslMode,
disablesTLS: tls.disablesTLS,
authSource: ext.authSource,
sshHost: nil,
sshPort: nil,
Expand All @@ -258,7 +261,8 @@ struct ConnectionURLParser {
safeModeLevel: ext.safeModeLevel,
useSrv: ext.useSrv,
mongoQueryParams: ext.mongoQueryParams,
multiHost: nil
multiHost: nil,
additionalFields: tls.additionalFields
))
}

Expand Down Expand Up @@ -401,6 +405,7 @@ struct ConnectionURLParser {
let ext = parseSSHQueryString(queryString, dbType: dbType)
let sslMode = ext.resolvedSSLMode(for: dbType, port: port ?? dbType.defaultPort)
port = significantPort(port, for: dbType, sslMode: sslMode)
let tls = ImportedTLS(sslMode: sslMode, disablesTLS: ext.disablesTLS, type: dbType)

// Oracle-specific: path component is the service name, not the database name
var oracleServiceName: String?
Expand All @@ -427,8 +432,8 @@ struct ConnectionURLParser {
database: database,
username: dbUsername,
password: dbPassword,
sslMode: sslMode,
disablesTLS: ext.disablesTLS,
sslMode: tls.sslMode,
disablesTLS: tls.disablesTLS,
authSource: ext.authSource,
sshHost: sshHost,
sshPort: sshPort,
Expand All @@ -453,7 +458,8 @@ struct ConnectionURLParser {
safeModeLevel: ext.safeModeLevel,
useSrv: ext.useSrv,
mongoQueryParams: ext.mongoQueryParams,
multiHost: nil
multiHost: nil,
additionalFields: tls.additionalFields
))
}

Expand Down Expand Up @@ -553,7 +559,8 @@ struct ConnectionURLParser {
safeModeLevel: ext.safeModeLevel,
useSrv: isSrv,
mongoQueryParams: ext.mongoQueryParams,
multiHost: multiHost
multiHost: multiHost,
additionalFields: [:]
)
}

Expand Down Expand Up @@ -595,6 +602,26 @@ struct ConnectionURLParser {
}
}

private struct ImportedTLS {
let sslMode: SSLMode?
let disablesTLS: Bool
let additionalFields: [String: String]

init(sslMode: SSLMode?, disablesTLS: Bool, type: DatabaseType) {
guard type == .etcd else {
self.sslMode = sslMode
self.disablesTLS = disablesTLS
self.additionalFields = [:]
return
}
self.sslMode = nil
self.disablesTLS = false
self.additionalFields = [
EtcdTLSModeField.fieldId: EtcdTLSModeField.fieldValue(for: sslMode ?? .disabled)
]
}
}

private static func parseQueryItems(_ queryItems: [URLQueryItem]?, dbType: DatabaseType? = nil) -> ExtendedParams {
var ext = ExtendedParams()
guard let queryItems else { return ext }
Expand Down
37 changes: 37 additions & 0 deletions TablePro/Core/Utilities/Connection/EtcdTLSModeField.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// EtcdTLSModeField.swift
// TablePro
//

import Foundation
import TableProPluginKit

internal enum EtcdTLSModeField {
static let fieldId = "etcdTlsMode"

static func fieldValue(for mode: SSLMode) -> String {
switch mode {
case .disabled:
return "Disabled"
case .preferred, .required:
return "Required"
case .verifyCa:
return "VerifyCA"
case .verifyIdentity:
return "VerifyIdentity"
}
}

static func sslMode(forFieldValue value: String?) -> SSLMode? {
switch value {
case "Required":
return .required
case "VerifyCA":
return .verifyCa
case "VerifyIdentity":
return .verifyIdentity
default:
return nil
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ internal enum TransientConnectionFactory {
oracleServiceName: parsed.oracleServiceName
)

connection.additionalFields.merge(parsed.additionalFields) { _, imported in imported }

for (key, value) in parsed.mongoQueryParams where !value.isEmpty {
if key != "authMechanism" && key != "replicaSet" {
connection.additionalFields["mongoParam_\(key)"] = value
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -957,6 +957,9 @@ final class ConnectionFormCoordinator: ObservableObject {
if let svcName = parsed.oracleServiceName, !svcName.isEmpty {
writeFieldByRegistry("oracleServiceName", value: svcName)
}
for (fieldId, value) in parsed.additionalFields {
writeFieldByRegistry(fieldId, value: value)
}
if let hex = parsed.statusColor, !hex.isEmpty {
customization.color = ConnectionURLParser.connectionColor(fromHex: hex)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,13 @@ struct PluginMetadataRegistryCuratedCapabilityTests {
#expect(built.capabilities.verifiesServerWithSystemTrust == true)
}

@Test("etcd offers no generic SSL Mode, since its driver reads only its own TLS Mode field")
@MainActor
func etcdDeclaresNoGenericSSL() {
#expect(PluginManager.shared.supportsSSL(for: .etcd) == false)
#expect(PluginMetadataRegistry.shared.snapshot(forRegisteredTypeId: "etcd")?.capabilities.supportsSSL == false)
}

@Test("SQL Server keeps its missing certificate fields when its plugin registers")
func mssqlKeepsItsCertificateFieldOptOut() {
let built = PluginMetadataRegistry.shared.buildMetadataSnapshot(from: MockMSSQLPlugin.self)
Expand Down
26 changes: 26 additions & 0 deletions TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,32 @@ struct ConnectionURLFormatterTests {
#expect(TransientConnectionFactory.build(from: parsed).sslConfig.mode == mode, "\(url)")
}

@Test("A copied etcd URL keeps the TLS Mode its driver reads when it is opened again")
func etcdTLSModeRoundTrips() {
for tlsMode in ["Required", "VerifyCA", "VerifyIdentity", "Disabled"] {
let conn = DatabaseConnection(
name: "", host: "etcd.example.com", port: 2_379, database: "", username: "root", type: .etcd,
sslConfig: SSLConfiguration(mode: .disabled), additionalFields: ["etcdTlsMode": tlsMode]
)
let url = ConnectionURLFormatter.format(conn, password: "", sshPassword: nil)
guard case .success(let parsed) = ConnectionURLParser.parse(url) else {
Issue.record("\(url) did not parse")
continue
}
#expect(parsed.additionalFields["etcdTlsMode"] == tlsMode, "\(url)")
}
}

@Test("An etcd URL never carries the generic SSL Mode its driver ignores")
func etcdURLIgnoresTheGenericSSLMode() {
let conn = DatabaseConnection(
name: "", host: "etcd.example.com", port: 2_379, database: "", username: "root", type: .etcd,
sslConfig: SSLConfiguration(mode: .verifyIdentity), additionalFields: ["etcdTlsMode": "Disabled"]
)
let url = ConnectionURLFormatter.format(conn, password: "", sshPassword: nil)
#expect(!url.contains("sslmode"), "\(url)")
}

// MARK: - Connection Name

@Test("Connection name in query string")
Expand Down
46 changes: 46 additions & 0 deletions TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,50 @@ struct ConnectionURLParserTLSPortTests {
let connection = TransientConnectionFactory.build(from: try parse("trino://trino.example.com:443/hive?SSL=false"))
#expect(connection.sslConfig.mode == .disabled)
}

@Test("An etcd URL sets the TLS Mode its driver reads, never the generic SSL Mode it ignores")
func etcdURLSetsTheDriverTLSMode() throws {
let cases: [(url: String, tlsMode: String)] = [
("etcds://etcd.example.com:2379", "Required"),
("etcd://etcd.example.com:2379", "Disabled"),
("etcds://etcd.example.com:2379?sslmode=verify-full", "VerifyIdentity"),
("etcds://etcd.example.com:2379?sslmode=verify-ca", "VerifyCA"),
("etcds://etcd.example.com:2379?sslmode=disable", "Disabled"),
("etcd://etcd.example.com:2379?tls=true", "Required"),
("etcd://etcd.example.com:2379?sslmode=prefer", "Required"),
("etcd://etcd.example.com:2379?tls=false", "Disabled"),
("etcd+ssh://me@bastion.example.com/10.0.0.5:2379?sslmode=verify-full", "VerifyIdentity")
]
for (url, tlsMode) in cases {
let parsed = try parse(url)
#expect(parsed.additionalFields["etcdTlsMode"] == tlsMode, "\(url)")
#expect(parsed.sslMode == nil, "\(url)")
#expect(parsed.disablesTLS == false, "\(url)")
let connection = TransientConnectionFactory.build(from: parsed)
#expect(connection.additionalFields["etcdTlsMode"] == tlsMode, "\(url)")
#expect(connection.sslConfig.mode == .disabled, "\(url)")
}
}

@Test("Only etcd carries its TLS in a plugin field; other engines keep the generic SSL Mode")
func otherEnginesKeepTheGenericSSLMode() throws {
let redis = try parse("rediss://cache.example.com")
#expect(redis.sslMode == .required)
#expect(redis.additionalFields.isEmpty)
let postgres = try parse("postgresql+ssh://deploy@bastion.example.com/app@db.example.com/app?sslmode=verify-full")
#expect(postgres.sslMode == .verifyIdentity)
#expect(postgres.additionalFields.isEmpty)
}

@Test("Importing etcds:// into the form sets TLS Mode to Required, and a plain etcd:// import sets it to Disabled")
func etcdFormImportSetsTLSMode() throws {
let secure = ConnectionFormCoordinator(connectionId: nil, initialParsedURL: try parse("etcds://etcd.example.com:2379"))
secure.start()
#expect(secure.advanced.additionalFieldValues["etcdTlsMode"] == "Required")
#expect(secure.ssl.mode == .disabled)

let plain = ConnectionFormCoordinator(connectionId: nil, initialParsedURL: try parse("etcd://etcd.example.com:2379"))
plain.start()
#expect(plain.advanced.additionalFieldValues["etcdTlsMode"] == "Disabled")
}
}
Loading
Loading