1use 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
14const USERINFO_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
18 .remove(b'-')
19 .remove(b'.')
20 .remove(b'_')
21 .remove(b'~');
22
23#[settings(fragment = true, default_policy = "required")]
25#[non_exhaustive]
26#[derive(Clone, Serialize, Deserialize)]
27pub struct DatabaseConfig {
28 pub engine: String,
30
31 pub name: String,
33
34 #[setting(optional)]
36 pub user: Option<String>,
37
38 #[setting(optional)]
40 pub password: Option<SecretString>,
41
42 #[setting(optional)]
44 pub host: Option<String>,
45
46 #[setting(optional)]
48 pub port: Option<u16>,
49
50 #[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 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 pub fn with_user(mut self, user: impl Into<String>) -> Self {
96 self.user = Some(user.into());
97 self
98 }
99
100 pub fn with_password(mut self, password: impl Into<String>) -> Self {
102 self.password = Some(SecretString::new(password.into()));
103 self
104 }
105
106 pub fn with_host(mut self, host: impl Into<String>) -> Self {
108 self.host = Some(host.into());
109 self
110 }
111
112 pub fn with_port(mut self, port: u16) -> Self {
114 self.port = Some(port);
115 self
116 }
117
118 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 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 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 pub fn to_url(&self) -> String {
227 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 "sqlite"
242 };
243
244 match scheme {
245 "sqlite" => {
246 if self.name == ":memory:" {
247 "sqlite::memory:".to_string()
248 } else {
249 use std::path::Path;
252 let path = Path::new(&self.name);
253 if path.is_absolute() {
254 format!("sqlite:///{}", self.name)
256 } else {
257 format!("sqlite:{}", self.name)
259 }
260 }
261 }
262 "postgresql" | "mysql" => {
263 let mut url = format!("{}://", scheme);
264
265 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 let host = self.host.as_deref().unwrap_or("localhost");
281 url.push_str(host);
282
283 if let Some(port) = self.port {
285 url.push(':');
286 url.push_str(&port.to_string());
287 }
288
289 url.push('/');
291 url.push_str(&self.name);
292
293 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 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
351pub const VALID_DATABASE_SCHEMES: &[&str] = &[
353 "postgres://",
354 "postgresql://",
355 "sqlite://",
356 "sqlite:",
357 "mysql://",
358 "mariadb://",
359];
360
361pub 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 let db = DatabaseConfig::sqlite("test.db");
386
387 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 let db = DatabaseConfig::postgresql("testdb", "user", "pass", "localhost", 5432);
399
400 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 Some("pass")
408 );
409 assert_eq!(db.port, Some(5432));
410 }
411
412 #[rstest]
413 fn test_debug_output_redacts_password() {
414 let db = DatabaseConfig::postgresql("testdb", "user", "s3cr3t!", "localhost", 5432);
417
418 let debug_output = format!("{:?}", db);
420
421 assert!(!debug_output.contains("s3cr3t!"));
423 assert!(debug_output.contains("[REDACTED]"));
424 }
425
426 #[rstest]
427 fn test_debug_output_without_password() {
428 let db = DatabaseConfig::sqlite("test.db");
430
431 let debug_output = format!("{:?}", db);
433
434 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 let mut db = DatabaseConfig::postgresql("mydb", "user@domain", "pass", "localhost", 5432);
444 db.user = Some("user@domain".to_string());
445
446 let url = db.to_url();
448
449 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 let db = DatabaseConfig::postgresql("mydb", "user", "p@ss:w/rd#", "localhost", 5432);
459
460 let url = db.to_url();
462
463 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 let db = DatabaseConfig::postgresql(
473 "mydb",
474 "admin@evil.com:9999/fake",
475 "pass",
476 "localhost",
477 5432,
478 );
479
480 let url = db.to_url();
482
483 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 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 let url = db.to_url();
498
499 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 let db = DatabaseConfig::postgresql("mydb", "user", "pass", "localhost", 5432);
509
510 let url = db.to_url();
512
513 assert_eq!(url, "postgresql://user:pass@localhost:5432/mydb");
515 }
516
517 #[rstest]
518 fn test_display_output_masks_credentials() {
519 let db = DatabaseConfig::postgresql("mydb", "admin", "s3cr3t!", "db.example.com", 5432);
522
523 let display_output = format!("{}", db);
525
526 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 let db = DatabaseConfig::sqlite("app.db");
538
539 let display_output = format!("{}", db);
541
542 assert_eq!(display_output, "sqlite:app.db");
544 }
545
546 #[rstest]
547 fn test_password_stored_as_secret_string() {
548 let db = DatabaseConfig::postgresql("mydb", "user", "my-secret-pw", "localhost", 5432);
551
552 let password = db.password.as_ref().unwrap();
554
555 assert_eq!(password.expose_secret(), "my-secret-pw");
557 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 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 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 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 let result = validate_database_url_scheme(url);
631
632 assert!(result.is_err());
634 assert!(result.unwrap_err().contains("Invalid database URL"));
635 }
636}