Skip to main content

reinhardt_conf/settings/
database_config.rs

1//! Database configuration for settings
2//!
3//! This module provides the `DatabaseConfig` struct and its methods for
4//! configuring database connections in Reinhardt settings files.
5
6use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fmt;
10
11use crate::settings::secret_types::SecretString;
12
13/// Characters that must be percent-encoded in URL userinfo components.
14/// RFC 3986 Section 3.2.1 defines userinfo = *( unreserved / pct-encoded / sub-delims / ":" )
15/// We encode everything except unreserved characters to be safe.
16const USERINFO_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
17	.remove(b'-')
18	.remove(b'.')
19	.remove(b'_')
20	.remove(b'~');
21
22/// Database configuration
23#[non_exhaustive]
24#[derive(Clone, Serialize, Deserialize)]
25pub struct DatabaseConfig {
26	/// Database engine/backend
27	pub engine: String,
28
29	/// Database name or path
30	pub name: String,
31
32	/// Database user (if applicable)
33	pub user: Option<String>,
34
35	/// Database password (if applicable) - stored as `SecretString` to prevent accidental exposure
36	pub password: Option<SecretString>,
37
38	/// Database host (if applicable)
39	pub host: Option<String>,
40
41	/// Database port (if applicable)
42	pub port: Option<u16>,
43
44	/// Additional options
45	pub options: HashMap<String, String>,
46}
47
48impl fmt::Debug for DatabaseConfig {
49	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50		f.debug_struct("DatabaseConfig")
51			.field("engine", &self.engine)
52			.field("name", &self.name)
53			.field("user", &self.user)
54			.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
55			.field("host", &self.host)
56			.field("port", &self.port)
57			.field("options", &self.options)
58			.finish()
59	}
60}
61
62impl DatabaseConfig {
63	/// Create a new database configuration with the given engine and name
64	///
65	/// # Examples
66	///
67	/// ```
68	/// use reinhardt_conf::settings::DatabaseConfig;
69	///
70	/// let db = DatabaseConfig::new("reinhardt.db.backends.sqlite3", "myapp.db");
71	/// assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
72	/// assert_eq!(db.name, "myapp.db");
73	/// ```
74	pub fn new(engine: impl Into<String>, name: impl Into<String>) -> Self {
75		Self {
76			engine: engine.into(),
77			name: name.into(),
78			user: None,
79			password: None,
80			host: None,
81			port: None,
82			options: HashMap::new(),
83		}
84	}
85
86	/// Set the user for this database configuration
87	pub fn with_user(mut self, user: impl Into<String>) -> Self {
88		self.user = Some(user.into());
89		self
90	}
91
92	/// Set the password for this database configuration
93	pub fn with_password(mut self, password: impl Into<String>) -> Self {
94		self.password = Some(SecretString::new(password.into()));
95		self
96	}
97
98	/// Set the host for this database configuration
99	pub fn with_host(mut self, host: impl Into<String>) -> Self {
100		self.host = Some(host.into());
101		self
102	}
103
104	/// Set the port for this database configuration
105	pub fn with_port(mut self, port: u16) -> Self {
106		self.port = Some(port);
107		self
108	}
109
110	/// Create a SQLite database configuration
111	///
112	/// # Examples
113	///
114	/// ```
115	/// use reinhardt_conf::settings::DatabaseConfig;
116	///
117	/// let db = DatabaseConfig::sqlite("myapp.db");
118	///
119	/// assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
120	/// assert_eq!(db.name, "myapp.db");
121	/// assert!(db.user.is_none());
122	/// assert!(db.password.is_none());
123	/// ```
124	pub fn sqlite(name: impl Into<String>) -> Self {
125		Self {
126			engine: "reinhardt.db.backends.sqlite3".to_string(),
127			name: name.into(),
128			user: None,
129			password: None,
130			host: None,
131			port: None,
132			options: HashMap::new(),
133		}
134	}
135	/// Create a PostgreSQL database configuration
136	///
137	/// # Examples
138	///
139	/// ```
140	/// use reinhardt_conf::settings::DatabaseConfig;
141	///
142	/// let db = DatabaseConfig::postgresql("mydb", "admin", "password123", "localhost", 5432);
143	///
144	/// assert_eq!(db.engine, "reinhardt.db.backends.postgresql");
145	/// assert_eq!(db.name, "mydb");
146	/// assert_eq!(db.user, Some("admin".to_string()));
147	/// assert_eq!(db.password.as_ref().map(|p| p.expose_secret()), Some("password123"));
148	/// assert_eq!(db.host, Some("localhost".to_string()));
149	/// assert_eq!(db.port, Some(5432));
150	/// ```
151	pub fn postgresql(
152		name: impl Into<String>,
153		user: impl Into<String>,
154		password: impl Into<String>,
155		host: impl Into<String>,
156		port: u16,
157	) -> Self {
158		Self {
159			engine: "reinhardt.db.backends.postgresql".to_string(),
160			name: name.into(),
161			user: Some(user.into()),
162			password: Some(SecretString::new(password.into())),
163			host: Some(host.into()),
164			port: Some(port),
165			options: HashMap::new(),
166		}
167	}
168	/// Create a MySQL database configuration
169	///
170	/// # Examples
171	///
172	/// ```
173	/// use reinhardt_conf::settings::DatabaseConfig;
174	///
175	/// let db = DatabaseConfig::mysql("mydb", "root", "password123", "localhost", 3306);
176	///
177	/// assert_eq!(db.engine, "reinhardt.db.backends.mysql");
178	/// assert_eq!(db.name, "mydb");
179	/// assert_eq!(db.user, Some("root".to_string()));
180	/// assert_eq!(db.password.as_ref().map(|p| p.expose_secret()), Some("password123"));
181	/// assert_eq!(db.host, Some("localhost".to_string()));
182	/// assert_eq!(db.port, Some(3306));
183	/// ```
184	pub fn mysql(
185		name: impl Into<String>,
186		user: impl Into<String>,
187		password: impl Into<String>,
188		host: impl Into<String>,
189		port: u16,
190	) -> Self {
191		Self {
192			engine: "reinhardt.db.backends.mysql".to_string(),
193			name: name.into(),
194			user: Some(user.into()),
195			password: Some(SecretString::new(password.into())),
196			host: Some(host.into()),
197			port: Some(port),
198			options: HashMap::new(),
199		}
200	}
201
202	/// Convert `DatabaseConfig` to DATABASE_URL string
203	///
204	/// Credentials and query parameter values are percent-encoded per RFC 3986
205	/// to prevent URL injection and parsing errors from special characters.
206	///
207	/// # Examples
208	///
209	/// ```
210	/// use reinhardt_conf::settings::DatabaseConfig;
211	///
212	/// let db = DatabaseConfig::sqlite("db.sqlite3");
213	/// assert_eq!(db.to_url(), "sqlite:db.sqlite3");
214	///
215	/// let db = DatabaseConfig::postgresql("mydb", "user", "p@ss:word", "localhost", 5432);
216	/// assert_eq!(db.to_url(), "postgresql://user:p%40ss%3Aword@localhost:5432/mydb");
217	/// ```
218	pub fn to_url(&self) -> String {
219		// Determine the database scheme from engine
220		// Handle both short names (e.g., "sqlite") and full backend paths (e.g., "reinhardt.db.backends.sqlite3")
221		let scheme = if self.engine == "sqlite" || self.engine.contains("sqlite") {
222			"sqlite"
223		} else if self.engine == "postgresql"
224			|| self.engine == "postgres"
225			|| self.engine.contains("postgresql")
226			|| self.engine.contains("postgres")
227		{
228			"postgresql"
229		} else if self.engine == "mysql" || self.engine.contains("mysql") {
230			"mysql"
231		} else {
232			// Default to sqlite for unknown engines
233			"sqlite"
234		};
235
236		match scheme {
237			"sqlite" => {
238				if self.name == ":memory:" {
239					"sqlite::memory:".to_string()
240				} else {
241					// Use sqlite: format for relative paths (will be converted to absolute in connect_database)
242					// sqlite:/// is for absolute paths
243					use std::path::Path;
244					let path = Path::new(&self.name);
245					if path.is_absolute() {
246						// Absolute path: sqlite:///path/to/db.sqlite3
247						format!("sqlite:///{}", self.name)
248					} else {
249						// Relative path: sqlite:db.sqlite3 (will be converted to absolute in connect_database)
250						format!("sqlite:{}", self.name)
251					}
252				}
253			}
254			"postgresql" | "mysql" => {
255				let mut url = format!("{}://", scheme);
256
257				// Add user and password if available, percent-encoded per RFC 3986
258				if let Some(user) = &self.user {
259					let encoded_user = utf8_percent_encode(user, USERINFO_ENCODE_SET).to_string();
260					url.push_str(&encoded_user);
261					if let Some(password) = &self.password {
262						url.push(':');
263						let encoded_password =
264							utf8_percent_encode(password.expose_secret(), USERINFO_ENCODE_SET)
265								.to_string();
266						url.push_str(&encoded_password);
267					}
268					url.push('@');
269				}
270
271				// Add host (default to localhost if not specified)
272				let host = self.host.as_deref().unwrap_or("localhost");
273				url.push_str(host);
274
275				// Add port if available
276				if let Some(port) = self.port {
277					url.push(':');
278					url.push_str(&port.to_string());
279				}
280
281				// Add database name
282				url.push('/');
283				url.push_str(&self.name);
284
285				// Add query parameters if any, with percent-encoded values
286				if !self.options.is_empty() {
287					let mut query_parts = Vec::new();
288					for (key, value) in &self.options {
289						let encoded_key = utf8_percent_encode(key, USERINFO_ENCODE_SET).to_string();
290						let encoded_value =
291							utf8_percent_encode(value, USERINFO_ENCODE_SET).to_string();
292						query_parts.push(format!("{}={}", encoded_key, encoded_value));
293					}
294					url.push('?');
295					url.push_str(&query_parts.join("&"));
296				}
297
298				url
299			}
300			_ => format!("sqlite://{}", self.name),
301		}
302	}
303}
304
305impl fmt::Display for DatabaseConfig {
306	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307		// Display a sanitized representation that never exposes credentials
308		let scheme = if self.engine.contains("sqlite") {
309			"sqlite"
310		} else if self.engine.contains("postgresql") || self.engine.contains("postgres") {
311			"postgresql"
312		} else if self.engine.contains("mysql") {
313			"mysql"
314		} else {
315			"unknown"
316		};
317
318		match scheme {
319			"sqlite" => write!(f, "sqlite:{}", self.name),
320			_ => {
321				write!(f, "{}://", scheme)?;
322				if self.user.is_some() || self.password.is_some() {
323					write!(f, "***@")?;
324				}
325				if let Some(host) = &self.host {
326					write!(f, "{}", host)?;
327				}
328				if let Some(port) = self.port {
329					write!(f, ":{}", port)?;
330				}
331				write!(f, "/{}", self.name)
332			}
333		}
334	}
335}
336
337impl Default for DatabaseConfig {
338	fn default() -> Self {
339		Self::sqlite("db.sqlite3".to_string())
340	}
341}
342
343/// Recognized database URL schemes for connection validation.
344pub(crate) const VALID_DATABASE_SCHEMES: &[&str] = &[
345	"postgres://",
346	"postgresql://",
347	"sqlite://",
348	"sqlite:",
349	"mysql://",
350	"mariadb://",
351];
352
353/// Validate that a database URL starts with a recognized scheme.
354///
355/// Returns `Ok(())` if the URL starts with one of the supported schemes,
356/// or `Err` with a descriptive message listing the accepted schemes.
357pub(crate) fn validate_database_url_scheme(url: &str) -> Result<(), String> {
358	if VALID_DATABASE_SCHEMES.iter().any(|s| url.starts_with(s)) {
359		Ok(())
360	} else {
361		Err(format!(
362			"Invalid database URL: unrecognized scheme. Expected one of: {}",
363			VALID_DATABASE_SCHEMES.join(", ")
364		))
365	}
366}
367
368#[cfg(test)]
369mod tests {
370	use super::*;
371	use rstest::rstest;
372
373	#[rstest]
374	fn test_settings_db_config_sqlite() {
375		// Arrange
376		let db = DatabaseConfig::sqlite("test.db");
377
378		// Assert
379		assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
380		assert_eq!(db.name, "test.db");
381		assert!(db.user.is_none());
382		assert!(db.password.is_none());
383	}
384
385	#[rstest]
386	fn test_settings_db_config_postgresql() {
387		// Arrange
388		let db = DatabaseConfig::postgresql("testdb", "user", "pass", "localhost", 5432);
389
390		// Assert
391		assert_eq!(db.engine, "reinhardt.db.backends.postgresql");
392		assert_eq!(db.name, "testdb");
393		assert_eq!(db.user, Some("user".to_string()));
394		assert_eq!(
395			db.password.as_ref().map(|p| p.expose_secret()),
396			Some("pass")
397		);
398		assert_eq!(db.port, Some(5432));
399	}
400
401	#[rstest]
402	fn test_debug_output_redacts_password() {
403		// Arrange
404		let db = DatabaseConfig::postgresql("testdb", "user", "s3cr3t!", "localhost", 5432);
405
406		// Act
407		let debug_output = format!("{:?}", db);
408
409		// Assert
410		assert!(!debug_output.contains("s3cr3t!"));
411		assert!(debug_output.contains("[REDACTED]"));
412	}
413
414	#[rstest]
415	fn test_debug_output_without_password() {
416		// Arrange
417		let db = DatabaseConfig::sqlite("test.db");
418
419		// Act
420		let debug_output = format!("{:?}", db);
421
422		// Assert
423		assert!(debug_output.contains("None"));
424		assert!(debug_output.contains("DatabaseConfig"));
425	}
426
427	#[rstest]
428	fn test_to_url_encodes_special_chars_in_username() {
429		// Arrange
430		let mut db = DatabaseConfig::postgresql("mydb", "user@domain", "pass", "localhost", 5432);
431		db.user = Some("user@domain".to_string());
432
433		// Act
434		let url = db.to_url();
435
436		// Assert
437		assert!(url.contains("user%40domain"));
438		assert!(!url.contains("user@domain:"));
439	}
440
441	#[rstest]
442	fn test_to_url_encodes_special_chars_in_password() {
443		// Arrange
444		let db = DatabaseConfig::postgresql("mydb", "user", "p@ss:w/rd#", "localhost", 5432);
445
446		// Act
447		let url = db.to_url();
448
449		// Assert
450		assert!(url.contains("p%40ss%3Aw%2Frd%23"));
451		assert!(!url.contains("p@ss:w/rd#"));
452	}
453
454	#[rstest]
455	fn test_to_url_prevents_host_injection() {
456		// Arrange - malicious username that attempts to redirect to a different host
457		let db = DatabaseConfig::postgresql(
458			"mydb",
459			"admin@evil.com:9999/fake",
460			"pass",
461			"localhost",
462			5432,
463		);
464
465		// Act
466		let url = db.to_url();
467
468		// Assert - the @ in username should be encoded, preventing host injection
469		assert!(url.contains("admin%40evil.com%3A9999%2Ffake"));
470		assert!(url.contains("@localhost:5432"));
471	}
472
473	#[rstest]
474	fn test_to_url_encodes_query_parameter_values() {
475		// Arrange
476		let mut db = DatabaseConfig::postgresql("mydb", "user", "pass", "localhost", 5432);
477		db.options
478			.insert("sslmode".to_string(), "require&inject=true".to_string());
479
480		// Act
481		let url = db.to_url();
482
483		// Assert
484		assert!(url.contains("require%26inject%3Dtrue"));
485		assert!(!url.contains("require&inject=true"));
486	}
487
488	#[rstest]
489	fn test_to_url_simple_credentials() {
490		// Arrange
491		let db = DatabaseConfig::postgresql("mydb", "user", "pass", "localhost", 5432);
492
493		// Act
494		let url = db.to_url();
495
496		// Assert
497		assert_eq!(url, "postgresql://user:pass@localhost:5432/mydb");
498	}
499
500	#[rstest]
501	fn test_display_output_masks_credentials() {
502		// Arrange
503		let db = DatabaseConfig::postgresql("mydb", "admin", "s3cr3t!", "db.example.com", 5432);
504
505		// Act
506		let display_output = format!("{}", db);
507
508		// Assert
509		assert!(!display_output.contains("admin"));
510		assert!(!display_output.contains("s3cr3t!"));
511		assert!(display_output.contains("***@"));
512		assert!(display_output.contains("db.example.com"));
513		assert!(display_output.contains("mydb"));
514	}
515
516	#[rstest]
517	fn test_display_output_sqlite() {
518		// Arrange
519		let db = DatabaseConfig::sqlite("app.db");
520
521		// Act
522		let display_output = format!("{}", db);
523
524		// Assert
525		assert_eq!(display_output, "sqlite:app.db");
526	}
527
528	#[rstest]
529	fn test_password_stored_as_secret_string() {
530		// Arrange
531		let db = DatabaseConfig::postgresql("mydb", "user", "my-secret-pw", "localhost", 5432);
532
533		// Act
534		let password = db.password.as_ref().unwrap();
535
536		// Assert
537		assert_eq!(password.expose_secret(), "my-secret-pw");
538		// Display should not reveal the password
539		assert_eq!(format!("{}", password), "[REDACTED]");
540	}
541
542	#[rstest]
543	#[case("postgres://localhost/db")]
544	#[case("postgresql://user:pass@localhost:5432/db")]
545	#[case("sqlite::memory:")]
546	#[case("sqlite:///path/to/db")]
547	#[case("mysql://root@localhost/db")]
548	#[case("mariadb://root@localhost/db")]
549	fn test_valid_database_url_schemes(#[case] url: &str) {
550		// Act / Assert
551		assert!(validate_database_url_scheme(url).is_ok());
552	}
553
554	#[rstest]
555	#[case("http://localhost/db")]
556	#[case("ftp://localhost/db")]
557	#[case("redis://localhost")]
558	#[case("")]
559	#[case("not-a-url")]
560	fn test_invalid_database_url_schemes(#[case] url: &str) {
561		// Act
562		let result = validate_database_url_scheme(url);
563
564		// Assert
565		assert!(result.is_err());
566		assert!(result.unwrap_err().contains("Invalid database URL"));
567	}
568}