1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
//! Postgres connection-string shape helpers, shared between the pool's TLS
//! module (`db::tls`, feature `db`) and config validation (`config`, always
//! compiled).
//!
//! tokio-postgres accepts two syntaxes: `postgres://`/`postgresql://` URLs,
//! and libpq-style keyword/value strings (`host=db user=app sslmode=require`).
//! Both consumers must agree on how those are recognized — config validation
//! rejecting a string the TLS parser supports would make the keyword form
//! unreachable (issue #1585 review).
/// Whether tokio-postgres would parse this connection string as a URL.
/// Its `Config::from_str` only treats `postgres://`/`postgresql://`
/// prefixes as URLs; everything else is keyword/value syntax. A substring
/// check like `contains("://")` would send keyword strings whose values
/// embed URL-like tokens (`password=https://…`) down the URL path.
pub fn is_url(database_url: &str) -> bool {
database_url.starts_with("postgres://") || database_url.starts_with("postgresql://")
}
/// Parse a libpq-style `key = value` connection string into its pairs,
/// mirroring tokio-postgres's (private) connection-string parser:
/// whitespace is allowed around `=`, values may be single-quoted, and
/// `\` escapes the next character both inside and outside quotes
/// (`host=db sslmode = require sslrootcert='/pki/my ca.pem'`). Naive
/// whitespace splitting would miss `sslmode` in such strings and
/// silently downgrade the TLS posture.
///
/// Returns `None` for strings tokio-postgres itself would reject
/// (missing `=`, empty unquoted value, unterminated quote), so callers
/// keep the default path and surface tokio-postgres's own parse error
/// at connect time. Like tokio-postgres, parsing stops silently at an
/// empty keyword (e.g. a stray leading `=`).
pub fn keyword_value_pairs(s: &str) -> Option<Vec<(String, String)>> {
let mut pairs = Vec::new();
let mut it = s.chars().peekable();
loop {
while it.next_if(|c| c.is_whitespace()).is_some() {}
let mut key = String::new();
while let Some(&c) = it.peek() {
if c.is_whitespace() || c == '=' {
break;
}
key.push(c);
it.next();
}
if key.is_empty() {
return Some(pairs);
}
while it.next_if(|c| c.is_whitespace()).is_some() {}
if it.next() != Some('=') {
return None;
}
while it.next_if(|c| c.is_whitespace()).is_some() {}
let mut value = String::new();
if it.next_if_eq(&'\'').is_some() {
loop {
// `?`: EOF inside quotes (also right after `\`) is an
// unterminated value.
match it.next()? {
'\'' => break,
'\\' => value.push(it.next()?),
c => value.push(c),
}
}
} else {
while let Some(&c) = it.peek() {
if c.is_whitespace() {
break;
}
it.next();
if c == '\\' {
match it.next() {
Some(c2) => value.push(c2),
None => break,
}
} else {
value.push(c);
}
}
if value.is_empty() {
// `key=` followed by whitespace/EOF: tokio-postgres
// rejects the empty unquoted value (use `key=''`).
return None;
}
}
pairs.push((key, value));
}
}
/// Whether `s` is a plausible libpq-style keyword/value connection string —
/// the shape check config validation uses to accept the keyword form.
///
/// Stricter than [`keyword_value_pairs`] alone: the string must contain at
/// least one pair, and every keyword must look like a libpq parameter name
/// (`[A-Za-z][A-Za-z0-9_]*` — `host`, `dbname`, `sslmode`, …). The key-shape
/// check keeps mistyped URL schemes (`mysql://db/app?a=b`, whose whole
/// prefix would otherwise parse as one weird keyword) rejected with the
/// clear config error instead of failing later at connect time.
pub fn is_keyword_value(s: &str) -> bool {
keyword_value_pairs(s).is_some_and(|pairs| {
!pairs.is_empty()
&& pairs.iter().all(|(key, _)| {
let mut chars = key.chars();
chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn url_prefixes_are_urls() {
assert!(is_url("postgres://u@h/db"));
assert!(is_url("postgresql://u@h/db"));
assert!(!is_url("host=db user=u"));
assert!(!is_url("mysql://u@h/db"));
}
#[test]
fn keyword_form_is_accepted() {
for s in [
"host=db user=app dbname=app",
"host=db sslmode=require",
"host=db sslmode = require",
"host=db password='p w' sslmode='verify-full'",
"host=db password=https://looks-like-a-url sslmode=require",
] {
assert!(is_keyword_value(s), "must accept keyword form: {s}");
}
}
#[test]
fn garbage_is_rejected() {
for s in [
"",
" ",
"not a connection string",
"localhost",
"mysql://localhost/db",
"mysql://localhost/db?a=b",
"host=db port", // trailing junk without `=`
"host=", // empty unquoted value
"host='unterminated", // unterminated quote
] {
assert!(!is_keyword_value(s), "must reject: {s:?}");
}
}
}