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