Skip to content
108 changes: 28 additions & 80 deletions apple/Plugins/MongoDBDriverPlugin/MongoDBConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,86 +236,24 @@ nonisolated final class MongoDBConnection: @unchecked Sendable {
// MARK: - URI Construction

private func buildUri() -> String {
let scheme = useSrv ? "mongodb+srv" : "mongodb"
var uri = "\(scheme)://"

if !user.isEmpty {
let encodedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
if let password = password, !password.isEmpty {
let encodedPassword = password.addingPercentEncoding(
withAllowedCharacters: .urlPasswordAllowed
) ?? password
uri += "\(encodedUser):\(encodedPassword)@"
} else {
uri += "\(encodedUser)@"
}
}

if useSrv {
let srvHost = Self.stripPort(fromSrvHost: host)
let encodedHost = srvHost.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? srvHost
uri += encodedHost
} else if let hosts = MongoDBHostList.uriHosts(host, defaultPort: port) {
uri += hosts
} else if host.contains(",") {
let segments = host.split(separator: ",").compactMap { segment -> String? in
let parts = segment.split(separator: ":", maxSplits: 1)
guard let first = parts.first else { return nil }
let h = String(first).trimmingCharacters(in: .whitespaces)
guard !h.isEmpty else { return nil }
let encodedH = h.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? h
if parts.count > 1 {
return "\(encodedH):\(parts[1].trimmingCharacters(in: .whitespaces))"
}
return "\(encodedH):\(port)"
}
uri += segments.isEmpty ? "localhost:\(port)" : segments.joined(separator: ",")
} else {
let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host
uri += "\(encodedHost):\(port)"
}

let encodedDb = database.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? database
uri += database.isEmpty ? "/" : "/\(encodedDb)"

let encodedAuthSource = resolvedAuthSource
.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? resolvedAuthSource
var params = connectTimeout.uriParameters
params.append("authSource=\(encodedAuthSource)")

params.append(contentsOf: MongoDBSSLMapping.uriParameters(
for: ssl,
certificateKeyFile: clientCertificateFile?.path
))

if let rp = readPreference, !rp.isEmpty {
params.append("readPreference=\(rp)")
}
if let wc = writeConcern, !wc.isEmpty {
params.append("w=\(wc)")
}
if let mechanism = authMechanism, !mechanism.isEmpty {
params.append("authMechanism=\(mechanism)")
}
if let rs = replicaSet, !rs.isEmpty {
params.append("replicaSet=\(rs)")
}

var explicitKeys: Set<String> = [
"connectTimeoutMS", "serverSelectionTimeoutMS",
"authSource", "authMechanism", "replicaSet",
"tls", "tlsAllowInvalidCertificates", "tlsAllowInvalidHostnames",
"tlsCAFile", "tlsCertificateKeyFile"
]
if let readPreference, !readPreference.isEmpty { explicitKeys.insert("readPreference") }
if let writeConcern, !writeConcern.isEmpty { explicitKeys.insert("w") }
for (key, value) in extraUriParams where !explicitKeys.contains(key) {
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
params.append("\(key)=\(encodedValue)")
}

uri += "?" + params.joined(separator: "&")
return uri
MongoDBConnectionURI(
useSrv: useSrv,
host: host,
port: port,
user: user,
password: password,
database: database,
options: MongoDBConnectionURI.options(
connectTimeout: connectTimeout,
authSource: resolvedAuthSource,
ssl: MongoDBSSLMapping.uriOptions(for: ssl, certificateKeyFile: clientCertificateFile?.path),
readPreference: readPreference,
writeConcern: writeConcern,
authMechanism: authMechanism,
replicaSet: replicaSet,
extraParams: extraUriParams
)
).text
}

/// Strips a trailing `:port` from a hostname intended for an SRV URI.
Expand Down Expand Up @@ -388,10 +326,13 @@ nonisolated final class MongoDBConnection: @unchecked Sendable {

self.client = newClient
let configuredWriteConcern = MongoWriteConcern(client: newClient)
// Read here, on the connection's queue, so building a browse query never waits on the server.
let version = self.fetchServerVersionSync()

self.stateLock.lock()
self._isConnected = true
self._writeConcern = configuredWriteConcern
self._cachedServerVersion = version
self.stateLock.unlock()

logger.info("Connected to MongoDB at \(self.host):\(self.port)")
Expand Down Expand Up @@ -611,6 +552,13 @@ nonisolated final class MongoDBConnection: @unchecked Sendable {
return nil
#endif
}

var knownServerVersion: String? {
stateLock.lock()
defer { stateLock.unlock() }
return _cachedServerVersion
}

func currentDatabase() -> String { database }

// MARK: - Command Execution
Expand Down
140 changes: 140 additions & 0 deletions apple/Plugins/MongoDBDriverPlugin/MongoDBConnectionURI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//
// MongoDBConnectionURI.swift
// MongoDBDriverPlugin
//

import Foundation
import TableProPluginKit

nonisolated struct MongoDBURIOption: Equatable, Sendable {
let key: String
let value: String
}

nonisolated struct MongoDBConnectionURI: Sendable {
let useSrv: Bool
let host: String
let port: Int
let user: String
let password: String?
let database: String
let options: [MongoDBURIOption]

// libmongoc splits options on a raw `&` and decodes every value, and the Go tools also split on `;` and read `+` as a
// space, so only RFC 3986 unreserved characters stay raw. Not `.alphanumerics`, which holds non-ASCII letters.
static let unreserved = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
)

static func encoded(_ component: String) -> String {
component.addingPercentEncoding(withAllowedCharacters: unreserved) ?? component
}

var text: String {
let scheme = useSrv ? "mongodb+srv" : "mongodb"
let encodedDb = database.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? database
let path = database.isEmpty ? "/" : "/\(encodedDb)"
let query = options.map { "\(Self.encoded($0.key))=\(Self.encoded($0.value))" }.joined(separator: "&")
return "\(scheme)://\(userInfo)\(hosts)\(path)?\(query)"
}

private var userInfo: String {
guard !user.isEmpty else { return "" }
let encodedUser = user.addingPercentEncoding(withAllowedCharacters: .urlUserAllowed) ?? user
guard let password, !password.isEmpty else { return "\(encodedUser)@" }
let encodedPassword = password.addingPercentEncoding(withAllowedCharacters: .urlPasswordAllowed) ?? password
return "\(encodedUser):\(encodedPassword)@"
}

private var hosts: String {
if useSrv {
let srvHost = MongoDBConnection.stripPort(fromSrvHost: host)
return srvHost.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? srvHost
}
if let hosts = MongoDBHostList.uriHosts(host, defaultPort: port) {
return hosts
}
guard host.contains(",") else {
let encodedHost = host.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? host
return "\(encodedHost):\(port)"
}
// A list MongoDBHostList cannot read is passed on for libmongoc to refuse, with its ports encoded so a typed
// `/?` cannot start the options.
let segments = host.split(separator: ",").compactMap { segment -> String? in
let parts = segment.split(separator: ":", maxSplits: 1)
guard let first = parts.first else { return nil }
let name = String(first).trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return nil }
let encodedName = name.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? name
guard parts.count > 1 else { return "\(encodedName):\(port)" }
return "\(encodedName):\(Self.encoded(parts[1].trimmingCharacters(in: .whitespaces)))"
}
return segments.isEmpty ? "localhost:\(port)" : segments.joined(separator: ",")
}

// The fields own their keys, and a typed parameter named like one is dropped rather than sent after it, where
// libmongoc would let it win. Keys compare without case, as libmongoc reads them.
static func options(
connectTimeout: MongoDBConnectTimeout,
authSource: String,
ssl: [MongoDBURIOption],
readPreference: String?,
writeConcern: String?,
authMechanism: String?,
replicaSet: String?,
extraParams: [String: String]
) -> [MongoDBURIOption] {
var options = connectTimeout.uriOptions
options.append(MongoDBURIOption(key: "authSource", value: authSource))
options += ssl
let typed: [(key: String, value: String?)] = [
("readPreference", readPreference),
("w", writeConcern),
("authMechanism", authMechanism),
("replicaSet", replicaSet)
]
for (key, value) in typed {
guard let value, !value.isEmpty else { continue }
options.append(MongoDBURIOption(key: key, value: value))
}

var taken = Set(fieldOwnedKeys.map { $0.lowercased() })
taken.formUnion(options.map { $0.key.lowercased() })
// With TLS on, the SSL mode decides certificate checking, and libmongoc refuses a revocation switch beside the
// `tlsAllowInvalidCertificates` that already turns it off.
if !ssl.isEmpty {
taken.insert("tlsinsecure")
}
if ssl.contains(where: { $0.key == "tlsAllowInvalidCertificates" }) {
taken.formUnion(["tlsdisableocspendpointcheck", "tlsdisablecertificaterevocationcheck"])
}
for (key, value) in extraParams.sorted(by: { $0.key < $1.key })
where !value.isEmpty && taken.insert(canonicalKey(key)).inserted {
options.append(MongoDBURIOption(key: key, value: value))
}
return options
}

// libmongoc still reads the old `ssl` names as their `tls` options, so a typed one would turn off a check the SSL
// mode asked for.
static let deprecatedAliases = [
"ssl": "tls",
"sslallowinvalidcertificates": "tlsallowinvalidcertificates",
"sslallowinvalidhostnames": "tlsallowinvalidhostnames",
"sslcertificateauthorityfile": "tlscafile",
"sslclientcertificatekeyfile": "tlscertificatekeyfile",
"sslclientcertificatekeypassword": "tlscertificatekeyfilepassword"
]

static func canonicalKey(_ key: String) -> String {
let lowered = key.lowercased()
return deprecatedAliases[lowered] ?? lowered
}

static let fieldOwnedKeys = [
"connectTimeoutMS", "serverSelectionTimeoutMS",
"authSource", "authMechanism", "replicaSet",
"tls", "tlsAllowInvalidCertificates", "tlsAllowInvalidHostnames",
"tlsCAFile", "tlsCertificateKeyFile"
]
}
Loading
Loading