From d5a85c4b447821c9e6278cc0552d599dc414d6e5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 14:39:07 +0700 Subject: [PATCH 1/4] fix(connections): rename MongoDB archive namespaces into the restore target --- CHANGELOG.md | 1 + .../Core/Database/NativeDumpArgumentQuoting.swift | 4 ++++ TablePro/Core/Database/NativeDumpRegistry.swift | 8 +++----- .../Database/NativeDumpRegistryTests.swift | 15 +++++++++++++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a1eb9939..4c28a33c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Literal backticks in cloudflared, cloud-sql-proxy, SSH config, remote command and dump tool install messages. - Tunnel command preview showing port 0 or the wrong host when Port is blank or the connection uses a host list. - SSH tab host-list warning naming replica set failover for Redis and Kafka, and implying Sentinel works through a tunnel. +- MongoDB restore into a database with a different name restoring nothing and reporting success. ### Security diff --git a/TablePro/Core/Database/NativeDumpArgumentQuoting.swift b/TablePro/Core/Database/NativeDumpArgumentQuoting.swift index f4459a93d5..67f85f0359 100644 --- a/TablePro/Core/Database/NativeDumpArgumentQuoting.swift +++ b/TablePro/Core/Database/NativeDumpArgumentQuoting.swift @@ -82,4 +82,8 @@ enum NativeDumpArgumentQuoting { static func mongoNamespace(database: String, collection: String) -> String { "\(database).\(collection)" } + + static func mongoRestoreRenaming(into database: String) -> [String] { + ["--nsFrom=$db$.$coll$", "--nsTo=\(database).$coll$"] + } } diff --git a/TablePro/Core/Database/NativeDumpRegistry.swift b/TablePro/Core/Database/NativeDumpRegistry.swift index 099279a3bb..8b7ae6f968 100644 --- a/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/TablePro/Core/Database/NativeDumpRegistry.swift @@ -277,11 +277,9 @@ enum NativeDumpRegistry { ] }, restoreArguments: { request, _ in - mongoConnectionFlags(request) + [ - "--nsInclude=\(request.database).*", - "--gzip", - "--archive=\(request.fileURL.path)" - ] + mongoConnectionFlags(request) + + NativeDumpArgumentQuoting.mongoRestoreRenaming(into: request.database) + + ["--gzip", "--archive=\(request.fileURL.path)"] } ) ), diff --git a/TableProTests/Database/NativeDumpRegistryTests.swift b/TableProTests/Database/NativeDumpRegistryTests.swift index 2febb996a6..5b82222364 100644 --- a/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/TableProTests/Database/NativeDumpRegistryTests.swift @@ -179,10 +179,21 @@ struct NativeDumpRegistryTests { #expect(!built.arguments.contains { $0.hasPrefix("--config=") }) } - @Test("MongoDB names the database on backup and scopes the namespace on restore") + @Test("MongoDB names the database on backup and renames the archive into the target on restore") func mongoScopesItsDatabase() throws { #expect(try command(.mongodb, kind: .backup).arguments.contains("--db=sales")) - #expect(try command(.mongodb, kind: .restore).arguments.contains("--nsInclude=sales.*")) + let restore = try command(.mongodb, kind: .restore).arguments + #expect(restore.contains("--nsFrom=$db$.$coll$")) + #expect(restore.contains("--nsTo=sales.$coll$")) + #expect(!restore.contains { $0.hasPrefix("--nsInclude") }) + } + + @Test("A restore target keeps an asterisk literal, which mongorestore reads as part of the name") + func mongoRestoreTargetIsLiteral() { + #expect( + NativeDumpArgumentQuoting.mongoRestoreRenaming(into: "we*ird") + == ["--nsFrom=$db$.$coll$", "--nsTo=we*ird.$coll$"] + ) } /// The database is a file the tool opens, so there is nothing to authenticate to. From f932a8a2b42736a161d364d09e8f0a818b457cf7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 14:57:48 +0700 Subject: [PATCH 2/4] fix(connections): give mongodump and mongorestore the connection's auth database, hosts, SRV and TLS --- CHANGELOG.md | 1 + .../Database/MongoToolsConnectionString.swift | 130 ++++++++++++++ .../Core/Database/NativeDumpDescriptor.swift | 16 +- .../Core/Database/NativeDumpRegistry.swift | 33 ++-- .../Core/Database/NativeDumpService.swift | 13 +- .../MongoToolsConnectionStringTests.swift | 166 ++++++++++++++++++ .../Database/NativeDumpRegistryTests.swift | 73 +++++++- 7 files changed, 397 insertions(+), 35 deletions(-) create mode 100644 TablePro/Core/Database/MongoToolsConnectionString.swift create mode 100644 TableProTests/Database/MongoToolsConnectionStringTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c28a33c16..b311a8d805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tunnel command preview showing port 0 or the wrong host when Port is blank or the connection uses a host list. - SSH tab host-list warning naming replica set failover for Redis and Kafka, and implying Sentinel works through a tunnel. - MongoDB restore into a database with a different name restoring nothing and reporting success. +- MongoDB Backup Dump and Restore ignoring the connection's Auth Database, Hosts list, SRV and TLS options. ### Security diff --git a/TablePro/Core/Database/MongoToolsConnectionString.swift b/TablePro/Core/Database/MongoToolsConnectionString.swift new file mode 100644 index 0000000000..0d8d7c1b03 --- /dev/null +++ b/TablePro/Core/Database/MongoToolsConnectionString.swift @@ -0,0 +1,130 @@ +// +// MongoToolsConnectionString.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum MongoToolsConnectionString { + private static let defaultAuthenticationDatabase = "admin" + + private static let queryValueAllowed = CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "&=+#")) + + private static let fieldOwnedParameterKeys: Set = [ + "authSource", "authMechanism", "replicaSet", + "tls", "tlsAllowInvalidCertificates", "tlsAllowInvalidHostnames", "tlsCAFile", "tlsCertificateKeyFile" + ] + + static func make(for connection: DatabaseConnection, host: String) -> String { + let scheme = connection.usesMongoSrv ? "mongodb+srv" : "mongodb" + let base = "\(scheme)://\(userInfo(connection))\(hosts(connection, host: host))/" + let query = parameters(connection) + return query.isEmpty ? base : "\(base)?\(query.joined(separator: "&"))" + } + + static func authenticationDatabase(for connection: DatabaseConnection) -> String { + if let explicit = connection.mongoAuthSource { + return explicit + } + guard !connection.usesMongoSrv, !connection.database.isEmpty else { + return defaultAuthenticationDatabase + } + return connection.database + } + + static func tlsParameters(for connection: DatabaseConnection) -> [String] { + let ssl = connection.sslConfig + guard ssl.isEnabled || connection.usesMongoSrv else { return [] } + var parameters = ["tls=true"] + if ssl.mode == .preferred || ssl.mode == .required { + parameters.append("tlsInsecure=true") + } + if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty { + parameters.append("tlsCAFile=\(encodedValue(ssl.caCertificatePath))") + } + if ssl.isEnabled, !ssl.clientCertificatePath.isEmpty { + parameters.append("tlsCertificateKeyFile=\(encodedValue(ssl.clientCertificatePath))") + } + return parameters + } + + private static func userInfo(_ connection: DatabaseConnection) -> String { + guard !connection.username.isEmpty else { return "" } + let encoded = connection.username.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) + ?? connection.username + return "\(encoded)@" + } + + private static func hosts(_ connection: DatabaseConnection, host: String) -> String { + let listed = hostEntries(connection.additionalFields["mongoHosts"] ?? "") + let entries = listed.isEmpty ? hostEntries(host) : listed + if connection.usesMongoSrv, let srvName = entries.first { + return encodedHost(srvName.host) + } + return entries + .map { "\(encodedHost($0.host)):\($0.port ?? String(connection.port))" } + .joined(separator: ",") + } + + private static func hostEntries(_ value: String) -> [(host: String, port: String?)] { + value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + .map(splitHostAndPort) + } + + private static func splitHostAndPort(_ entry: String) -> (host: String, port: String?) { + if entry.hasPrefix("["), let closing = entry.firstIndex(of: "]") { + let remainder = entry[entry.index(after: closing)...] + let port = remainder.hasPrefix(":") ? String(remainder.dropFirst()) : "" + return (String(entry[...closing]), port.isEmpty ? nil : port) + } + guard let colon = entry.lastIndex(of: ":") else { return (entry, nil) } + let port = String(entry[entry.index(after: colon)...]) + return (String(entry[.. [String] { + var parameters: [String] = [] + if !connection.username.isEmpty || connection.mongoAuthMechanism != nil { + parameters.append("authSource=\(encodedValue(authenticationDatabase(for: connection)))") + } + if let mechanism = connection.mongoAuthMechanism { + parameters.append("authMechanism=\(encodedValue(mechanism))") + } + if let replicaSet = connection.mongoReplicaSet { + parameters.append("replicaSet=\(encodedValue(replicaSet))") + } + if let readPreference = connection.mongoReadPreference { + parameters.append("readPreference=\(encodedValue(readPreference))") + } + if let writeConcern = connection.mongoWriteConcern { + parameters.append("w=\(encodedValue(writeConcern))") + } + parameters.append(contentsOf: tlsParameters(for: connection)) + let setKeys = Set(parameters.compactMap { $0.split(separator: "=", maxSplits: 1).first.map(String.init) }) + return parameters + extraParameters(connection, excluding: setKeys.union(fieldOwnedParameterKeys)) + } + + private static func extraParameters(_ connection: DatabaseConnection, excluding keys: Set) -> [String] { + connection.additionalFields + .compactMap { key, value -> (String, String)? in + guard key.hasPrefix("mongoParam_") else { return nil } + let name = String(key.dropFirst("mongoParam_".count)) + guard !name.isEmpty, !keys.contains(name) else { return nil } + return (name, value) + } + .sorted { $0.0 < $1.0 } + .map { "\(encodedValue($0.0))=\(encodedValue($0.1))" } + } + + private static func encodedHost(_ host: String) -> String { + host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host + } + + private static func encodedValue(_ value: String) -> String { + value.addingPercentEncoding(withAllowedCharacters: queryValueAllowed) ?? value + } +} diff --git a/TablePro/Core/Database/NativeDumpDescriptor.swift b/TablePro/Core/Database/NativeDumpDescriptor.swift index 1484c3447c..f3acbf271b 100644 --- a/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -102,6 +102,11 @@ struct NativeDumpDescriptor: Sendable { } } + internal struct ConfigurationEntry: Sendable, Equatable { + let key: String + let value: String + } + /// A tool on the user's Mac that the app spawns. struct CommandLineTool: Sendable { /// The candidate names, in the order they are tried. More than one because a tool can ship @@ -123,10 +128,7 @@ struct NativeDumpDescriptor: Sendable { /// such a tool say so rather than leaving the user to find out. let exposesPasswordInArguments: Bool - /// True when the tool reads a password from neither the environment nor standard input, so - /// the only channel left is a file written at mode `0600`. Declared rather than inferred - /// from the binary's name, which is what `buildCommand` used to do. - let needsCredentialsFile: Bool + let configurationFileEntries: @Sendable (Request) -> [ConfigurationEntry] internal let restoreExitPolicy: NativeDumpExitPolicy @@ -153,14 +155,14 @@ struct NativeDumpDescriptor: Sendable { backupDelivery: OutputDelivery, restoreDelivery: OutputDelivery, exposesPasswordInArguments: Bool = false, - needsCredentialsFile: Bool = false, restoreExitPolicy: NativeDumpExitPolicy = .zeroExitOnly, requiresUntranslatedMessages: Bool = false, toolForServer: (@Sendable (_ binary: String, _ serverVersion: String?) -> NativeDumpToolSelection)? = nil, identifyExecutable: (@Sendable (_ name: String, _ path: String) -> NativeDumpResolvedTool)? = nil, backupArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String], - environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] } + environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }, + configurationFileEntries: @escaping @Sendable (Request) -> [ConfigurationEntry] = { _ in [] } ) { self.backupBinaries = backupBinaries self.restoreBinaries = restoreBinaries @@ -168,7 +170,6 @@ struct NativeDumpDescriptor: Sendable { self.backupDelivery = backupDelivery self.restoreDelivery = restoreDelivery self.exposesPasswordInArguments = exposesPasswordInArguments - self.needsCredentialsFile = needsCredentialsFile self.restoreExitPolicy = restoreExitPolicy self.requiresUntranslatedMessages = requiresUntranslatedMessages self.toolForServer = toolForServer @@ -176,6 +177,7 @@ struct NativeDumpDescriptor: Sendable { self.backupArguments = backupArguments self.restoreArguments = restoreArguments self.environment = environment + self.configurationFileEntries = configurationFileEntries } func binaries(for kind: NativeDumpKind) -> [String] { diff --git a/TablePro/Core/Database/NativeDumpRegistry.swift b/TablePro/Core/Database/NativeDumpRegistry.swift index 8b7ae6f968..b847495a45 100644 --- a/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/TablePro/Core/Database/NativeDumpRegistry.swift @@ -269,18 +269,14 @@ enum NativeDumpRegistry { installHint: String(localized: "Install it with “brew install mongodb-database-tools”."), backupDelivery: .toolWritesFile, restoreDelivery: .toolWritesFile, - needsCredentialsFile: true, backupArguments: { request, _ in - mongoConnectionFlags(request) + mongoNamespaceFlags(request) + [ - "--gzip", - "--archive=\(request.fileURL.path)" - ] + mongoNamespaceFlags(request) + ["--gzip", "--archive=\(request.fileURL.path)"] }, restoreArguments: { request, _ in - mongoConnectionFlags(request) - + NativeDumpArgumentQuoting.mongoRestoreRenaming(into: request.database) + NativeDumpArgumentQuoting.mongoRestoreRenaming(into: request.database) + ["--gzip", "--archive=\(request.fileURL.path)"] - } + }, + configurationFileEntries: { request in mongoConfigurationEntries(request) } ) ), archiveFormat: NativeDumpDescriptor.ArchiveFormat( @@ -300,16 +296,19 @@ enum NativeDumpRegistry { } } - private static func mongoConnectionFlags(_ request: NativeDumpDescriptor.Request) -> [String] { - var flags = ["--host=\(request.host)", "--port=\(request.connection.port)"] - if !request.connection.username.isEmpty { - flags.append("--username=\(request.connection.username)") - flags.append("--authenticationDatabase=\(request.connection.database.isEmpty ? "admin" : request.connection.database)") - } - if request.connection.sslConfig.isEnabled { - flags.append("--ssl") + private static func mongoConfigurationEntries( + _ request: NativeDumpDescriptor.Request + ) -> [NativeDumpDescriptor.ConfigurationEntry] { + var entries = [ + NativeDumpDescriptor.ConfigurationEntry( + key: "uri", + value: MongoToolsConnectionString.make(for: request.connection, host: request.host) + ) + ] + if let password = request.password, !password.isEmpty, !request.connection.username.isEmpty { + entries.append(NativeDumpDescriptor.ConfigurationEntry(key: "password", value: password)) } - return flags + return entries } // MARK: - SQL Server diff --git a/TablePro/Core/Database/NativeDumpService.swift b/TablePro/Core/Database/NativeDumpService.swift index 4ceaccc975..e85d3908e0 100644 --- a/TablePro/Core/Database/NativeDumpService.swift +++ b/TablePro/Core/Database/NativeDumpService.swift @@ -499,10 +499,9 @@ final class NativeDumpService: ObservableObject { } var credentialsFileURL: URL? - if tool.needsCredentialsFile, - let password = request.password, !password.isEmpty, - !request.connection.username.isEmpty { - let file = try writeMongoCredentialsFile(password: password) + let configurationEntries = tool.configurationFileEntries(request) + if !configurationEntries.isEmpty { + let file = try writeMongoConfigurationFile(configurationEntries) credentialsFileURL = file arguments.append("--config=\(file.path)") } @@ -524,10 +523,12 @@ final class NativeDumpService: ObservableObject { /// `mongodump` and `mongorestore` read a password from neither the environment nor standard /// input, and one in `argv` is readable by every process on the machine. Their `--config` file /// is the remaining channel, so it is written owner-only and removed when the process exits. - nonisolated static func writeMongoCredentialsFile(password: String) throws -> URL { + nonisolated static func writeMongoConfigurationFile( + _ entries: [NativeDumpDescriptor.ConfigurationEntry] + ) throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("tablepro-mongo-\(UUID().uuidString).yaml") - let contents = "password: \(mongoYAMLQuoted(password))\n" + let contents = entries.map { "\($0.key): \(mongoYAMLQuoted($0.value))\n" }.joined() guard let data = contents.data(using: .utf8) else { throw NativeDumpError.sourceUnreadable } diff --git a/TableProTests/Database/MongoToolsConnectionStringTests.swift b/TableProTests/Database/MongoToolsConnectionStringTests.swift new file mode 100644 index 0000000000..12f79f5cb7 --- /dev/null +++ b/TableProTests/Database/MongoToolsConnectionStringTests.swift @@ -0,0 +1,166 @@ +// +// MongoToolsConnectionStringTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct MongoToolsConnectionStringTests { + private func connection( + host: String = "db.example.com", + port: Int = 27_017, + database: String = "shop", + username: String = "alice", + sslMode: SSLMode = .disabled + ) -> DatabaseConnection { + DatabaseConnection( + name: "Test", + host: host, + port: port, + database: database, + username: username, + type: .mongodb, + sshConfig: SSHConfiguration(), + sslConfig: SSLConfiguration(mode: sslMode) + ) + } + + private func uri(_ connection: DatabaseConnection) -> String { + MongoToolsConnectionString.make(for: connection, host: connection.host) + } + + @Test("The auth database follows the driver's rule for every combination") + func authenticationDatabaseMatchesTheDriver() { + for explicit in [nil, "", "accounts", "$external"] { + for database in ["", "shop"] { + for useSrv in [false, true] { + var subject = connection(database: database) + subject.mongoAuthSource = explicit + subject.mongoUseSrv = useSrv + let driver = MongoDBAuthSourceResolver.resolve( + explicitAuthSource: explicit, + configuredDatabase: database, + useSrv: useSrv + ) + #expect( + MongoToolsConnectionString.authenticationDatabase(for: subject) == driver, + "explicit \(explicit ?? "nil"), database \(database), srv \(useSrv)" + ) + } + } + } + } + + @Test("An Atlas host is SRV without the toggle, as the driver treats it") + func atlasHostImpliesSrv() { + let atlas = uri(connection(host: "cluster0.abcde.mongodb.net")) + #expect(atlas.hasPrefix("mongodb+srv://alice@cluster0.abcde.mongodb.net/?authSource=admin")) + #expect(atlas.contains("tls=true")) + } + + @Test("An SRV name loses the port the Hosts list gave it") + func srvDropsThePort() { + var subject = connection(host: "cluster0.example.com") + subject.mongoUseSrv = true + subject.additionalFields["mongoHosts"] = "cluster0.example.com:27017" + #expect(uri(subject).hasPrefix("mongodb+srv://alice@cluster0.example.com/?")) + } + + @Test("Hosts without a port take the connection's port, and an IPv6 literal keeps its brackets") + func hostListPorts() { + var subject = connection(port: 27_018) + subject.additionalFields["mongoHosts"] = "a.example.com, [::1], b.example.com:27019, [fe80::1]:27020" + #expect(uri(subject).hasPrefix( + "mongodb://alice@a.example.com:27018,[::1]:27018,b.example.com:27019,[fe80::1]:27020/?" + )) + } + + @Test("A connection without a username sends no auth database, which the tools would try to use") + func anonymousSendsNoAuthDatabase() { + #expect(uri(connection(username: "")) == "mongodb://db.example.com:27017/") + } + + @Test("Reserved characters in the username are percent-encoded") + func usernameIsEncoded() { + #expect(uri(connection(username: "pl+us@x:y/z%")).hasPrefix("mongodb://pl+us%40x%3Ay%2Fz%25@db.example.com")) + } + + @Test("The connection's mechanism, replica set, read preference and write concern reach the tool") + func connectionOptionsReachTheTool() { + var subject = connection() + subject.mongoAuthMechanism = "SCRAM-SHA-256" + subject.mongoReplicaSet = "rs0" + subject.mongoReadPreference = "secondaryPreferred" + subject.mongoWriteConcern = "majority" + #expect(uri(subject) == """ + mongodb://alice@db.example.com:27017/?authSource=shop&authMechanism=SCRAM-SHA-256\ + &replicaSet=rs0&readPreference=secondaryPreferred&w=majority + """) + } + + @Test("Extra URI parameters pass through unless a connection field owns them") + func extraParametersPassThrough() { + var subject = connection() + subject.additionalFields["mongoParam_directConnection"] = "true" + subject.additionalFields["mongoParam_authSource"] = "elsewhere" + subject.additionalFields["mongoParam_readPreference"] = "nearest" + subject.additionalFields["mongoParam_appName"] = "a&b=c+d" + let plain = uri(subject) + #expect(plain.contains("directConnection=true")) + #expect(plain.contains("readPreference=nearest")) + #expect(plain.contains("appName=a%26b%3Dc%2Bd")) + #expect(!plain.contains("authSource=elsewhere")) + + subject.mongoReadPreference = "secondary" + let owned = uri(subject) + #expect(owned.contains("readPreference=secondary")) + #expect(!owned.contains("readPreference=nearest")) + } + + @Test("SSL off sends no TLS options and no certificate the form still holds") + func tlsDisabled() { + var subject = connection() + subject.sslConfig.caCertificatePath = "/certs/ca.pem" + subject.sslConfig.clientCertificatePath = "/certs/client.pem" + #expect(MongoToolsConnectionString.tlsParameters(for: subject).isEmpty) + } + + @Test("Preferred and Required skip verification with the one option the tools honor") + func tlsWithoutVerification() { + for mode in [SSLMode.preferred, .required] { + var subject = connection(sslMode: mode) + subject.sslConfig.caCertificatePath = "/certs/ca.pem" + #expect( + MongoToolsConnectionString.tlsParameters(for: subject) == ["tls=true", "tlsInsecure=true"], + "\(mode.rawValue)" + ) + } + } + + @Test("The verifying modes send their CA and never switch verification off") + func tlsWithVerification() { + for mode in [SSLMode.verifyCa, .verifyIdentity] { + var subject = connection(sslMode: mode) + subject.sslConfig.caCertificatePath = "/certs/my ca.pem" + subject.sslConfig.clientCertificatePath = "/certs/client.pem" + #expect( + MongoToolsConnectionString.tlsParameters(for: subject) == [ + "tls=true", "tlsCAFile=/certs/my%20ca.pem", "tlsCertificateKeyFile=/certs/client.pem" + ], + "\(mode.rawValue)" + ) + } + } + + @Test("SRV turns TLS on with full verification when SSL is left off") + func srvForcesTls() { + var subject = connection() + subject.mongoUseSrv = true + subject.sslConfig.clientCertificatePath = "/certs/client.pem" + #expect(MongoToolsConnectionString.tlsParameters(for: subject) == ["tls=true"]) + } +} diff --git a/TableProTests/Database/NativeDumpRegistryTests.swift b/TableProTests/Database/NativeDumpRegistryTests.swift index 5b82222364..b138d15b49 100644 --- a/TableProTests/Database/NativeDumpRegistryTests.swift +++ b/TableProTests/Database/NativeDumpRegistryTests.swift @@ -69,6 +69,18 @@ struct NativeDumpRegistryTests { ) } + private func configuration(of built: NativeDumpCommand) throws -> String { + let url = try #require(built.temporaryCredentialsFileURL) + defer { try? FileManager.default.removeItem(at: url) } + return try String(contentsOf: url, encoding: .utf8) + } + + private func configurationValue(_ key: String, in contents: String) throws -> String { + let prefix = "\(key): \"" + let line = try #require(contents.split(separator: "\n").first { $0.hasPrefix(prefix) }) + return String(line.dropFirst(prefix.count).dropLast()) + } + @Test("The engines with client-side tools are the ones the menu offers") func supportedEngines() { for type in [DatabaseType.postgresql, .redshift, .mysql, .mariadb, .mongodb, .sqlite] { @@ -171,12 +183,63 @@ struct NativeDumpRegistryTests { #expect(built.environment["MONGO_PASSWORD"] == nil) } - @Test("MongoDB writes no credentials file without a username") + @Test("MongoDB writes no password and no auth database without a username") func mongoSkipsCredentialsWithoutUser() throws { let anonymous = connection(type: .mongodb, username: "") - let built = try command(.mongodb, connection: anonymous) - #expect(built.temporaryCredentialsFileURL == nil) - #expect(!built.arguments.contains { $0.hasPrefix("--config=") }) + let contents = try configuration(of: command(.mongodb, connection: anonymous)) + #expect(!contents.contains("password:")) + let uri = try configurationValue("uri", in: contents) + #expect(uri == "mongodb://db.example.com:5432/") + } + + @Test("MongoDB authenticates against the Auth Database field before the connection's database") + func mongoAuthenticatesAgainstTheAuthDatabase() throws { + var shop = connection(type: .mongodb, database: "shop") + shop.mongoAuthSource = "admin" + let explicit = try configurationValue("uri", in: configuration(of: command(.mongodb, connection: shop))) + #expect(explicit.contains("authSource=admin")) + #expect(!explicit.contains("authSource=shop")) + + shop.mongoAuthSource = nil + let fallback = try configurationValue("uri", in: configuration(of: command(.mongodb, connection: shop))) + #expect(fallback.contains("authSource=shop")) + } + + @Test("An SRV connection reaches the tool as a mongodb+srv connection string with TLS on", arguments: [ + NativeDumpKind.backup, .restore + ]) + func mongoSrvUsesAConnectionString(kind: NativeDumpKind) throws { + var atlas = connection(type: .mongodb, host: "cluster0.example.mongodb.net", port: 27_017) + atlas.mongoUseSrv = true + let built = try command(.mongodb, kind: kind, connection: atlas) + let uri = try configurationValue("uri", in: configuration(of: built)) + #expect(uri.hasPrefix("mongodb+srv://alice@cluster0.example.mongodb.net/?")) + #expect(uri.contains("authSource=admin")) + #expect(uri.contains("tls=true")) + #expect(!built.arguments.contains { $0.hasPrefix("--host") || $0.hasPrefix("--port") }) + } + + @Test("A host list and its replica set reach the tool whole, not as the first host") + func mongoHostListUsesAConnectionString() throws { + var replicaSet = connection(type: .mongodb, host: "a.example.com", port: 27_017) + replicaSet.additionalFields["mongoHosts"] = "a.example.com:27017,b.example.com:27018" + replicaSet.mongoReplicaSet = "rs0" + let built = try command(.mongodb, connection: replicaSet) + let uri = try configurationValue("uri", in: configuration(of: built)) + #expect(uri.hasPrefix("mongodb://alice@a.example.com:27017,b.example.com:27018/?")) + #expect(uri.contains("replicaSet=rs0")) + #expect(!built.arguments.contains { $0.hasPrefix("--host") }) + } + + @Test("A Required MongoDB connection reaches the tool without verifying, as it does in the app") + func mongoRequiredSkipsVerification() throws { + var secured = connection(type: .mongodb, sslMode: .required, sslEnabled: true) + secured.sslConfig.clientCertificatePath = "/certs/client.pem" + let built = try command(.mongodb, connection: secured) + let uri = try configurationValue("uri", in: configuration(of: built)) + #expect(uri.contains("tls=true&tlsInsecure=true")) + #expect(uri.contains("tlsCertificateKeyFile=/certs/client.pem")) + #expect(!built.arguments.contains("--ssl")) } @Test("MongoDB names the database on backup and renames the archive into the target on restore") @@ -240,7 +303,7 @@ struct NativeDumpRegistryTests { #expect(mysql.arguments.contains("127.0.0.1")) let mongo = try command(.mongodb, connection: connection(type: .mongodb, host: "")) - #expect(mongo.arguments.contains("--host=127.0.0.1")) + #expect(try configurationValue("uri", in: configuration(of: mongo)).hasPrefix("mongodb://alice@127.0.0.1:5432/?")) } /// Measured, MariaDB 12.3.3 answers any `--ssl-mode` with `unknown variable` and exit 7, and From ea1fe39f40b9bec61f2f5ec14c74be690197cab4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 15:10:39 +0700 Subject: [PATCH 3/4] fix(connections): say what each engine's restore does to existing objects in the Restore confirmation --- CHANGELOG.md | 1 + .../Core/Database/NativeDumpDescriptor.swift | 10 ++++ .../Core/Database/NativeDumpRegistry.swift | 19 ++++-- TablePro/Resources/Localizable.xcstrings | 9 +++ .../Backup/RestoreConfirmationText.swift | 42 +++++++++++++ .../Views/Backup/RestoreDatabaseFlow.swift | 11 ++-- .../Backup/RestoreConfirmationTextTests.swift | 59 +++++++++++++++++++ 7 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 TablePro/Views/Backup/RestoreConfirmationText.swift create mode 100644 TableProTests/Views/Backup/RestoreConfirmationTextTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b311a8d805..3701476d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SSH tab host-list warning naming replica set failover for Redis and Kafka, and implying Sentinel works through a tunnel. - MongoDB restore into a database with a different name restoring nothing and reporting success. - MongoDB Backup Dump and Restore ignoring the connection's Auth Database, Hosts list, SRV and TLS options. +- Restore confirmation claiming existing objects are overwritten on PostgreSQL, MongoDB, SQLite, SQL Server and DuckDB. ### Security diff --git a/TablePro/Core/Database/NativeDumpDescriptor.swift b/TablePro/Core/Database/NativeDumpDescriptor.swift index f3acbf271b..4274cbfa88 100644 --- a/TablePro/Core/Database/NativeDumpDescriptor.swift +++ b/TablePro/Core/Database/NativeDumpDescriptor.swift @@ -232,9 +232,17 @@ struct NativeDumpDescriptor: Sendable { case engineStatements(EngineStatements) } + internal enum RestoreSemantics: Sendable, Equatable { + case replacesObjects + case addsToExistingObjects + case stopsAtExistingObjects + case requiresEmptyDatabase + } + let mechanism: Mechanism let archiveFormat: ArchiveFormat let objectScope: NativeDumpObjectScope + let restoreSemantics: RestoreSemantics /// True when the tool opens the database file itself and so cannot reach a connection that has /// no local file. libSQL claims the SQLite descriptor and reaches either a file or a Turso URL, @@ -245,11 +253,13 @@ struct NativeDumpDescriptor: Sendable { mechanism: Mechanism, archiveFormat: ArchiveFormat, objectScope: NativeDumpObjectScope, + restoreSemantics: RestoreSemantics, requiresLocalFile: Bool = false ) { self.mechanism = mechanism self.archiveFormat = archiveFormat self.objectScope = objectScope + self.restoreSemantics = restoreSemantics self.requiresLocalFile = requiresLocalFile } diff --git a/TablePro/Core/Database/NativeDumpRegistry.swift b/TablePro/Core/Database/NativeDumpRegistry.swift index b847495a45..d3df2e0ec5 100644 --- a/TablePro/Core/Database/NativeDumpRegistry.swift +++ b/TablePro/Core/Database/NativeDumpRegistry.swift @@ -121,7 +121,8 @@ enum NativeDumpRegistry { A dump of chosen tables may not restore on its own. Sequences, types, schemas \ and tables it references are left out. """) - ) + ), + restoreSemantics: .addsToExistingObjects ) } @@ -223,7 +224,8 @@ enum NativeDumpRegistry { Views and tables the chosen tables reference are left out. The dump turns \ foreign key checks off, so it restores with those references dangling. """) - ) + ), + restoreSemantics: .replacesObjects ) } @@ -283,7 +285,8 @@ enum NativeDumpRegistry { fileExtension: "archive", contentDescription: String(localized: "MongoDB gzipped archive") ), - objectScope: .collections + objectScope: .collections, + restoreSemantics: .addsToExistingObjects ) } @@ -353,7 +356,8 @@ enum NativeDumpRegistry { The .bacpac always carries the whole schema. Only the chosen tables' data is \ narrowed, and tables they reference by foreign key have to be chosen too. """) - ) + ), + restoreSemantics: .requiresEmptyDatabase ) } @@ -417,6 +421,7 @@ enum NativeDumpRegistry { contentDescription: String(localized: "SQL statements") ), objectScope: .tables(caveat: nil), + restoreSemantics: .addsToExistingObjects, requiresLocalFile: true ) } @@ -474,7 +479,8 @@ enum NativeDumpRegistry { archiveFormat: duckDBFileFormat, objectScope: .unsupported( reason: String(localized: "DuckDB copies the whole database. There is no table filter.") - ) + ), + restoreSemantics: .stopsAtExistingObjects ) } @@ -497,7 +503,8 @@ enum NativeDumpRegistry { archiveFormat: duckDBParquetFormat, objectScope: .unsupported( reason: String(localized: "EXPORT DATABASE writes the whole database. There is no table filter.") - ) + ), + restoreSemantics: .stopsAtExistingObjects ) } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 7df06f13c6..065026394d 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -159075,9 +159075,18 @@ } } } + }, + "The dump is copied into this database, and the change cannot be undone. If an object it holds already exists, the restore stops before copying anything." : { + + }, + "The dump is imported into this database, and the change cannot be undone. The import needs an empty database and stops if this one already holds objects." : { + }, "The dump is replayed into this database. Objects it names are overwritten and the change cannot be undone." : { + }, + "The dump is replayed into this database. Objects that already exist are kept, not replaced, and the dump's data is added to them. The change cannot be undone." : { + }, "The encrypted file is corrupt or incomplete" : { "extractionState" : "stale", diff --git a/TablePro/Views/Backup/RestoreConfirmationText.swift b/TablePro/Views/Backup/RestoreConfirmationText.swift new file mode 100644 index 0000000000..34798d4436 --- /dev/null +++ b/TablePro/Views/Backup/RestoreConfirmationText.swift @@ -0,0 +1,42 @@ +// +// RestoreConfirmationText.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +internal enum RestoreConfirmationText { + static func message(for type: DatabaseType, formatId: String? = nil) -> String? { + NativeDumpRegistry.descriptor(for: type, formatId: formatId).map { message(for: $0.restoreSemantics) } + } + + static func message(for semantics: NativeDumpDescriptor.RestoreSemantics) -> String { + switch semantics { + case .replacesObjects: + return String( + localized: """ + The dump is replayed into this database. Objects it names are overwritten and \ + the change cannot be undone. + """) + case .addsToExistingObjects: + return String( + localized: """ + The dump is replayed into this database. Objects that already exist are kept, \ + not replaced, and the dump's data is added to them. The change cannot be undone. + """) + case .stopsAtExistingObjects: + return String( + localized: """ + The dump is copied into this database, and the change cannot be undone. If an \ + object it holds already exists, the restore stops before copying anything. + """) + case .requiresEmptyDatabase: + return String( + localized: """ + The dump is imported into this database, and the change cannot be undone. The \ + import needs an empty database and stops if this one already holds objects. + """) + } + } +} diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index c71b871bab..6011fc74a2 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -174,14 +174,15 @@ struct RestoreDatabaseFlow: View { /// A restore replays a dump into a database that already has contents, and the tools it drives /// do not ask. Picking a database in the list used to be the last step before the first write. private func startRestore(database: String) async { + guard let message = RestoreConfirmationText.message(for: connection.type, formatId: formatId) else { + phase = .failed( + message: NativeDumpError.unsupportedDatabase.localizedDescription, targetMayBeModified: false) + return + } guard await AlertHelper.confirmDestructive( title: String( format: String(localized: "Restore into \u{201C}%@\u{201D}?"), database), - message: String( - localized: """ - The dump is replayed into this database. Objects it names are overwritten and \ - the change cannot be undone. - """), + message: message, confirmButton: String(localized: "Restore"), window: hostWindow ) else { diff --git a/TableProTests/Views/Backup/RestoreConfirmationTextTests.swift b/TableProTests/Views/Backup/RestoreConfirmationTextTests.swift new file mode 100644 index 0000000000..71b577ef44 --- /dev/null +++ b/TableProTests/Views/Backup/RestoreConfirmationTextTests.swift @@ -0,0 +1,59 @@ +// +// RestoreConfirmationTextTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +struct RestoreConfirmationTextTests { + @Test("Only MySQL and MariaDB, whose dump drops and recreates each table, say objects are overwritten") + func onlyMySQLOverwrites() throws { + for type in [DatabaseType.mysql, .mariadb] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(message.contains("overwritten"), "\(type.rawValue)") + } + for type in [DatabaseType.postgresql, .redshift, .mongodb, .sqlite, .libsql, .mssql, .duckdb] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(!message.contains("overwritten"), "\(type.rawValue)") + } + } + + @Test("pg_restore, mongorestore and sqlite3 say existing objects are kept and added to") + func appendingEnginesKeepExistingObjects() throws { + for type in [DatabaseType.postgresql, .redshift, .mongodb, .sqlite, .libsql] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(message.contains("kept"), "\(type.rawValue)") + } + } + + @Test("DuckDB says the restore stops at an object that already exists, in both formats") + func duckDBStopsAtExistingObjects() throws { + for format in NativeDumpRegistry.formats(for: .duckdb) { + let message = try #require(RestoreConfirmationText.message(for: .duckdb, formatId: format.id)) + #expect(message.contains("stops"), "\(format.id)") + } + } + + @Test("SQL Server says the import needs an empty database") + func sqlServerNeedsAnEmptyDatabase() throws { + let message = try #require(RestoreConfirmationText.message(for: .mssql)) + #expect(message.contains("empty database")) + } + + @Test("Every message says the change cannot be undone") + func everyMessageWarnsItCannotBeUndone() throws { + for type in [DatabaseType.mysql, .postgresql, .mongodb, .sqlite, .mssql, .duckdb] { + let message = try #require(RestoreConfirmationText.message(for: type)) + #expect(message.contains("cannot be undone"), "\(type.rawValue)") + } + } + + @Test("An engine with no dump has no confirmation to show") + func unsupportedEngineHasNoMessage() { + #expect(RestoreConfirmationText.message(for: .clickhouse) == nil) + } +} From c8124168a0be46c1844d82e5201faf155305730b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 30 Sep 2026 20:00:00 +0700 Subject: [PATCH 4/4] test(plugins): request display text in shortened value fixtures --- TableProTests/Plugins/ElasticsearchDriverTests.swift | 2 +- TableProTests/Plugins/TypesenseDriverTests.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/TableProTests/Plugins/ElasticsearchDriverTests.swift b/TableProTests/Plugins/ElasticsearchDriverTests.swift index 5825be8490..c7c1b5138f 100644 --- a/TableProTests/Plugins/ElasticsearchDriverTests.swift +++ b/TableProTests/Plugins/ElasticsearchDriverTests.swift @@ -1202,7 +1202,7 @@ struct ElasticsearchStatementGeneratorTests { } private func displayed(_ value: Any) -> String { - ElasticsearchMappingFlattener.cell(value).asText ?? "" + ElasticsearchMappingFlattener.cell(value, length: .display).asText ?? "" } private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { diff --git a/TableProTests/Plugins/TypesenseDriverTests.swift b/TableProTests/Plugins/TypesenseDriverTests.swift index 4b7d6a779c..1c50b08287 100644 --- a/TableProTests/Plugins/TypesenseDriverTests.swift +++ b/TableProTests/Plugins/TypesenseDriverTests.swift @@ -964,7 +964,7 @@ struct TypesenseStatementGeneratorTests { } private func shortenedAuthors() -> String { - TypesenseSchema.cell((0..<1_500).map { "author-\($0)" }).asText ?? "" + TypesenseSchema.cell((0..<1_500).map { "author-\($0)" }, length: .display).asText ?? "" } private func shortenedRefusal(_ column: String) -> PluginRowWriteRefusal { @@ -1025,7 +1025,7 @@ struct TypesenseStatementGeneratorTests { @Test("A complete array written over one shortened for display is refused, since it may be the shown part closed") func updateRefusesACompleteArrayWrittenOverAShortenedOne() throws { - let shownPart = TypesenseSchema.cell((0..<600).map { "author-\($0)" }).asText ?? "" + let shownPart = TypesenseSchema.cell((0..<600).map { "author-\($0)" }, length: .display).asText ?? "" try #require(!shownPart.hasSuffix("...")) #expect(throws: shortenedRefusal("authors")) { try updateRequest(authorsEdit(from: shortenedAuthors(), to: .text(shownPart)))