toasty_core/driver/
connection_url.rs1use crate::{Error, Result};
2use fluent_uri::IriRef;
3use percent_encoding::percent_decode_str;
4use std::{borrow::Cow, ops::Range, path::PathBuf};
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub struct ConnectionUrl<'a> {
18 value: &'a str,
19 scheme_end: usize,
20}
21
22impl<'a> ConnectionUrl<'a> {
23 pub fn parse(value: &'a str) -> Result<Self> {
28 let scheme_end = value.find(':').ok_or_else(|| {
29 Error::invalid_connection_url(format!("connection URL has no scheme; url={value}"))
30 })?;
31 let scheme = &value[..scheme_end];
32
33 if !is_valid_scheme(scheme) {
34 return Err(Error::invalid_connection_url(format!(
35 "connection URL has an invalid scheme; url={value}"
36 )));
37 }
38
39 Ok(Self { value, scheme_end })
40 }
41
42 pub fn as_str(&self) -> &'a str {
44 self.value
45 }
46
47 pub fn scheme(&self) -> &'a str {
49 &self.value[..self.scheme_end]
50 }
51
52 pub fn has_scheme(&self, expected: &str) -> bool {
56 self.scheme().eq_ignore_ascii_case(expected)
57 }
58
59 pub fn path(&self) -> &'a str {
64 let rest = self.rest();
65 let path = if let Some(authority) = self.authority_range() {
66 &rest[authority.end..]
67 } else {
68 rest
69 };
70
71 split_before(path, &['?', '#'])
72 }
73
74 pub fn decoded_path(&self) -> Result<Cow<'a, str>> {
76 percent_decode_str(self.path())
77 .decode_utf8()
78 .map_err(|_| Error::invalid_connection_url("URL path is not valid UTF-8"))
79 }
80
81 pub fn file_path(&self) -> Result<PathBuf> {
87 let rest = self.rest();
88 let path = rest.strip_prefix("//").unwrap_or(rest);
89 let path = split_before(path, &['?']);
90
91 if path.is_empty() {
92 return Err(Error::invalid_connection_url(format!(
93 "connection URL does not name a database file; url={}",
94 self.value
95 )));
96 }
97
98 Ok(PathBuf::from(
99 percent_decode_str(path).decode_utf8_lossy().as_ref(),
100 ))
101 }
102
103 pub fn username(&self) -> Result<Option<Cow<'a, str>>> {
105 let Some(userinfo) = self.userinfo() else {
106 return Ok(None);
107 };
108 let username = userinfo.split_once(':').map_or(userinfo, |(name, _)| name);
109 percent_decode_str(username)
110 .decode_utf8()
111 .map(Some)
112 .map_err(|_| Error::invalid_connection_url("username is not valid UTF-8"))
113 }
114
115 pub fn password(&self) -> Option<Cow<'a, [u8]>> {
117 let (_, password) = self.userinfo()?.split_once(':')?;
118
119 if password.as_bytes().contains(&b'%') {
120 Some(Cow::Owned(percent_decode_str(password).collect()))
121 } else {
122 Some(Cow::Borrowed(password.as_bytes()))
123 }
124 }
125
126 pub fn host(&self) -> Result<Option<&'a str>> {
130 let Some(authority) = self.parsed_authority()? else {
131 return Ok(None);
132 };
133 let host = authority.host();
134 let host = host
135 .strip_prefix('[')
136 .and_then(|host| host.strip_suffix(']'))
137 .unwrap_or(host);
138
139 Ok((!host.is_empty()).then_some(host))
140 }
141
142 pub fn port(&self) -> Result<Option<u16>> {
144 let Some(authority) = self.parsed_authority()? else {
145 return Ok(None);
146 };
147 authority
148 .port_to_u16()
149 .map_err(|_| self.invalid_authority())
150 }
151
152 pub fn validate_authority(&self) -> Result<()> {
156 self.host()?;
157 self.port()?;
158 Ok(())
159 }
160
161 pub fn query_pairs(&self) -> impl Iterator<Item = (Cow<'a, str>, Cow<'a, str>)> + '_ {
165 self.query()
166 .into_iter()
167 .flat_map(|query| form_urlencoded::parse(query.as_bytes()))
168 }
169
170 pub fn redact_password(&self) -> Cow<'a, str> {
172 let Some(authority) = self.authority_range() else {
173 return Cow::Borrowed(self.value);
174 };
175 let authority_value = &self.rest()[authority.clone()];
176 let Some((userinfo, _)) = authority_value.rsplit_once('@') else {
177 return Cow::Borrowed(self.value);
178 };
179 let Some((username, _)) = userinfo.split_once(':') else {
180 return Cow::Borrowed(self.value);
181 };
182
183 let password_start = self.scheme_end + 1 + authority.start + username.len() + 1;
184 let password_end = self.scheme_end + 1 + authority.start + userinfo.len();
185 let mut redacted = String::with_capacity(self.value.len());
186 redacted.push_str(&self.value[..password_start]);
187 redacted.push_str("***");
188 redacted.push_str(&self.value[password_end..]);
189 Cow::Owned(redacted)
190 }
191
192 fn rest(&self) -> &'a str {
193 &self.value[self.scheme_end + 1..]
194 }
195
196 fn authority_range(&self) -> Option<Range<usize>> {
197 let rest = self.rest();
198 let authority = rest.strip_prefix("//")?;
199 let len = split_before(authority, &['/', '?', '#']).len();
200 Some(2..2 + len)
201 }
202
203 fn authority(&self) -> Option<&'a str> {
204 self.authority_range().map(|range| &self.rest()[range])
205 }
206
207 fn userinfo(&self) -> Option<&'a str> {
208 self.authority()?
209 .rsplit_once('@')
210 .map(|(userinfo, _)| userinfo)
211 }
212
213 fn parsed_authority(&self) -> Result<Option<fluent_uri::component::IAuthority<'a>>> {
214 let Some(range) = self.authority_range() else {
215 return Ok(None);
216 };
217 IriRef::parse(&self.rest()[..range.end])
218 .map_err(|_| self.invalid_authority())
219 .map(|url| url.authority())
220 }
221
222 fn query(&self) -> Option<&'a str> {
223 let rest = self.rest();
224 let query_start = rest.find('?')?;
225 if rest[..query_start].contains('#') {
226 return None;
227 }
228
229 Some(split_before(&rest[query_start + 1..], &['#']))
230 }
231
232 fn invalid_authority(&self) -> Error {
233 Error::invalid_connection_url(format!(
234 "connection URL has an invalid authority; url={}",
235 self.value
236 ))
237 }
238}
239
240fn is_valid_scheme(scheme: &str) -> bool {
241 let mut chars = scheme.chars();
242 matches!(chars.next(), Some(first) if first.is_ascii_alphabetic())
243 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '+' | '-' | '.'))
244}
245
246fn split_before<'a>(value: &'a str, delimiters: &[char]) -> &'a str {
247 value
248 .find(delimiters)
249 .map_or(value, |index| &value[..index])
250}