use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConnectionConfig { pub id: String, pub name: String, pub db_type: DatabaseType, #[serde(default)] pub driver_profile: Option, #[serde(default)] pub driver_label: Option, #[serde(default)] pub url_params: Option, pub host: String, pub port: u16, pub username: String, pub password: String, pub database: Option, #[serde(default)] pub color: Option, #[serde(default)] pub ssh_enabled: bool, #[serde(default)] pub ssh_host: String, #[serde(default = "default_ssh_port")] pub ssh_port: u16, #[serde(default)] pub ssh_user: String, #[serde(default)] pub ssh_password: String, #[serde(default)] pub ssh_key_path: String, #[serde(default)] pub ssl: bool, } fn default_ssh_port() -> u16 { 22 } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum DatabaseType { Mysql, Postgres, Sqlite, Redis, #[serde(rename = "duckdb")] DuckDb, #[serde(rename = "clickhouse")] ClickHouse, #[serde(rename = "sqlserver")] SqlServer, #[serde(rename = "mongodb")] MongoDb, #[serde(rename = "oracle")] Oracle, } impl ConnectionConfig { pub fn connection_url(&self) -> String { self.connection_url_with_host(&self.host, self.port) } pub fn redacted_connection_url(&self) -> String { self.redacted_connection_url_with_host(&self.host, self.port) } pub fn redacted_connection_url_with_host(&self, host: &str, port: u16) -> String { let db_part = self .database .as_deref() .filter(|d| !d.is_empty()) .map(|d| format!("/{}", encode_url_part(d))) .unwrap_or_default(); let params = self.normalized_url_params(); match self.db_type { DatabaseType::Sqlite | DatabaseType::DuckDb => { format!("{}?mode=rwc", self.host) } DatabaseType::Redis => { let scheme = if self.ssl { "rediss" } else { "redis" }; format!("{scheme}://{host}:{port}/") } DatabaseType::Mysql => format!("mysql://{host}:{port}{db_part}?{params}"), DatabaseType::Postgres => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("postgres://{host}:{port}{db_part}{suffix}") } DatabaseType::ClickHouse => format!("http://{host}:{port}{db_part}"), DatabaseType::SqlServer => format!( "server=tcp:{host},{port};database={}", self.database.as_deref().unwrap_or("master") ), DatabaseType::MongoDb => format!("mongodb://{host}:{port}{db_part}"), DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"), } } pub fn connection_url_with_host(&self, host: &str, port: u16) -> String { let db_part = self .database .as_deref() .filter(|d| !d.is_empty()) .map(|d| format!("/{}", encode_url_part(d))) .unwrap_or_default(); let username = encode_url_part(&self.username); let password = encode_url_part(&self.password); let params = self.normalized_url_params(); match self.db_type { DatabaseType::Sqlite | DatabaseType::DuckDb => { format!("{}?mode=rwc", self.host) } DatabaseType::Redis => { let scheme = if self.ssl { "rediss" } else { "redis" }; if self.username.is_empty() && self.password.is_empty() { format!("{scheme}://{host}:{port}/") } else if self.username.is_empty() { format!("{scheme}://:{password}@{host}:{port}/") } else { format!("{scheme}://{username}:{password}@{host}:{port}/") } } DatabaseType::Mysql => format!( "mysql://{}:{}@{host}:{port}{db_part}?{params}", username, password ), DatabaseType::Postgres => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!( "postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password ) } DatabaseType::ClickHouse => format!("http://{host}:{port}{db_part}"), DatabaseType::SqlServer => format!( "server=tcp:{host},{port};user={};password={};database={}", self.username, self.password, self.database.as_deref().unwrap_or("master") ), DatabaseType::MongoDb => { if self.username.is_empty() { format!("mongodb://{host}:{port}{db_part}") } else { format!("mongodb://{username}:{password}@{host}:{port}{db_part}") } } DatabaseType::Oracle => { format!("oracle://{}:{}@{host}:{port}{db_part}", username, password) } } } fn normalized_url_params(&self) -> String { let value = self.url_params.as_deref().unwrap_or("").trim(); match self.db_type { DatabaseType::Mysql => { if value.is_empty() { "ssl-mode=preferred".to_string() } else if value.contains("ssl-mode=") { value.trim_start_matches('?').to_string() } else { format!("ssl-mode=preferred&{}", value.trim_start_matches('?')) } } DatabaseType::Postgres => value.trim_start_matches('?').to_string(), _ => value.trim_start_matches('?').to_string(), } } } fn encode_url_part(value: &str) -> String { utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() } #[cfg(test)] mod tests { use super::{ConnectionConfig, DatabaseType}; fn mysql_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig { ConnectionConfig { id: "id".to_string(), name: "name".to_string(), db_type: DatabaseType::Mysql, driver_profile: None, driver_label: None, url_params: None, host: "10.1.2.3".to_string(), port: 2883, username: username.to_string(), password: password.to_string(), database: database.map(str::to_string), color: None, ssh_enabled: false, ssh_host: String::new(), ssh_port: 22, ssh_user: String::new(), ssh_password: String::new(), ssh_key_path: String::new(), ssl: false, } } #[test] fn mysql_url_encodes_oceanbase_username() { let config = mysql_config("user@tenant#cluster", "secret", None); assert_eq!( config.connection_url(), "mysql://user%40tenant%23cluster:secret@10.1.2.3:2883?ssl-mode=preferred" ); } #[test] fn mysql_url_encodes_password_and_database() { let config = mysql_config("root", "p@ss:word#1", Some("db/name")); assert_eq!( config.connection_url(), "mysql://root:p%40ss%3Aword%231@10.1.2.3:2883/db%2Fname?ssl-mode=preferred" ); } #[test] fn mysql_url_appends_custom_params() { let mut config = mysql_config("root", "secret", Some("test")); config.url_params = Some("charset=utf8mb4".to_string()); assert_eq!( config.connection_url(), "mysql://root:secret@10.1.2.3:2883/test?ssl-mode=preferred&charset=utf8mb4" ); } #[test] fn postgres_url_appends_custom_params() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("sslmode=disable".to_string()); assert_eq!( config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=disable" ); } #[test] fn redacted_mysql_url_omits_credentials() { let config = mysql_config("user@tenant#cluster", "p@ss:word#1", Some("db/name")); let url = config.redacted_connection_url(); assert_eq!(url, "mysql://10.1.2.3:2883/db%2Fname?ssl-mode=preferred"); assert!(!url.contains("user")); assert!(!url.contains("p%40ss")); assert!(!url.contains("p@ss")); } #[test] fn redacted_sqlserver_url_omits_credentials() { let mut config = mysql_config("sa", "super-secret", Some("master")); config.db_type = DatabaseType::SqlServer; let url = config.redacted_connection_url(); assert_eq!(url, "server=tcp:10.1.2.3,2883;database=master"); assert!(!url.contains("sa")); assert!(!url.contains("super-secret")); } #[test] fn redacted_redis_url_omits_credentials_and_keeps_tls_scheme() { let mut config = mysql_config("default", "redis-secret", None); config.db_type = DatabaseType::Redis; config.ssl = true; let url = config.redacted_connection_url(); assert_eq!(url, "rediss://10.1.2.3:2883/"); assert!(!url.contains("default")); assert!(!url.contains("redis-secret")); } }