Skip to content

Commit dbc6ef6

Browse files
Fix MongoConnectionString errors (#729)
1 parent 79c9683 commit dbc6ef6

File tree

7 files changed

+155
-40
lines changed

7 files changed

+155
-40
lines changed

Sources/MongoSwift/ConnectionPool.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,22 @@ internal class ConnectionPool {
296296
}
297297
}
298298

299+
/// Retrieves the `defaultAuthDB` configured on the connection string used to create this pool. If SDAM has been
300+
/// started in libmongoc, this will return the up-to-date value after SRV lookup.
301+
internal func getConnectionStringAuthDB() throws -> String? {
302+
try self.withConnection { connection in
303+
try connection.withMongocConnection { connPtr in
304+
guard let uri = mongoc_client_get_uri(connPtr) else {
305+
throw MongoError.InternalError(message: "Couldn't retrieve client's connection string")
306+
}
307+
guard let authSource = mongoc_uri_get_database(uri) else {
308+
return nil
309+
}
310+
return String(cString: authSource)
311+
}
312+
}
313+
}
314+
299315
/// Sets the provided APM callbacks on this pool, using the provided client as the "context" value. **This method
300316
/// may only be called before any connections are checked out of the pool.** Ideally this code would just live in
301317
/// `ConnectionPool.init`. However, the client we accept here has to be fully initialized before we can pass it

Sources/MongoSwift/MongoClient.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ public class MongoClient {
319319
if let options = options {
320320
try connString.applyOptions(options)
321321
}
322+
try connString.validate()
322323

323324
self.operationExecutor = OperationExecutor(
324325
eventLoopGroup: eventLoopGroup,

Sources/MongoSwift/MongoConnectionString.swift

Lines changed: 63 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,6 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
120120

121121
/// Initializes a ServerAddress, using the default localhost:27017 if a host/port is not provided.
122122
internal init(_ hostAndPort: String = "localhost:27017") throws {
123-
guard !hostAndPort.contains("?") else {
124-
throw MongoError.InvalidArgumentError(message: "\(hostAndPort) contains invalid characters")
125-
}
126-
127123
// Check if host is an IPv6 literal.
128124
if hostAndPort.first == "[" {
129125
let ipLiteralRegex = try NSRegularExpression(pattern: #"^\[(.*)\](?::([0-9]+))?$"#)
@@ -228,7 +224,7 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
228224
fileprivate var wTimeoutMS: Int?
229225
fileprivate var zlibCompressionLevel: Int?
230226

231-
fileprivate init(_ uriOptions: String) throws {
227+
fileprivate init(_ uriOptions: Substring) throws {
232228
let options = uriOptions.components(separatedBy: "&")
233229
// tracks options that have already been set to error on duplicates
234230
var setOptions: Set<String> = []
@@ -384,30 +380,51 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
384380
/// - Throws:
385381
/// - `MongoError.InvalidArgumentError` if the input is invalid.
386382
public init(string input: String) throws {
383+
// Parse the connection string's scheme.
387384
let schemeAndRest = input.components(separatedBy: "://")
388385
guard schemeAndRest.count == 2, let scheme = Scheme(schemeAndRest[0]) else {
389386
throw MongoError.InvalidArgumentError(
390387
message: "Invalid connection string scheme, expecting \'mongodb\' or \'mongodb+srv\'"
391388
)
392389
}
393390
guard !schemeAndRest[1].isEmpty else {
394-
throw MongoError.InvalidArgumentError(message: "Invalid connection string")
391+
throw MongoError.InvalidArgumentError(message: "The connection string must contain host information")
395392
}
396-
let identifiersAndOptions = schemeAndRest[1].components(separatedBy: "/")
397-
guard identifiersAndOptions.count <= 2 else {
393+
394+
// Split the rest of the connection string into its components.
395+
let infoAndOptions = schemeAndRest[1].split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
396+
if infoAndOptions[0].isEmpty {
397+
throw MongoError.InvalidArgumentError(message: "The connection string must contain host information")
398+
}
399+
let userHostsAndAuthDB = infoAndOptions[0].split(separator: "/", omittingEmptySubsequences: false)
400+
if userHostsAndAuthDB.count > 2 {
401+
throw MongoError.InvalidArgumentError(
402+
message: "The user information, host information, and defaultAuthDB in the connection string must not"
403+
+ " contain unescaped slashes"
404+
)
405+
} else if userHostsAndAuthDB.count == 1 && infoAndOptions.count == 2 {
398406
throw MongoError.InvalidArgumentError(
399-
message: "Connection string contains an unescaped slash"
407+
message: "The connection string must contain a delimiting slash between the host information and"
408+
+ " options"
400409
)
401410
}
402-
let userAndHost = identifiersAndOptions[0].components(separatedBy: "@")
403-
guard userAndHost.count <= 2 else {
404-
throw MongoError.InvalidArgumentError(message: "Connection string contains an unescaped @ symbol")
411+
let userInfoAndHosts = userHostsAndAuthDB[0].split(separator: "@", omittingEmptySubsequences: false)
412+
if userInfoAndHosts.count > 2 {
413+
throw MongoError.InvalidArgumentError(
414+
message: "The user information and host information in the connection string must not contain"
415+
+ " unescaped @ symbols"
416+
)
405417
}
406418

407-
// do not omit empty subsequences to include an empty password
408-
let userInfo = userAndHost.count == 2 ?
409-
userAndHost[0].split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) : nil
410-
if let userInfo = userInfo {
419+
// Parse user information if present and set the hosts string.
420+
let hostsString: Substring
421+
if userInfoAndHosts.count == 2 {
422+
let userInfo = userInfoAndHosts[0].split(separator: ":", omittingEmptySubsequences: false)
423+
if userInfo.count > 2 {
424+
throw MongoError.InvalidArgumentError(
425+
message: "Username and password in the connection string must not contain unescaped colons"
426+
)
427+
}
411428
var credential = MongoCredential()
412429
credential.username = try userInfo[0].getValidatedUserInfo(forKey: "username")
413430
if userInfo.count == 2 {
@@ -416,11 +433,16 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
416433
// If no other authentication options or defaultAuthDB were provided, we should use "admin" as the
417434
// credential source. This will be overwritten later if a defaultAuthDB or an authSource is provided.
418435
credential.source = "admin"
436+
// Overwrite the sourceFromAuthSource field to false as the source is a default.
437+
credential.sourceFromAuthSource = false
419438
self.credential = credential
439+
hostsString = userInfoAndHosts[1]
440+
} else {
441+
hostsString = userInfoAndHosts[0]
420442
}
421443

422-
let hostString = userInfo != nil ? userAndHost[1] : userAndHost[0]
423-
let hosts = try hostString.components(separatedBy: ",").map(HostIdentifier.init)
444+
// Parse host information.
445+
let hosts = try hostsString.components(separatedBy: ",").map(HostIdentifier.init)
424446
if case .srv = scheme {
425447
guard hosts.count == 1 else {
426448
throw MongoError.InvalidArgumentError(
@@ -432,45 +454,44 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
432454
message: "A port cannot be specified in a mongodb+srv connection string"
433455
)
434456
}
457+
guard hosts[0].host.filter({ $0 == "." }).count >= 2 else {
458+
throw MongoError.InvalidArgumentError(
459+
message: "The host specified in a mongodb+srv connection string must contain a host name, a domain"
460+
+ " name, and a top-level domain"
461+
)
462+
}
435463
}
436464
self.scheme = scheme
437465
self.hosts = hosts
438466

439-
guard identifiersAndOptions.count == 2 else {
440-
// no auth DB or options were specified
441-
try self.validate()
442-
return
443-
}
444-
445-
let authDatabaseAndOptions = identifiersAndOptions[1].components(separatedBy: "?")
446-
guard authDatabaseAndOptions.count <= 2 else {
447-
throw MongoError.InvalidArgumentError(message: "Connection string contains an unescaped question mark")
448-
}
449-
if !authDatabaseAndOptions[0].isEmpty {
450-
let decoded = try authDatabaseAndOptions[0].getPercentDecoded(forKey: "defaultAuthDB")
467+
// Parse the defaultAuthDB if present.
468+
if userHostsAndAuthDB.count == 2 && !userHostsAndAuthDB[1].isEmpty {
469+
let defaultAuthDB = try userHostsAndAuthDB[1].getPercentDecoded(forKey: "defaultAuthDB")
451470
for character in Self.forbiddenDBCharacters {
452-
if decoded.contains(character) {
471+
if defaultAuthDB.contains(character) {
453472
throw MongoError.InvalidArgumentError(
454473
message: "defaultAuthDB contains invalid character: \(character)"
455474
)
456475
}
457476
}
458-
self.defaultAuthDB = decoded
477+
self.defaultAuthDB = defaultAuthDB
459478
// If no other authentication options were provided, we should use the defaultAuthDB as the credential
460479
// source. This will be overwritten later if an authSource is provided.
461480
if self.credential == nil {
462481
self.credential = MongoCredential()
463482
}
464-
self.credential?.source = decoded
483+
self.credential?.source = defaultAuthDB
484+
// Overwrite the sourceFromAuthSource field to false as the source is a default.
485+
self.credential?.sourceFromAuthSource = false
465486
}
466487

467-
guard authDatabaseAndOptions.count == 2 else {
468-
// no options were specified
488+
// Return early if no options were specified.
489+
guard infoAndOptions.count == 2 else {
469490
try self.validate()
470491
return
471492
}
472493

473-
let options = try Options(authDatabaseAndOptions[1])
494+
let options = try Options(infoAndOptions[1])
474495

475496
// Validate and set compressors. This validation is only necessary for compressors provided in the URI string
476497
// and therefore is not included in the general validate method.
@@ -617,7 +638,7 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
617638
try self.validate()
618639
}
619640

620-
private mutating func validate() throws {
641+
internal mutating func validate() throws {
621642
func optionError(name: OptionName, violation: String) -> MongoError.InvalidArgumentError {
622643
MongoError.InvalidArgumentError(
623644
message: "Value for \(name) in the connection string must " + violation
@@ -717,6 +738,8 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
717738
?? self.defaultAuthDB
718739
?? "admin"
719740
self.credential?.source = defaultSource
741+
// Overwrite the sourceFromAuthSource field to false as the source is a default.
742+
self.credential?.sourceFromAuthSource = false
720743
}
721744
if self.credential?.mechanism != nil {
722745
// credential cannot be nil within the external conditional
@@ -931,7 +954,9 @@ public struct MongoConnectionString: Codable, LosslessStringConvertible {
931954
}
932955
return property
933956
}.joined(separator: ","))
934-
uri.appendOption(name: .authSource, option: self.credential?.source)
957+
if self.credential?.sourceFromAuthSource == true {
958+
uri.appendOption(name: .authSource, option: self.credential?.source)
959+
}
935960
uri.appendOption(name: .compressors, option: self.compressors?.map {
936961
switch $0._compressor {
937962
case let .zlib(level):

Sources/MongoSwift/MongoCredential.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import SwiftBSON
12
/// Represents an authentication credential.
23
public struct MongoCredential: Decodable, Equatable {
34
/// A string containing the username. For auth mechanisms that do not utilize a password, this may be the entire
@@ -8,7 +9,17 @@ public struct MongoCredential: Decodable, Equatable {
89
public var password: String?
910

1011
/// A string containing the authentication database.
11-
public var source: String?
12+
public var source: String? {
13+
didSet {
14+
self.sourceFromAuthSource = self.source != nil
15+
}
16+
}
17+
18+
/// Tracks whether the `source` was set manually (i.e. via an `authSource` or by setting the field's value) or by
19+
/// a default (i.e. via the `defaultAuthDB`, the `mechanism`'s default, or the default of "admin"). This
20+
/// information is necessary to determine whether the `authSource` field should be set when reconstructing a
21+
/// `MongoConnectionString`.
22+
internal var sourceFromAuthSource: Bool
1223

1324
/// The authentication mechanism. A nil value for this property indicates that a mechanism wasn't specified and
1425
/// that mechanism negotiation is required.
@@ -21,6 +32,16 @@ public struct MongoCredential: Decodable, Equatable {
2132
case username, password, source, mechanism, mechanismProperties = "mechanism_properties"
2233
}
2334

35+
public init(from decoder: Decoder) throws {
36+
let container = try decoder.container(keyedBy: CodingKeys.self)
37+
self.username = try container.decodeIfPresent(String.self, forKey: .username)
38+
self.password = try container.decodeIfPresent(String.self, forKey: .password)
39+
self.source = try container.decodeIfPresent(String.self, forKey: .source)
40+
self.sourceFromAuthSource = self.source != nil
41+
self.mechanism = try container.decodeIfPresent(Mechanism.self, forKey: .mechanism)
42+
self.mechanismProperties = try container.decodeIfPresent(BSONDocument.self, forKey: .mechanismProperties)
43+
}
44+
2445
public init(
2546
username: String? = nil,
2647
password: String? = nil,
@@ -31,6 +52,7 @@ public struct MongoCredential: Decodable, Equatable {
3152
self.username = username
3253
self.password = password
3354
self.source = source
55+
self.sourceFromAuthSource = self.source != nil
3456
self.mechanism = mechanism
3557
self.mechanismProperties = mechanismProperties
3658
}

Tests/MongoSwiftTests/AuthTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ final class AuthTests: MongoSwiftTestCase {
8989
connStringCredential.mechanismProperties = nil
9090
}
9191

92+
// this field is only relevant for rebuilding a connection string
93+
credential.sourceFromAuthSource = false
94+
connStringCredential.sourceFromAuthSource = false
95+
9296
// compare rest of non-document options normally
9397
expect(connStringCredential).to(equal(credential), description: testCase.description)
9498
}

Tests/MongoSwiftTests/DNSSeedlistTests.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ final class DNSSeedlistTests: MongoSwiftTestCase {
9090

9191
func runDNSSeedlistTests(_ tests: [(String, DNSSeedlistTestCase)]) throws {
9292
for (fileName, testCase) in tests {
93+
// TODO: SWIFT-1455: unskip these test
94+
if fileName == "loadBalanced-no-results.json" || fileName == "loadBalanced-true-multiple-hosts.json" {
95+
continue
96+
}
97+
9398
// this particular test case requires SSL is disabled. see DRIVERS-1324.
9499
let requiresTLS = fileName != "txt-record-with-overridden-ssl-option.json"
95100

@@ -138,7 +143,9 @@ final class DNSSeedlistTests: MongoSwiftTestCase {
138143
case "ssl":
139144
expect(connStrOptions["tls"]).to(equal(v))
140145
// these values are not returned as part of the options doc
141-
case "authSource", "auth_database":
146+
case "auth_database":
147+
expect(try client.connectionPool.getConnectionStringAuthDB()).to(equal(v.stringValue))
148+
case "authSource":
142149
expect(try client.connectionPool.getConnectionStringAuthSource()).to(equal(v.stringValue))
143150
case "user":
144151
expect(connStr.credential?.username).to(equal(v.stringValue))

Tests/MongoSwiftTests/MongoConnectionStringTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,4 +621,44 @@ final class ConnectionStringTests: MongoSwiftTestCase {
621621
expect(host.host).to(equal("256.1.2.3"))
622622
expect(host.type).to(equal(.hostname))
623623
}
624+
625+
func testAuthSourceInDescription() throws {
626+
// defaultAuthDB
627+
let connString1 = try MongoConnectionString(string: "mongodb://localhost:27017/test")
628+
expect(connString1.description).toNot(contain("authsource"))
629+
630+
// authSource
631+
let connString2 = try MongoConnectionString(string: "mongodb://localhost:27017/?authSource=test")
632+
expect(connString2.description).to(contain("authsource=test"))
633+
634+
// defaultAuthDB and authSource
635+
let connString3 = try MongoConnectionString(string: "mongodb://localhost:27017/admin?authSource=test")
636+
expect(connString3.description).to(contain("authsource=test"))
637+
638+
// set from options
639+
let options = MongoClientOptions(credential: MongoCredential(source: "test"))
640+
var connString4 = try MongoConnectionString(string: "mongodb://localhost:27017")
641+
try connString4.applyOptions(options)
642+
expect(connString4.description).to(contain("authsource=test"))
643+
644+
// set manually
645+
let credential1 = MongoCredential(username: "user", source: "test")
646+
var connString5 = try MongoConnectionString(string: "mongodb://localhost:27107")
647+
connString5.credential = credential1
648+
expect(connString5.description).to(contain("authsource=test"))
649+
650+
// field changed to value
651+
let credential2 = MongoCredential(username: "user")
652+
var connString6 = try MongoConnectionString(string: "mongodb://localhost:27017")
653+
connString6.credential = credential2
654+
connString6.credential?.source = "test"
655+
expect(connString6.description).to(contain("authsource=test"))
656+
657+
// field changed to nil
658+
let credential3 = MongoCredential(username: "user", source: "test")
659+
var connString7 = try MongoConnectionString(string: "mongodb://localhost:27107")
660+
connString7.credential = credential3
661+
connString7.credential?.source = nil
662+
expect(connString7.description).toNot(contain("authsource"))
663+
}
624664
}

0 commit comments

Comments
 (0)