Skip to main content

toasty_core/driver/
connection_url.rs

1use crate::{Error, Result};
2use fluent_uri::IriRef;
3use percent_encoding::percent_decode_str;
4use std::{borrow::Cow, ops::Range, path::PathBuf};
5
6/// A parsed database connection URL.
7///
8/// Connection URLs use the common `<scheme>:<target>?<query>` form, but the
9/// target is interpreted by the selected database driver. Network drivers use
10/// the conventional `//user:password@host:port/path` target. File-backed
11/// drivers use [`file_path`](Self::file_path), which treats `//` as an optional
12/// marker and keeps the following text as the path.
13///
14/// This distinction allows connection strings such as `sqlite://todos.db` and
15/// `sqlite://:memory:` without interpreting `todos.db` or `:memory:` as a host.
16#[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    /// Parses a database connection URL.
24    ///
25    /// This validates the scheme. Drivers validate the target and query
26    /// parameters they support.
27    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    /// Returns the connection URL exactly as supplied.
43    pub fn as_str(&self) -> &'a str {
44        self.value
45    }
46
47    /// Returns the URL scheme without the trailing colon.
48    pub fn scheme(&self) -> &'a str {
49        &self.value[..self.scheme_end]
50    }
51
52    /// Returns `true` when the URL uses `expected`.
53    ///
54    /// URL schemes are ASCII case-insensitive.
55    pub fn has_scheme(&self, expected: &str) -> bool {
56        self.scheme().eq_ignore_ascii_case(expected)
57    }
58
59    /// Returns the path component of an authority-based connection URL.
60    ///
61    /// For `postgresql://localhost/mydb`, this returns `/mydb`. Use
62    /// [`file_path`](Self::file_path) for SQLite and other file-backed drivers.
63    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    /// Returns the percent-decoded path component.
75    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    /// Returns the file path named by a file-backed connection URL.
82    ///
83    /// The optional `//` after the scheme is discarded, a query string is not
84    /// part of the path, and `#` remains a normal filename character. The path
85    /// is percent-decoded before it is returned.
86    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    /// Returns the percent-decoded username from the authority component.
104    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    /// Returns the percent-decoded password bytes from the authority component.
116    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    /// Returns the host from the authority component.
127    ///
128    /// Brackets around an IPv6 address are not included.
129    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    /// Returns the port from the authority component.
143    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    /// Validates the host and port syntax of an authority component.
153    ///
154    /// URLs without an authority component are valid.
155    pub fn validate_authority(&self) -> Result<()> {
156        self.host()?;
157        self.port()?;
158        Ok(())
159    }
160
161    /// Iterates over the percent-decoded query parameters.
162    ///
163    /// Query components use form encoding, so `+` decodes to a space.
164    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    /// Returns the URL with an authority password replaced by `***`.
171    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}