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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ 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.
- 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.
- iPhone and iPad reading a Safe Mode level they do not recognize from iCloud as Off.

### Security
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)
}
}
130 changes: 130 additions & 0 deletions TablePro/Core/Database/MongoToolsConnectionString.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
"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[..<colon]), port.isEmpty ? nil : port)
}

private static func parameters(_ connection: DatabaseConnection) -> [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>) -> [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
}
}
4 changes: 4 additions & 0 deletions TablePro/Core/Database/NativeDumpArgumentQuoting.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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$"]
}
}
26 changes: 19 additions & 7 deletions TablePro/Core/Database/NativeDumpDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -153,29 +155,29 @@ 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
self.installHint = installHint
self.backupDelivery = backupDelivery
self.restoreDelivery = restoreDelivery
self.exposesPasswordInArguments = exposesPasswordInArguments
self.needsCredentialsFile = needsCredentialsFile
self.restoreExitPolicy = restoreExitPolicy
self.requiresUntranslatedMessages = requiresUntranslatedMessages
self.toolForServer = toolForServer
self.identifyExecutable = identifyExecutable
self.backupArguments = backupArguments
self.restoreArguments = restoreArguments
self.environment = environment
self.configurationFileEntries = configurationFileEntries
}

func binaries(for kind: NativeDumpKind) -> [String] {
Expand Down Expand Up @@ -230,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,
Expand All @@ -243,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
}

Expand Down
56 changes: 30 additions & 26 deletions TablePro/Core/Database/NativeDumpRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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
)
}

Expand Down Expand Up @@ -269,27 +271,22 @@ 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) + [
"--nsInclude=\(request.database).*",
"--gzip",
"--archive=\(request.fileURL.path)"
]
}
NativeDumpArgumentQuoting.mongoRestoreRenaming(into: request.database)
+ ["--gzip", "--archive=\(request.fileURL.path)"]
},
configurationFileEntries: { request in mongoConfigurationEntries(request) }
)
),
archiveFormat: NativeDumpDescriptor.ArchiveFormat(
fileExtension: "archive",
contentDescription: String(localized: "MongoDB gzipped archive")
),
objectScope: .collections
objectScope: .collections,
restoreSemantics: .addsToExistingObjects
)
}

Expand All @@ -302,16 +299,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
Expand Down Expand Up @@ -356,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
)
}

Expand Down Expand Up @@ -420,6 +421,7 @@ enum NativeDumpRegistry {
contentDescription: String(localized: "SQL statements")
),
objectScope: .tables(caveat: nil),
restoreSemantics: .addsToExistingObjects,
requiresLocalFile: true
)
}
Expand Down Expand Up @@ -477,7 +479,8 @@ enum NativeDumpRegistry {
archiveFormat: duckDBFileFormat,
objectScope: .unsupported(
reason: String(localized: "DuckDB copies the whole database. There is no table filter.")
)
),
restoreSemantics: .stopsAtExistingObjects
)
}

Expand All @@ -500,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
)
}

Expand Down
Loading
Loading