From 1b8245ec030f7667d7e2588d9e855b7706736744 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 29 Sep 2026 18:56:14 +0700 Subject: [PATCH 1/3] fix(plugin-etcd): drop the SSL Mode the driver ignores and import etcds:// into the etcd TLS Mode --- CHANGELOG.md | 1 + Plugins/EtcdDriverPlugin/EtcdPlugin.swift | 3 +- ...ginMetadataRegistry+RegistryDefaults.swift | 2 +- .../ParsedConnectionURL+Scanned.swift | 3 +- .../ScannedConnectionFields.swift | 3 +- .../Connection/ConnectionURLFormatter.swift | 8 ++++ .../Connection/ConnectionURLParser.swift | 43 +++++++++++++---- .../Connection/EtcdTLSModeField.swift | 37 +++++++++++++++ .../TransientConnectionFactory.swift | 2 + .../ConnectionFormCoordinator.swift | 3 ++ ...tadataRegistryCuratedCapabilityTests.swift | 7 +++ .../ConnectionURLFormatterTests.swift | 26 +++++++++++ .../ConnectionURLParserTLSPortTests.swift | 46 +++++++++++++++++++ .../Utilities/ConnectionURLParserTests.swift | 29 +++++++----- 14 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 TablePro/Core/Utilities/Connection/EtcdTLSModeField.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 40eb3555b3..d77319b62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - iOS row editor saving the placeholder of a long text or binary value over the full value. - Explain Analyze running write statements on Read-Only connections and skipping the Alert and Safe Mode confirmation. - **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. ## [0.76.1] - 2026-09-29 diff --git a/Plugins/EtcdDriverPlugin/EtcdPlugin.swift b/Plugins/EtcdDriverPlugin/EtcdPlugin.swift index 941d1b5e6e..9279e4410b 100644 --- a/Plugins/EtcdDriverPlugin/EtcdPlugin.swift +++ b/Plugins/EtcdDriverPlugin/EtcdPlugin.swift @@ -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 @@ -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 diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index a62921807d..7f2de23803 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -901,7 +901,7 @@ extension PluginMetadataRegistry { supportsImport: false, supportsExport: true, supportsSSH: true, - supportsSSL: true, + supportsSSL: false, supportsCascadeDrop: false, supportsForeignKeyDisable: false, supportsReadOnlyMode: false, diff --git a/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift b/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift index cbacfcc550..0722e9836a 100644 --- a/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift +++ b/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift @@ -44,7 +44,8 @@ extension ParsedConnectionURL { safeModeLevel: overriddenSafeModeLevel ?? safeModeLevel, useSrv: useSrv, mongoQueryParams: mongoQueryParams, - multiHost: multiHost + multiHost: multiHost, + additionalFields: additionalFields ) } } diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift index a1ce8d19d0..fdb5aeb5fd 100644 --- a/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift @@ -57,7 +57,8 @@ struct ScannedConnectionFields { safeModeLevel: safeModeLevel, useSrv: false, mongoQueryParams: [:], - multiHost: nil + multiHost: nil, + additionalFields: [:] ) } } diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 8a34624179..9c7423cb6c 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -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" diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index 61f65d0392..6f4f2f4b60 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -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 { @@ -149,7 +150,8 @@ struct ConnectionURLParser { safeModeLevel: nil, useSrv: false, mongoQueryParams: [:], - multiHost: nil + multiHost: nil, + additionalFields: [:] )) } @@ -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, @@ -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, @@ -258,7 +261,8 @@ struct ConnectionURLParser { safeModeLevel: ext.safeModeLevel, useSrv: ext.useSrv, mongoQueryParams: ext.mongoQueryParams, - multiHost: nil + multiHost: nil, + additionalFields: tls.additionalFields )) } @@ -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? @@ -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, @@ -453,7 +458,8 @@ struct ConnectionURLParser { safeModeLevel: ext.safeModeLevel, useSrv: ext.useSrv, mongoQueryParams: ext.mongoQueryParams, - multiHost: nil + multiHost: nil, + additionalFields: tls.additionalFields )) } @@ -553,7 +559,8 @@ struct ConnectionURLParser { safeModeLevel: ext.safeModeLevel, useSrv: isSrv, mongoQueryParams: ext.mongoQueryParams, - multiHost: multiHost + multiHost: multiHost, + additionalFields: [:] ) } @@ -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 } diff --git a/TablePro/Core/Utilities/Connection/EtcdTLSModeField.swift b/TablePro/Core/Utilities/Connection/EtcdTLSModeField.swift new file mode 100644 index 0000000000..de4bedaff4 --- /dev/null +++ b/TablePro/Core/Utilities/Connection/EtcdTLSModeField.swift @@ -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 + } + } +} diff --git a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift index fd370ca56a..45e37a0b0e 100644 --- a/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift +++ b/TablePro/Core/Utilities/Connection/TransientConnectionFactory.swift @@ -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 diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index 784b467560..362e911ab1 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -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) } diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift index 6305fa6093..99f7f4a978 100644 --- a/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift +++ b/TableProTests/Core/Plugins/PluginMetadataRegistryCuratedCapabilityTests.swift @@ -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) diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index 4ad1fe9570..f96141f688 100644 --- a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -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") diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift index 6d7132a5f7..892b94da86 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTLSPortTests.swift @@ -130,4 +130,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") + } } diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index 070cf52a59..25796b3b1d 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -6,10 +6,10 @@ import Foundation import TableProPluginKit import Testing + @testable import TablePro struct ConnectionURLParserTests { - // MARK: - PostgreSQL @Test("Full PostgreSQL URL") @@ -317,7 +317,10 @@ struct ConnectionURLParserTests { @Test("Full mysql+ssh URL") func testFullMySQLSSHURL() { - let result = ConnectionURLParser.parse("mysql+ssh://root@123.123.123.123:1234/database_user:database_password@127.0.0.1/database_name?name=FlashPanel&usePrivateKey=true&env=production") + let result = ConnectionURLParser.parse( + "mysql+ssh://root@123.123.123.123:1234/database_user:database_password@127.0.0.1/database_name" + + "?name=FlashPanel&usePrivateKey=true&env=production" + ) guard case .success(let parsed) = result else { Issue.record("Expected success"); return } @@ -328,7 +331,7 @@ struct ConnectionURLParserTests { #expect(parsed.username == "database_user") #expect(parsed.password == "database_password") #expect(parsed.sshHost == "123.123.123.123") - #expect(parsed.sshPort == 1234) + #expect(parsed.sshPort == 1_234) #expect(parsed.sshUsername == "root") #expect(parsed.usePrivateKey == true) #expect(parsed.connectionName == "FlashPanel") @@ -387,7 +390,7 @@ struct ConnectionURLParserTests { guard case .success(let parsed) = result else { Issue.record("Expected success"); return } - #expect(parsed.port == 5433) + #expect(parsed.port == 5_433) #expect(parsed.sshPort == 22) } @@ -397,7 +400,7 @@ struct ConnectionURLParserTests { guard case .success(let parsed) = result else { Issue.record("Expected success"); return } - #expect(parsed.port == 5433) + #expect(parsed.port == 5_433) } @Test("MariaDB SSH URL") @@ -408,7 +411,7 @@ struct ConnectionURLParserTests { } #expect(parsed.type == .mariadb) #expect(parsed.sshHost == "192.168.1.1") - #expect(parsed.sshPort == 2222) + #expect(parsed.sshPort == 2_222) #expect(parsed.sshUsername == "admin") #expect(parsed.host == "127.0.0.1") #expect(parsed.database == "production") @@ -1040,7 +1043,7 @@ struct ConnectionURLParserTests { #expect(parsed.useSSHAgent == true) #expect(parsed.agentSocket == SSHAgentSocketOption.onePasswordSocketPath) #expect(parsed.sshHost == "bastion") - #expect(parsed.sshPort == 2222) + #expect(parsed.sshPort == 2_222) } // MARK: - DuckDB @@ -1090,21 +1093,23 @@ struct ConnectionURLParserTests { // MARK: - etcds TLS - @Test("etcds scheme enables SSL") - func testEtcdsSchemeEnablesSSL() { + @Test("etcds scheme turns on the etcd TLS Mode, not the generic SSL Mode the driver ignores") + func testEtcdsSchemeEnablesTLSMode() { let result = ConnectionURLParser.parse("etcds://host:2379") guard case .success(let parsed) = result else { Issue.record("Expected success"); return } - #expect(parsed.sslMode == .required) + #expect(parsed.additionalFields["etcdTlsMode"] == "Required") + #expect(parsed.sslMode == nil) } - @Test("etcd scheme does not enable SSL") - func testEtcdSchemeNoSSL() { + @Test("etcd scheme leaves the etcd TLS Mode disabled") + func testEtcdSchemeNoTLS() { let result = ConnectionURLParser.parse("etcd://host:2379") guard case .success(let parsed) = result else { Issue.record("Expected success"); return } + #expect(parsed.additionalFields["etcdTlsMode"] == "Disabled") #expect(parsed.sslMode == nil) } From b068a092c5ae357ab16f6b7acd808c38eb4544a7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 17:28:14 +0700 Subject: [PATCH 2/3] fix(sync): decode unknown safe mode values with the model policy --- CHANGELOG.md | 1 + .../TableProSync/SyncRecordMapper.swift | 14 ++----------- .../SyncRecordMapperTests.swift | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1999af26da..ad1c081fc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Unknown Safe Mode levels synced to iOS allowing writes without confirmation. - Save disabled for Kafka connections set to Verify Identity without a CA file. - Crash on `inf` or `nan`, text like `007` stored as a number, and blobs stored as text on remote libSQL. - External Clients level and Cloudflare, Cloud SQL, SOCKS and Tunnel Command settings reset by an iCloud sync. diff --git a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift index 53a5a6fb18..cd1d9c03f8 100644 --- a/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift +++ b/Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift @@ -187,22 +187,12 @@ public enum SyncRecordMapper { } private static func storedSafeModeLevel(in fields: SyncRecordFields) -> SafeModeLevel { - safeModeLevel( - fromWire: fields[.safeModeLevel] as? String, + SafeModeLevel( + wireValue: fields[.safeModeLevel] as? String, isReadOnly: (fields[.isReadOnly] as? Int64 ?? 0) != 0 ) } - private static func safeModeLevel(fromWire raw: String?, isReadOnly: Bool) -> SafeModeLevel { - guard let raw else { return isReadOnly ? .readOnly : .off } - if let level = SafeModeLevel(rawValue: raw) { return level } - switch raw { - case "silent": return .off - case "alert", "alertFull", "safeMode", "safeModeFull": return .confirmWrites - default: return isReadOnly ? .readOnly : .off - } - } - // MARK: - Update Existing CKRecord (preserves macOS-only fields) public static func updateRecord(_ record: CKRecord, with connection: DatabaseConnection) { diff --git a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift index 0463cdcad4..2fa2afdfb4 100644 --- a/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift +++ b/Packages/TableProCore/Tests/TableProSyncTests/SyncRecordMapperTests.swift @@ -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) + } } From d0dee4de2f384c236e2f5706169df150c0c76813 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 23:05:27 +0700 Subject: [PATCH 3/3] docs(changelog): drop the Safe Mode entry that #3214 already carries --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9727f031ef..01e3bb1b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Unknown Safe Mode levels synced to iOS allowing writes without confirmation. - Save disabled for Kafka connections set to Verify Identity without a CA file. - Crash on `inf` or `nan`, text like `007` stored as a number, and blobs stored as text on remote libSQL. - External Clients level and Cloudflare, Cloud SQL, SOCKS and Tunnel Command settings reset by an iCloud sync.