use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
use reinhardt_core::macros::settings;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use crate::settings::secret_types::SecretString;
const USERINFO_ENCODE_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'.')
.remove(b'_')
.remove(b'~');
#[settings(fragment = true, default_policy = "required")]
#[non_exhaustive]
#[derive(Clone, Serialize, Deserialize)]
pub struct DatabaseConfig {
pub engine: String,
pub name: String,
#[setting(optional)]
pub user: Option<String>,
#[setting(optional)]
pub password: Option<SecretString>,
#[setting(optional)]
pub host: Option<String>,
#[setting(optional)]
pub port: Option<u16>,
#[setting(optional)]
#[serde(default)]
pub options: HashMap<String, String>,
}
impl fmt::Debug for DatabaseConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DatabaseConfig")
.field("engine", &self.engine)
.field("name", &self.name)
.field("user", &self.user)
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.field("host", &self.host)
.field("port", &self.port)
.field("options", &self.options)
.finish()
}
}
impl DatabaseConfig {
pub fn new(engine: impl Into<String>, name: impl Into<String>) -> Self {
Self {
engine: engine.into(),
name: name.into(),
user: None,
password: None,
host: None,
port: None,
options: HashMap::new(),
}
}
pub fn with_user(mut self, user: impl Into<String>) -> Self {
self.user = Some(user.into());
self
}
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = Some(SecretString::new(password.into()));
self
}
pub fn with_host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn sqlite(name: impl Into<String>) -> Self {
Self {
engine: "reinhardt.db.backends.sqlite3".to_string(),
name: name.into(),
user: None,
password: None,
host: None,
port: None,
options: HashMap::new(),
}
}
pub fn postgresql(
name: impl Into<String>,
user: impl Into<String>,
password: impl Into<String>,
host: impl Into<String>,
port: u16,
) -> Self {
Self {
engine: "reinhardt.db.backends.postgresql".to_string(),
name: name.into(),
user: Some(user.into()),
password: Some(SecretString::new(password.into())),
host: Some(host.into()),
port: Some(port),
options: HashMap::new(),
}
}
pub fn mysql(
name: impl Into<String>,
user: impl Into<String>,
password: impl Into<String>,
host: impl Into<String>,
port: u16,
) -> Self {
Self {
engine: "reinhardt.db.backends.mysql".to_string(),
name: name.into(),
user: Some(user.into()),
password: Some(SecretString::new(password.into())),
host: Some(host.into()),
port: Some(port),
options: HashMap::new(),
}
}
pub fn to_url(&self) -> String {
let scheme = if self.engine == "sqlite" || self.engine.contains("sqlite") {
"sqlite"
} else if self.engine == "postgresql"
|| self.engine == "postgres"
|| self.engine.contains("postgresql")
|| self.engine.contains("postgres")
{
"postgresql"
} else if self.engine == "mysql" || self.engine.contains("mysql") {
"mysql"
} else {
"sqlite"
};
match scheme {
"sqlite" => {
if self.name == ":memory:" {
"sqlite::memory:".to_string()
} else {
use std::path::Path;
let path = Path::new(&self.name);
if path.is_absolute() {
format!("sqlite:///{}", self.name)
} else {
format!("sqlite:{}", self.name)
}
}
}
"postgresql" | "mysql" => {
let mut url = format!("{}://", scheme);
if let Some(user) = &self.user {
let encoded_user = utf8_percent_encode(user, USERINFO_ENCODE_SET).to_string();
url.push_str(&encoded_user);
if let Some(password) = &self.password {
url.push(':');
let encoded_password =
utf8_percent_encode(password.expose_secret(), USERINFO_ENCODE_SET)
.to_string();
url.push_str(&encoded_password);
}
url.push('@');
}
let host = self.host.as_deref().unwrap_or("localhost");
url.push_str(host);
if let Some(port) = self.port {
url.push(':');
url.push_str(&port.to_string());
}
url.push('/');
url.push_str(&self.name);
if !self.options.is_empty() {
let mut query_parts = Vec::new();
for (key, value) in &self.options {
let encoded_key = utf8_percent_encode(key, USERINFO_ENCODE_SET).to_string();
let encoded_value =
utf8_percent_encode(value, USERINFO_ENCODE_SET).to_string();
query_parts.push(format!("{}={}", encoded_key, encoded_value));
}
url.push('?');
url.push_str(&query_parts.join("&"));
}
url
}
_ => format!("sqlite://{}", self.name),
}
}
}
impl fmt::Display for DatabaseConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let scheme = if self.engine.contains("sqlite") {
"sqlite"
} else if self.engine.contains("postgresql") || self.engine.contains("postgres") {
"postgresql"
} else if self.engine.contains("mysql") {
"mysql"
} else {
"unknown"
};
match scheme {
"sqlite" => write!(f, "sqlite:{}", self.name),
_ => {
write!(f, "{}://", scheme)?;
if self.user.is_some() || self.password.is_some() {
write!(f, "***@")?;
}
if let Some(host) = &self.host {
write!(f, "{}", host)?;
}
if let Some(port) = self.port {
write!(f, ":{}", port)?;
}
write!(f, "/{}", self.name)
}
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self::sqlite("db.sqlite3".to_string())
}
}
pub const VALID_DATABASE_SCHEMES: &[&str] = &[
"postgres://",
"postgresql://",
"sqlite://",
"sqlite:",
"mysql://",
"mariadb://",
];
pub fn validate_database_url_scheme(url: &str) -> Result<(), String> {
if VALID_DATABASE_SCHEMES.iter().any(|s| url.starts_with(s)) {
Ok(())
} else {
Err(format!(
"Invalid database URL: unrecognized scheme. Expected one of: {}",
VALID_DATABASE_SCHEMES.join(", ")
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
use serial_test::serial;
#[rstest]
fn test_settings_db_config_sqlite() {
let db = DatabaseConfig::sqlite("test.db");
assert_eq!(db.engine, "reinhardt.db.backends.sqlite3");
assert_eq!(db.name, "test.db");
assert!(db.user.is_none());
assert!(db.password.is_none());
}
#[rstest]
fn test_settings_db_config_postgresql() {
let db = DatabaseConfig::postgresql("testdb", "user", "pass", "localhost", 5432);
assert_eq!(db.engine, "reinhardt.db.backends.postgresql");
assert_eq!(db.name, "testdb");
assert_eq!(db.user, Some("user".to_string()));
assert_eq!(
db.password.as_ref().map(|p| p.expose_secret()),
Some("pass")
);
assert_eq!(db.port, Some(5432));
}
#[rstest]
fn test_debug_output_redacts_password() {
let db = DatabaseConfig::postgresql("testdb", "user", "s3cr3t!", "localhost", 5432);
let debug_output = format!("{:?}", db);
assert!(!debug_output.contains("s3cr3t!"));
assert!(debug_output.contains("[REDACTED]"));
}
#[rstest]
fn test_debug_output_without_password() {
let db = DatabaseConfig::sqlite("test.db");
let debug_output = format!("{:?}", db);
assert!(debug_output.contains("None"));
assert!(debug_output.contains("DatabaseConfig"));
}
#[rstest]
fn test_to_url_encodes_special_chars_in_username() {
let mut db = DatabaseConfig::postgresql("mydb", "user@domain", "pass", "localhost", 5432);
db.user = Some("user@domain".to_string());
let url = db.to_url();
assert!(url.contains("user%40domain"));
assert!(!url.contains("user@domain:"));
}
#[rstest]
fn test_to_url_encodes_special_chars_in_password() {
let db = DatabaseConfig::postgresql("mydb", "user", "p@ss:w/rd#", "localhost", 5432);
let url = db.to_url();
assert!(url.contains("p%40ss%3Aw%2Frd%23"));
assert!(!url.contains("p@ss:w/rd#"));
}
#[rstest]
fn test_to_url_prevents_host_injection() {
let db = DatabaseConfig::postgresql(
"mydb",
"admin@evil.com:9999/fake",
"pass",
"localhost",
5432,
);
let url = db.to_url();
assert!(url.contains("admin%40evil.com%3A9999%2Ffake"));
assert!(url.contains("@localhost:5432"));
}
#[rstest]
fn test_to_url_encodes_query_parameter_values() {
let mut db = DatabaseConfig::postgresql("mydb", "user", "pass", "localhost", 5432);
db.options
.insert("sslmode".to_string(), "require&inject=true".to_string());
let url = db.to_url();
assert!(url.contains("require%26inject%3Dtrue"));
assert!(!url.contains("require&inject=true"));
}
#[rstest]
fn test_to_url_simple_credentials() {
let db = DatabaseConfig::postgresql("mydb", "user", "pass", "localhost", 5432);
let url = db.to_url();
assert_eq!(url, "postgresql://user:pass@localhost:5432/mydb");
}
#[rstest]
fn test_display_output_masks_credentials() {
let db = DatabaseConfig::postgresql("mydb", "admin", "s3cr3t!", "db.example.com", 5432);
let display_output = format!("{}", db);
assert!(!display_output.contains("admin"));
assert!(!display_output.contains("s3cr3t!"));
assert!(display_output.contains("***@"));
assert!(display_output.contains("db.example.com"));
assert!(display_output.contains("mydb"));
}
#[rstest]
fn test_display_output_sqlite() {
let db = DatabaseConfig::sqlite("app.db");
let display_output = format!("{}", db);
assert_eq!(display_output, "sqlite:app.db");
}
#[rstest]
fn test_password_stored_as_secret_string() {
let db = DatabaseConfig::postgresql("mydb", "user", "my-secret-pw", "localhost", 5432);
let password = db.password.as_ref().unwrap();
assert_eq!(password.expose_secret(), "my-secret-pw");
assert_eq!(format!("{}", password), "[REDACTED]");
}
#[rstest]
#[serial(env)]
fn test_database_password_deserializes_from_secret_sources() {
let temp_file = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), "replica-secret\n").unwrap();
unsafe { std::env::set_var("REINHARDT_DEFAULT_DB_PASSWORD", "default-secret") };
let file_path = temp_file.path().to_string_lossy().replace('\\', "\\\\");
let toml = format!(
r#"
[default]
engine = "postgresql"
host = "localhost"
port = 5432
name = "app"
user = "app"
password = {{ env = "REINHARDT_DEFAULT_DB_PASSWORD" }}
[replica]
engine = "postgresql"
host = "replica.internal"
port = 5432
name = "app"
user = "readonly"
password = {{ file = "{}" }}
"#,
file_path
);
let databases: HashMap<String, DatabaseConfig> = toml::from_str(&toml).unwrap();
assert_eq!(
databases["default"]
.password
.as_ref()
.map(|password| password.expose_secret()),
Some("default-secret")
);
assert_eq!(
databases["replica"]
.password
.as_ref()
.map(|password| password.expose_secret()),
Some("replica-secret")
);
unsafe { std::env::remove_var("REINHARDT_DEFAULT_DB_PASSWORD") };
}
#[rstest]
#[case("postgres://localhost/db")]
#[case("postgresql://user:pass@localhost:5432/db")]
#[case("sqlite::memory:")]
#[case("sqlite:///path/to/db")]
#[case("mysql://root@localhost/db")]
#[case("mariadb://root@localhost/db")]
fn test_valid_database_url_schemes(#[case] url: &str) {
assert!(validate_database_url_scheme(url).is_ok());
}
#[rstest]
#[case("http://localhost/db")]
#[case("ftp://localhost/db")]
#[case("redis://localhost")]
#[case("")]
#[case("not-a-url")]
fn test_invalid_database_url_schemes(#[case] url: &str) {
let result = validate_database_url_scheme(url);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid database URL"));
}
}