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