Skip to main content

rustlavel_db/
config.rs

1//! Connection settings, from a URL or from the application's configuration.
2
3use rustlavel_core::{Config, Error, Result};
4use std::time::Duration;
5
6#[derive(Debug, Clone)]
7pub struct DatabaseConfig {
8    /// Which database this points at: `postgres`, `mysql`, `sqlserver`.
9    pub driver: String,
10    pub host: String,
11    pub port: u16,
12    pub user: String,
13    pub password: String,
14    pub database: String,
15    pub application_name: String,
16    /// Connections kept open by the pool.
17    pub max_connections: usize,
18    pub connect_timeout: Duration,
19    /// How long a query may run before the driver gives up on it.
20    pub query_timeout: Duration,
21    /// Whether to encrypt the connection, and how much of the certificate to
22    /// believe. See [`crate::tls::TlsMode`] — the default asks for encryption
23    /// but accepts a server that declines, which guarantees nothing.
24    pub tls_mode: crate::tls::TlsMode,
25    /// A PEM file of trust anchors, for `verify-ca` and `verify-full` against a
26    /// private CA. `None` uses the public roots.
27    pub tls_root_certificate: Option<String>,
28    /// Credentials that may be replaced while the process runs, for a dynamic
29    /// account issued by a secret store. `None` means `user` and `password`
30    /// above are the whole story and never change.
31    pub credentials: Option<crate::credentials::Credentials>,
32}
33
34impl Default for DatabaseConfig {
35    fn default() -> Self {
36        DatabaseConfig {
37            driver: "postgres".into(),
38            host: "127.0.0.1".into(),
39            port: 5432,
40            user: "postgres".into(),
41            password: String::new(),
42            database: "postgres".into(),
43            application_name: "rustlavel".into(),
44            max_connections: 10,
45            connect_timeout: Duration::from_secs(10),
46            query_timeout: Duration::from_secs(30),
47            tls_mode: crate::tls::TlsMode::default(),
48            tls_root_certificate: None,
49            credentials: None,
50        }
51    }
52}
53
54impl DatabaseConfig {
55    /// Parse a database URL.
56    ///
57    /// The scheme chooses the driver: `postgres://`, `mysql://` or
58    /// `sqlserver://`. User, password and database are percent-decoded, so a
59    /// password with an `@` or `/` in it works without escaping anything twice.
60    pub fn from_url(url: &str) -> Result<Self> {
61        let (scheme, rest) = url.split_once("://").ok_or_else(|| {
62            Error::msg(format!(
63                "`{url}` has no scheme. Expected postgres://, mysql:// or sqlserver:// \
64                 followed by user:password@host:port/database"
65            ))
66        })?;
67
68        // The scheme names the database, and the default port follows from it,
69        // because nobody remembers 1433.
70        let (driver, default_port) = match scheme.to_ascii_lowercase().as_str() {
71            "postgres" | "postgresql" | "pgsql" => ("postgres", 5432),
72            "mysql" | "mariadb" => ("mysql", 3306),
73            "sqlserver" | "mssql" => ("sqlserver", 1433),
74            other => {
75                return Err(Error::msg(format!(
76                    "`{other}` is not a database this framework speaks. \
77                     Available schemes: postgres, mysql, sqlserver."
78                )));
79            }
80        };
81
82        let mut config = DatabaseConfig {
83            driver: driver.to_string(),
84            port: default_port,
85            ..DatabaseConfig::default()
86        };
87
88        // Split off the query string before anything else, so `?` inside it
89        // cannot be mistaken for part of the database name.
90        let (rest, query) = match rest.split_once('?') {
91            Some((rest, query)) => (rest, Some(query)),
92            None => (rest, None),
93        };
94
95        // The last `@` separates credentials from the host, which is what makes
96        // an `@` inside a password unambiguous.
97        let (credentials, host_part) = match rest.rsplit_once('@') {
98            Some((credentials, host)) => (Some(credentials), host),
99            None => (None, rest),
100        };
101
102        if let Some(credentials) = credentials {
103            let (user, password) = match credentials.split_once(':') {
104                Some((user, password)) => (user, password),
105                None => (credentials, ""),
106            };
107            if !user.is_empty() {
108                config.user = decode(user);
109            }
110            config.password = decode(password);
111        }
112
113        let (host, database) = match host_part.split_once('/') {
114            Some((host, database)) => (host, database),
115            None => (host_part, ""),
116        };
117
118        if !database.is_empty() {
119            config.database = decode(database);
120        }
121
122        if !host.is_empty() {
123            let (name, port) = match host.rsplit_once(':') {
124                Some((name, port)) => (name, Some(port)),
125                None => (host, None),
126            };
127            if !name.is_empty() {
128                config.host = name.to_string();
129            }
130            if let Some(port) = port {
131                config.port = port
132                    .parse()
133                    .map_err(|_| Error::msg(format!("`{port}` is not a valid port number")))?;
134            }
135        }
136
137        for (key, value) in query.into_iter().flat_map(|q| q.split('&')).filter_map(|p| p.split_once('='))
138        {
139            match key {
140                "application_name" => config.application_name = decode(value),
141                "max_connections" => {
142                    config.max_connections = value.parse().unwrap_or(config.max_connections)
143                }
144                "connect_timeout" => {
145                    if let Ok(seconds) = value.parse() {
146                        config.connect_timeout = Duration::from_secs(seconds);
147                    }
148                }
149                // An unreadable sslmode is an error rather than a shrug: the
150                // silent fallback would be a weaker mode than the one asked
151                // for, which is the wrong way round for a security setting.
152                "sslmode" | "ssl-mode" | "ssl_mode" => {
153                    config.tls_mode = crate::tls::TlsMode::parse(&decode(value))?
154                }
155                "sslrootcert" | "ssl-ca" | "ssl_ca" => {
156                    config.tls_root_certificate = Some(decode(value))
157                }
158                _ => {}
159            }
160        }
161
162        Ok(config)
163    }
164
165    /// This configuration with the credentials that are current *now*.
166    ///
167    /// Every driver calls this on the way into a connect rather than reading
168    /// `user` and `password` directly, which is the single point where a
169    /// rotation takes effect. Without it a rotated credential would sit in the
170    /// config being ignored.
171    pub fn resolved(&self) -> DatabaseConfig {
172        let Some(credentials) = &self.credentials else { return self.clone() };
173
174        let (user, password) = credentials.current();
175        DatabaseConfig { user, password, ..self.clone() }
176    }
177
178    /// Which generation of credentials a connection opened now belongs to.
179    ///
180    /// Zero when nothing rotates, so a pool holding static credentials never
181    /// retires anything.
182    pub fn generation(&self) -> u64 {
183        self.credentials.as_ref().map_or(0, |credentials| credentials.generation())
184    }
185
186    /// The dialect this configuration implies.
187    pub fn dialect(&self) -> Result<Box<dyn crate::dialect::Dialect>> {
188        crate::dialect::by_name(&self.driver)
189    }
190
191    /// Read from the application config, falling back to `DATABASE_URL`.
192    pub fn from_app_config(config: &Config) -> Result<Self> {
193        if let Some(url) = config.get("database.url").and_then(|v| v.as_str().map(str::to_string))
194            && !url.is_empty() {
195                return DatabaseConfig::from_url(&url);
196            }
197        if let Ok(url) = std::env::var("DATABASE_URL")
198            && !url.is_empty() {
199                return DatabaseConfig::from_url(&url);
200            }
201
202        let mut settings = DatabaseConfig {
203            driver: config.string("database.driver", "postgres"),
204            host: config.string("database.host", "127.0.0.1"),
205            port: config.int("database.port", 5432) as u16,
206            user: config.string("database.user", "postgres"),
207            password: config.string("database.password", ""),
208            database: config.string("database.name", "postgres"),
209            ..DatabaseConfig::default()
210        };
211        settings.max_connections = config.int("database.max_connections", 10).max(1) as usize;
212        settings.application_name = config.string("app.name", "rustlavel");
213        Ok(settings)
214    }
215
216    /// The URL with the password removed, for logs and error messages.
217    pub fn redacted_url(&self) -> String {
218        let password = if self.password.is_empty() { "" } else { ":***" };
219        format!(
220            "{}://{}{password}@{}:{}/{}",
221            self.driver, self.user, self.host, self.port, self.database
222        )
223    }
224}
225
226fn decode(value: &str) -> String {
227    if !value.contains('%') {
228        return value.to_string();
229    }
230
231    let bytes = value.as_bytes();
232    let mut out = Vec::with_capacity(bytes.len());
233    let mut index = 0;
234    while index < bytes.len() {
235        if bytes[index] == b'%' && index + 2 < bytes.len()
236            && let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
237                out.push(byte);
238                index += 3;
239                continue;
240            }
241        out.push(bytes[index]);
242        index += 1;
243    }
244    String::from_utf8_lossy(&out).into_owned()
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn parses_a_full_url() {
253        let config = DatabaseConfig::from_url("postgres://ada:hunter2@db.internal:6543/blog").unwrap();
254
255        assert_eq!(config.user, "ada");
256        assert_eq!(config.password, "hunter2");
257        assert_eq!(config.host, "db.internal");
258        assert_eq!(config.port, 6543);
259        assert_eq!(config.database, "blog");
260    }
261
262    #[test]
263    fn falls_back_to_defaults_for_missing_parts() {
264        let config = DatabaseConfig::from_url("postgres://localhost/blog").unwrap();
265
266        assert_eq!(config.host, "localhost");
267        assert_eq!(config.port, 5432);
268        assert_eq!(config.user, "postgres");
269        assert_eq!(config.database, "blog");
270    }
271
272    #[test]
273    fn a_password_may_contain_an_at_sign() {
274        let config = DatabaseConfig::from_url("postgres://ada:p%40ss@host/blog").unwrap();
275        assert_eq!(config.password, "p@ss");
276    }
277
278    #[test]
279    fn reads_query_parameters() {
280        let config =
281            DatabaseConfig::from_url("postgres://host/blog?application_name=worker&max_connections=25")
282                .unwrap();
283
284        assert_eq!(config.application_name, "worker");
285        assert_eq!(config.max_connections, 25);
286    }
287
288    #[test]
289    fn the_scheme_chooses_the_driver_and_its_default_port() {
290        for (url, driver, port) in [
291            ("postgres://host/blog", "postgres", 5432),
292            ("postgresql://host/blog", "postgres", 5432),
293            ("mysql://host/blog", "mysql", 3306),
294            ("mariadb://host/blog", "mysql", 3306),
295            ("sqlserver://host/blog", "sqlserver", 1433),
296            ("mssql://host/blog", "sqlserver", 1433),
297        ] {
298            let config = DatabaseConfig::from_url(url).unwrap();
299            assert_eq!(config.driver, driver, "for {url}");
300            assert_eq!(config.port, port, "for {url}");
301        }
302    }
303
304    #[test]
305    fn an_explicit_port_still_wins_over_the_default() {
306        assert_eq!(DatabaseConfig::from_url("mysql://host:3307/blog").unwrap().port, 3307);
307    }
308
309    #[test]
310    fn a_configuration_knows_its_dialect() {
311        for (url, dialect) in [
312            ("postgres://host/b", "postgres"),
313            ("mysql://host/b", "mysql"),
314            ("sqlserver://host/b", "sqlserver"),
315        ] {
316            assert_eq!(
317                DatabaseConfig::from_url(url).unwrap().dialect().unwrap().name(),
318                dialect
319            );
320        }
321    }
322
323    #[test]
324    fn an_unsupported_database_lists_the_ones_that_work() {
325        let error = DatabaseConfig::from_url("oracle://host/blog").unwrap_err().to_string();
326        assert!(error.contains("postgres, mysql, sqlserver"), "{error}");
327
328        let missing = DatabaseConfig::from_url("just-a-host/blog").unwrap_err().to_string();
329        assert!(missing.contains("has no scheme"), "{missing}");
330    }
331
332    #[test]
333    fn never_prints_the_password() {
334        for url in [
335            "postgres://ada:hunter2@host/blog",
336            "mysql://ada:hunter2@host/blog",
337            "sqlserver://ada:hunter2@host/blog",
338        ] {
339            let shown = DatabaseConfig::from_url(url).unwrap().redacted_url();
340
341            assert!(!shown.contains("hunter2"), "the password leaked into {shown}");
342            assert!(shown.contains("ada"));
343        }
344    }
345}