Skip to content

Commit bb64b1e

Browse files
RUST-1765 Make slash between hosts and options in the URI optional (#1314)
1 parent 2b35455 commit bb64b1e

File tree

12 files changed

+281
-265
lines changed

12 files changed

+281
-265
lines changed

src/client/options.rs

Lines changed: 99 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,34 @@ impl ClientOptions {
13171317
}
13181318
}
13191319

1320+
/// Splits the string once on the first instance of the given delimiter. If the delimiter is not
1321+
/// present, returns the entire string as the "left" side.
1322+
///
1323+
/// e.g.
1324+
/// "abc.def" split on "." -> ("abc", Some("def"))
1325+
/// "ab.cd.ef" split on "." -> ("ab", Some("cd.ef"))
1326+
/// "abcdef" split on "." -> ("abcdef", None)
1327+
fn split_once_left<'a>(s: &'a str, delimiter: &str) -> (&'a str, Option<&'a str>) {
1328+
match s.split_once(delimiter) {
1329+
Some((l, r)) => (l, Some(r)),
1330+
None => (s, None),
1331+
}
1332+
}
1333+
1334+
/// Splits the string once on the last instance of the given delimiter. If the delimiter is not
1335+
/// present, returns the entire string as the "right" side.
1336+
///
1337+
/// e.g.
1338+
/// "abd.def" split on "." -> (Some("abc"), "def")
1339+
/// "ab.cd.ef" split on "." -> (Some("ab.cd"), "ef")
1340+
/// "abcdef" split on "." -> (None, "abcdef")
1341+
fn split_once_right<'a>(s: &'a str, delimiter: &str) -> (Option<&'a str>, &'a str) {
1342+
match s.rsplit_once(delimiter) {
1343+
Some((l, r)) => (Some(l), r),
1344+
None => (None, s),
1345+
}
1346+
}
1347+
13201348
/// Splits a string into a section before a given index and a section exclusively after the index.
13211349
/// Empty portions are returned as `None`.
13221350
fn exclusive_split_at(s: &str, i: usize) -> (Option<&str>, Option<&str>) {
@@ -1338,12 +1366,12 @@ fn percent_decode(s: &str, err_message: &str) -> Result<String> {
13381366
}
13391367
}
13401368

1341-
fn validate_userinfo(s: &str, userinfo_type: &str) -> Result<()> {
1369+
fn validate_and_parse_userinfo(s: &str, userinfo_type: &str) -> Result<String> {
13421370
if s.chars().any(|c| USERINFO_RESERVED_CHARACTERS.contains(&c)) {
1343-
return Err(ErrorKind::InvalidArgument {
1344-
message: format!("{} must be URL encoded", userinfo_type),
1345-
}
1346-
.into());
1371+
return Err(Error::invalid_argument(format!(
1372+
"{} must be URL encoded",
1373+
userinfo_type
1374+
)));
13471375
}
13481376

13491377
// All instances of '%' in the username must be part of an percent-encoded substring. This means
@@ -1352,13 +1380,13 @@ fn validate_userinfo(s: &str, userinfo_type: &str) -> Result<()> {
13521380
.skip(1)
13531381
.any(|part| part.len() < 2 || part[0..2].chars().any(|c| !c.is_ascii_hexdigit()))
13541382
{
1355-
return Err(ErrorKind::InvalidArgument {
1356-
message: "username/password cannot contain unescaped %".to_string(),
1357-
}
1358-
.into());
1383+
return Err(Error::invalid_argument(format!(
1384+
"{} cannot contain unescaped %",
1385+
userinfo_type
1386+
)));
13591387
}
13601388

1361-
Ok(())
1389+
percent_decode(s, &format!("{} must be URL encoded", userinfo_type))
13621390
}
13631391

13641392
impl TryFrom<&str> for ConnectionString {
@@ -1390,116 +1418,60 @@ impl ConnectionString {
13901418
/// malformed or one of the options has an invalid value, an error will be returned.
13911419
pub fn parse(s: impl AsRef<str>) -> Result<Self> {
13921420
let s = s.as_ref();
1393-
let end_of_scheme = match s.find("://") {
1394-
Some(index) => index,
1395-
None => {
1396-
return Err(ErrorKind::InvalidArgument {
1397-
message: "connection string contains no scheme".to_string(),
1398-
}
1399-
.into())
1400-
}
1421+
1422+
let Some((scheme, after_scheme)) = s.split_once("://") else {
1423+
return Err(Error::invalid_argument(
1424+
"connection string contains no scheme",
1425+
));
14011426
};
14021427

1403-
let srv = match &s[..end_of_scheme] {
1428+
let srv = match scheme {
14041429
"mongodb" => false,
1430+
#[cfg(feature = "dns-resolver")]
14051431
"mongodb+srv" => true,
1406-
_ => {
1407-
return Err(ErrorKind::InvalidArgument {
1408-
message: format!("invalid connection string scheme: {}", &s[..end_of_scheme]),
1409-
}
1410-
.into())
1432+
#[cfg(not(feature = "dns-resolver"))]
1433+
"mongodb+srv" => {
1434+
return Err(Error::invalid_argument(
1435+
"mongodb+srv connection strings cannot be used when the 'dns-resolver' \
1436+
feature is disabled",
1437+
))
14111438
}
1412-
};
1413-
#[cfg(not(feature = "dns-resolver"))]
1414-
if srv {
1415-
return Err(Error::invalid_argument(
1416-
"mongodb+srv connection strings cannot be used when the 'dns-resolver' feature is \
1417-
disabled",
1418-
));
1419-
}
1420-
1421-
let after_scheme = &s[end_of_scheme + 3..];
1422-
1423-
let (pre_slash, post_slash) = match after_scheme.find('/') {
1424-
Some(slash_index) => match exclusive_split_at(after_scheme, slash_index) {
1425-
(Some(section), o) => (section, o),
1426-
(None, _) => {
1427-
return Err(ErrorKind::InvalidArgument {
1428-
message: "missing hosts".to_string(),
1429-
}
1430-
.into())
1431-
}
1432-
},
1433-
None => {
1434-
if after_scheme.find('?').is_some() {
1435-
return Err(ErrorKind::InvalidArgument {
1436-
message: "Missing delimiting slash between hosts and options".to_string(),
1437-
}
1438-
.into());
1439-
}
1440-
(after_scheme, None)
1439+
other => {
1440+
return Err(Error::invalid_argument(format!(
1441+
"unsupported connection string scheme: {}",
1442+
other
1443+
)))
14411444
}
14421445
};
14431446

1444-
let (database, options_section) = match post_slash {
1445-
Some(section) => match section.find('?') {
1446-
Some(index) => exclusive_split_at(section, index),
1447-
None => (post_slash, None),
1448-
},
1449-
None => (None, None),
1450-
};
1451-
1452-
let db = match database {
1453-
Some(db) => {
1454-
let decoded = percent_decode(db, "database name must be URL encoded")?;
1455-
if decoded
1456-
.chars()
1457-
.any(|c| ILLEGAL_DATABASE_CHARACTERS.contains(&c))
1458-
{
1459-
return Err(ErrorKind::InvalidArgument {
1460-
message: "illegal character in database name".to_string(),
1461-
}
1462-
.into());
1463-
}
1464-
Some(decoded)
1465-
}
1466-
None => None,
1467-
};
1447+
let (pre_options, options) = split_once_left(after_scheme, "?");
1448+
let (user_info, hosts_and_auth_db) = split_once_right(pre_options, "@");
14681449

1469-
let (authentication_requested, cred_section, hosts_section) = match pre_slash.rfind('@') {
1470-
Some(index) => {
1471-
// if '@' is in the host section, it MUST be interpreted as a request for
1472-
// authentication, even if the credentials are empty.
1473-
let (creds, hosts) = exclusive_split_at(pre_slash, index);
1474-
match hosts {
1475-
Some(hs) => (true, creds, hs),
1476-
None => {
1477-
return Err(ErrorKind::InvalidArgument {
1478-
message: "missing hosts".to_string(),
1479-
}
1480-
.into())
1481-
}
1482-
}
1450+
// if '@' is in the host section, it MUST be interpreted as a request for authentication
1451+
let authentication_requested = user_info.is_some();
1452+
let (username, password) = match user_info {
1453+
Some(user_info) => {
1454+
let (username, password) = split_once_left(user_info, ":");
1455+
let username = if username.is_empty() {
1456+
None
1457+
} else {
1458+
Some(validate_and_parse_userinfo(username, "username")?)
1459+
};
1460+
let password = match password {
1461+
Some(password) => Some(validate_and_parse_userinfo(password, "password")?),
1462+
None => None,
1463+
};
1464+
(username, password)
14831465
}
1484-
None => (false, None, pre_slash),
1485-
};
1486-
1487-
let (username, password) = match cred_section {
1488-
Some(creds) => match creds.find(':') {
1489-
Some(index) => match exclusive_split_at(creds, index) {
1490-
(username, None) => (username, Some("")),
1491-
(username, password) => (username, password),
1492-
},
1493-
None => (Some(creds), None), // Lack of ":" implies whole string is username
1494-
},
14951466
None => (None, None),
14961467
};
14971468

1498-
let hosts = hosts_section
1499-
.split(',')
1469+
let (hosts, auth_db) = split_once_left(hosts_and_auth_db, "/");
1470+
1471+
let hosts = hosts
1472+
.split(",")
15001473
.map(ServerAddress::parse)
15011474
.collect::<Result<Vec<ServerAddress>>>()?;
1502-
15031475
let host_info = if !srv {
15041476
HostInfo::HostIdentifiers(hosts)
15051477
} else {
@@ -1527,17 +1499,32 @@ impl ConnectionString {
15271499
}
15281500
};
15291501

1502+
let db = match auth_db {
1503+
Some("") | None => None,
1504+
Some(db) => {
1505+
let decoded = percent_decode(db, "database name must be URL encoded")?;
1506+
for c in decoded.chars() {
1507+
if ILLEGAL_DATABASE_CHARACTERS.contains(&c) {
1508+
return Err(Error::invalid_argument(format!(
1509+
"illegal character in database name: {}",
1510+
c
1511+
)));
1512+
}
1513+
}
1514+
Some(decoded)
1515+
}
1516+
};
1517+
15301518
let mut conn_str = ConnectionString {
15311519
host_info,
15321520
#[cfg(test)]
15331521
original_uri: s.into(),
15341522
..Default::default()
15351523
};
15361524

1537-
let mut parts = if let Some(opts) = options_section {
1538-
conn_str.parse_options(opts)?
1539-
} else {
1540-
ConnectionStringParts::default()
1525+
let mut parts = match options {
1526+
Some(options) => conn_str.parse_options(options)?,
1527+
None => ConnectionStringParts::default(),
15411528
};
15421529

15431530
if conn_str.srv_service_name.is_some() && !srv {
@@ -1566,19 +1553,10 @@ impl ConnectionString {
15661553
}
15671554
}
15681555

1569-
// Set username and password.
1570-
if let Some(u) = username {
1556+
if let Some(username) = username {
15711557
let credential = conn_str.credential.get_or_insert_with(Default::default);
1572-
validate_userinfo(u, "username")?;
1573-
let decoded_u = percent_decode(u, "username must be URL encoded")?;
1574-
1575-
credential.username = Some(decoded_u);
1576-
1577-
if let Some(pass) = password {
1578-
validate_userinfo(pass, "password")?;
1579-
let decoded_p = percent_decode(pass, "password must be URL encoded")?;
1580-
credential.password = Some(decoded_p)
1581-
}
1558+
credential.username = Some(username);
1559+
credential.password = password;
15821560
}
15831561

15841562
if parts.auth_source.as_deref() == Some("") {

src/client/options/test.rs

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ static SKIPPED_TESTS: Lazy<Vec<&'static str>> = Lazy::new(|| {
2222
"maxPoolSize=0 does not error",
2323
#[cfg(not(feature = "cert-key-password"))]
2424
"Valid tlsCertificateKeyFilePassword is parsed correctly",
25+
// TODO RUST-1954: unskip these tests
26+
"Colon in a key value pair",
27+
"Comma in a key value pair causes a warning",
2528
];
2629

2730
// TODO RUST-1896: unskip this test when openssl-tls is enabled
@@ -72,11 +75,26 @@ struct TestAuth {
7275
}
7376

7477
impl TestAuth {
75-
fn matches_client_options(&self, options: &ClientOptions) -> bool {
78+
fn assert_matches_client_options(&self, options: &ClientOptions, description: &str) {
7679
let credential = options.credential.as_ref();
77-
self.username.as_ref() == credential.and_then(|cred| cred.username.as_ref())
78-
&& self.password.as_ref() == credential.and_then(|cred| cred.password.as_ref())
79-
&& self.db.as_ref() == options.default_database.as_ref()
80+
assert_eq!(
81+
self.username.as_ref(),
82+
credential.and_then(|c| c.username.as_ref()),
83+
"{}",
84+
description
85+
);
86+
assert_eq!(
87+
self.password.as_ref(),
88+
credential.and_then(|c| c.password.as_ref()),
89+
"{}",
90+
description
91+
);
92+
assert_eq!(
93+
self.db.as_ref(),
94+
options.default_database.as_ref(),
95+
"{}",
96+
description
97+
);
8098
}
8199
}
82100

@@ -177,7 +195,8 @@ async fn run_tests(path: &[&str], skipped_files: &[&str]) {
177195
}
178196

179197
if let Some(test_auth) = test_case.auth {
180-
assert!(test_auth.matches_client_options(&client_options));
198+
test_auth
199+
.assert_matches_client_options(&client_options, &test_case.description);
181200
}
182201
} else {
183202
let error = client_options_result.expect_err(&test_case.description);
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Connection String Tests
2+
3+
The YAML and JSON files in this directory tree are platform-independent tests that drivers can use to prove their
4+
conformance to the Connection String Spec.
5+
6+
As the spec is primarily concerned with parsing the parts of a URI, these tests do not focus on host and option
7+
validation. Where necessary, the tests use options known to be (un)supported by drivers to assert behavior such as
8+
issuing a warning on repeated option keys. As such these YAML tests are in no way a replacement for more thorough
9+
testing. However, they can provide an initial verification of your implementation.
10+
11+
## Version
12+
13+
Files in the "specifications" repository have no version scheme. They are not tied to a MongoDB server version.
14+
15+
## Format
16+
17+
Each YAML file contains an object with a single `tests` key. This key is an array of test case objects, each of which
18+
have the following keys:
19+
20+
- `description`: A string describing the test.
21+
- `uri`: A string containing the URI to be parsed.
22+
- `valid:` A boolean indicating if the URI should be considered valid.
23+
- `warning:` A boolean indicating whether URI parsing should emit a warning (independent of whether or not the URI is
24+
valid).
25+
- `hosts`: An array of host objects, each of which have the following keys:
26+
- `type`: A string denoting the type of host. Possible values are "ipv4", "ip_literal", "hostname", and "unix".
27+
Asserting the type is *optional*.
28+
- `host`: A string containing the parsed host.
29+
- `port`: An integer containing the parsed port number.
30+
- `auth`: An object containing the following keys:
31+
- `username`: A string containing the parsed username. For auth mechanisms that do not utilize a password, this may be
32+
the entire `userinfo` token (as discussed in [RFC 2396](https://www.ietf.org/rfc/rfc2396.txt)).
33+
- `password`: A string containing the parsed password.
34+
- `db`: A string containing the parsed authentication database. For legacy implementations that support namespaces
35+
(databases and collections) this may be the full namespace eg: `<db>.<coll>`
36+
- `options`: An object containing key/value pairs for each parsed query string option.
37+
38+
If a test case includes a null value for one of these keys (e.g. `auth: ~`, `port: ~`), no assertion is necessary. This
39+
both simplifies parsing of the test files (keys should always exist) and allows flexibility for drivers that might
40+
substitute default values *during* parsing (e.g. omitted `port` could be parsed as 27017).
41+
42+
The `valid` and `warning` fields are boolean in order to keep the tests flexible. We are not concerned with asserting
43+
the format of specific error or warnings messages strings.
44+
45+
### Use as unit tests
46+
47+
Testing whether a URI is valid or not should simply be a matter of checking whether URI parsing (or MongoClient
48+
construction) raises an error or exception. Testing for emitted warnings may require more legwork (e.g. configuring a
49+
log handler and watching for output).
50+
51+
Not all drivers may be able to directly assert the hosts, auth credentials, and options. Doing so may require exposing
52+
the driver's URI parsing component.
53+
54+
The file `valid-db-with-dotted-name.yml` is a special case for testing drivers that allow dotted namespaces, instead of
55+
only database names, in the Auth Database portion of the URI.

0 commit comments

Comments
 (0)