use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Serialize, PartialEq)] 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, skip_serializing_if = "Option::is_none")] pub visible_databases: Option>, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub attached_databases: Vec, #[serde(default)] pub color: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub transport_layers: Vec, #[serde(default = "default_connect_timeout_secs")] pub connect_timeout_secs: u64, #[serde(default = "default_query_timeout_secs")] pub query_timeout_secs: u64, #[serde(default = "default_idle_timeout_secs")] pub idle_timeout_secs: u64, #[serde(default = "default_keepalive_interval_secs")] pub keepalive_interval_secs: u64, #[serde(default)] pub ssl: bool, #[serde(default, skip_serializing_if = "String::is_empty")] pub ca_cert_path: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub client_cert_path: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub client_key_path: String, #[serde(default)] pub sysdba: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub oracle_connection_type: Option, #[serde(default)] pub connection_string: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub redis_connection_mode: Option, #[serde(default, skip_serializing_if = "String::is_empty")] pub redis_sentinel_master: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub redis_sentinel_nodes: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub redis_sentinel_username: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub redis_sentinel_password: String, #[serde(default, skip_serializing_if = "is_false")] pub redis_sentinel_tls: bool, #[serde(default, skip_serializing_if = "String::is_empty")] pub redis_cluster_nodes: String, #[serde(default = "default_redis_key_separator", skip_serializing_if = "is_default_redis_separator")] pub redis_key_separator: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub etcd_endpoints: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub gbase_server: String, /// Informix server name (INFORMIXSERVER). When empty, the agent /// derives it from the hostname. #[serde(default, skip_serializing_if = "String::is_empty")] pub informix_server: String, /// Typed configuration for external tabular sources. #[serde(default)] pub external_config: Option, #[serde(default)] pub jdbc_driver_class: Option, #[serde(default)] pub jdbc_driver_paths: Vec, #[serde(default, skip_serializing_if = "is_false")] pub one_time: bool, #[serde(default, skip_serializing_if = "is_false")] pub read_only: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "lowercase")] pub enum TransportLayerConfig { Ssh(SshTunnelConfig), Proxy(ProxyTunnelConfig), } impl TransportLayerConfig { pub fn id(&self) -> &str { match self { TransportLayerConfig::Ssh(layer) => &layer.id, TransportLayerConfig::Proxy(layer) => &layer.id, } } pub fn name(&self) -> &str { match self { TransportLayerConfig::Ssh(layer) => &layer.name, TransportLayerConfig::Proxy(layer) => &layer.name, } } pub fn enabled(&self) -> bool { match self { TransportLayerConfig::Ssh(layer) => layer.enabled, TransportLayerConfig::Proxy(layer) => layer.enabled, } } pub fn endpoint(&self) -> (&str, u16) { match self { TransportLayerConfig::Ssh(layer) => (&layer.host, layer.port), TransportLayerConfig::Proxy(layer) => (&layer.host, layer.port), } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct SshTunnelConfig { #[serde(default)] pub id: String, #[serde(default)] pub name: String, #[serde(default = "default_true")] pub enabled: bool, #[serde(default)] pub host: String, #[serde(default = "default_ssh_port")] pub port: u16, #[serde(default)] pub user: String, #[serde(default)] pub password: String, #[serde(default)] pub key_path: String, #[serde(default)] pub key_passphrase: String, #[serde(default = "default_ssh_connect_timeout_secs")] pub connect_timeout_secs: u64, #[serde(default)] pub expose_lan: bool, #[serde(default)] pub use_ssh_agent: bool, /// Custom SSH agent socket path (e.g. `~/.ssh/agent.sock`). /// When set and `use_ssh_agent` is true, this path is used instead of /// the `SSH_AUTH_SOCK` environment variable. #[serde(default)] pub ssh_agent_sock_path: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProxyTunnelConfig { #[serde(default)] pub id: String, #[serde(default)] pub name: String, #[serde(default = "default_true")] pub enabled: bool, #[serde(default)] pub proxy_type: ProxyType, #[serde(default)] pub host: String, #[serde(default = "default_proxy_port")] pub port: u16, #[serde(default)] pub username: String, #[serde(default)] pub password: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AttachedDatabaseConfig { pub name: String, pub path: String, } fn default_true() -> bool { true } fn default_ssh_port() -> u16 { 22 } pub fn default_ssh_connect_timeout_secs() -> u64 { 5 } pub fn default_connect_timeout_secs() -> u64 { 5 } pub fn default_query_timeout_secs() -> u64 { 30 } pub fn default_idle_timeout_secs() -> u64 { 60 } pub fn default_keepalive_interval_secs() -> u64 { 60 } fn default_proxy_port() -> u16 { 1080 } fn is_false(value: &bool) -> bool { !*value } pub fn default_redis_key_separator() -> String { ":".to_string() } fn is_default_redis_separator(value: &str) -> bool { value == ":" } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[derive(Default)] pub enum ProxyType { #[default] Socks5, Http, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum DatabaseType { Mysql, Postgres, Sqlite, Rqlite, Redis, #[serde(rename = "duckdb")] DuckDb, #[serde(rename = "clickhouse")] ClickHouse, #[serde(rename = "sqlserver")] SqlServer, #[serde(rename = "mongodb")] MongoDb, #[serde(rename = "oracle")] Oracle, #[serde(rename = "elasticsearch")] Elasticsearch, #[serde(rename = "qdrant")] Qdrant, #[serde(rename = "milvus")] Milvus, Doris, #[serde(rename = "starrocks")] StarRocks, #[serde(rename = "manticoresearch")] ManticoreSearch, Databend, Redshift, Dameng, Kingbase, Highgo, Vastbase, Goldendb, Gaussdb, Kwdb, Yashandb, Databricks, #[serde(rename = "saphana")] SapHana, Teradata, Vertica, Firebird, Exasol, #[serde(rename = "opengauss")] OpenGauss, #[serde(rename = "oceanbase-oracle")] OceanbaseOracle, Gbase, Access, #[serde(rename = "h2")] H2, Snowflake, Trino, Hive, #[serde(rename = "db2")] Db2, Informix, #[serde(rename = "neo4j")] Neo4j, Cassandra, #[serde(rename = "bigquery")] Bigquery, Kylin, Sundb, Tdengine, Xugu, Iotdb, Etcd, #[serde(rename = "iris")] Iris, #[serde(rename = "turso")] Turso, #[serde(rename = "influxdb")] InfluxDb, #[serde(rename = "questdb")] Questdb, Jdbc, /// Message queue admin connection (Pulsar / Kafka / RocketMQ). The specific /// system is determined by `external_config.systemKind`. #[serde(rename = "mq")] MessageQueue, } #[derive(Deserialize)] struct ConnectionConfigData { 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 visible_databases: Option>, #[serde(default)] pub attached_databases: Vec, #[serde(default)] pub color: Option, #[serde(default)] pub transport_layers: Vec, #[serde(default = "default_connect_timeout_secs")] pub connect_timeout_secs: u64, #[serde(default = "default_query_timeout_secs")] pub query_timeout_secs: u64, #[serde(default = "default_idle_timeout_secs")] pub idle_timeout_secs: u64, #[serde(default = "default_keepalive_interval_secs")] pub keepalive_interval_secs: u64, #[serde(default)] pub ssl: bool, #[serde(default)] pub ca_cert_path: String, #[serde(default)] pub client_cert_path: String, #[serde(default)] pub client_key_path: String, #[serde(default)] pub sysdba: bool, #[serde(default)] pub oracle_connection_type: Option, #[serde(default)] pub connection_string: Option, #[serde(default)] pub redis_connection_mode: Option, #[serde(default)] pub redis_sentinel_master: String, #[serde(default)] pub redis_sentinel_nodes: String, #[serde(default)] pub redis_sentinel_username: String, #[serde(default)] pub redis_sentinel_password: String, #[serde(default)] pub redis_sentinel_tls: bool, #[serde(default)] pub redis_cluster_nodes: String, #[serde(default = "default_redis_key_separator")] pub redis_key_separator: String, #[serde(default)] pub etcd_endpoints: String, #[serde(default)] pub gbase_server: String, #[serde(default)] pub informix_server: String, #[serde(default)] pub external_config: Option, #[serde(default)] pub jdbc_driver_class: Option, #[serde(default)] pub jdbc_driver_paths: Vec, #[serde(default)] pub one_time: bool, #[serde(default)] pub read_only: bool, } impl From for ConnectionConfig { fn from(data: ConnectionConfigData) -> Self { Self { id: data.id, name: data.name, db_type: data.db_type, driver_profile: data.driver_profile, driver_label: data.driver_label, url_params: data.url_params, host: data.host, port: data.port, username: data.username, password: data.password, database: data.database, visible_databases: data.visible_databases, attached_databases: data.attached_databases, color: data.color, transport_layers: data.transport_layers, connect_timeout_secs: data.connect_timeout_secs, query_timeout_secs: data.query_timeout_secs, idle_timeout_secs: data.idle_timeout_secs, keepalive_interval_secs: data.keepalive_interval_secs, ssl: data.ssl, ca_cert_path: data.ca_cert_path, client_cert_path: data.client_cert_path, client_key_path: data.client_key_path, sysdba: data.sysdba, oracle_connection_type: data.oracle_connection_type, connection_string: data.connection_string, redis_connection_mode: data.redis_connection_mode, redis_sentinel_master: data.redis_sentinel_master, redis_sentinel_nodes: data.redis_sentinel_nodes, redis_sentinel_username: data.redis_sentinel_username, redis_sentinel_password: data.redis_sentinel_password, redis_sentinel_tls: data.redis_sentinel_tls, redis_cluster_nodes: data.redis_cluster_nodes, redis_key_separator: data.redis_key_separator, etcd_endpoints: data.etcd_endpoints, gbase_server: data.gbase_server, informix_server: data.informix_server, external_config: data.external_config, jdbc_driver_class: data.jdbc_driver_class, jdbc_driver_paths: data.jdbc_driver_paths, one_time: data.one_time, read_only: data.read_only, } } } impl<'de> Deserialize<'de> for ConnectionConfig { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { let mut value = Value::deserialize(deserializer)?; migrate_legacy_transport_layers(&mut value); let data = ConnectionConfigData::deserialize(value).map_err(serde::de::Error::custom)?; Ok(data.into()) } } fn migrate_legacy_transport_layers(value: &mut Value) { let Some(object) = value.as_object_mut() else { return; }; if object.get("transport_layers").and_then(Value::as_array).is_some_and(|layers| !layers.is_empty()) { return; } let mut layers = Vec::new(); let ssh_enabled = object.get("ssh_enabled").and_then(Value::as_bool).unwrap_or(false); if ssh_enabled { if let Some(ssh_tunnels) = object.get("ssh_tunnels").and_then(Value::as_array) { for hop in ssh_tunnels { let mut layer = hop.clone(); if let Some(layer_object) = layer.as_object_mut() { layer_object.insert("type".to_string(), Value::String("ssh".to_string())); } layers.push(layer); } } if layers.is_empty() && string_field(object, "ssh_host").is_some() { let mut layer = serde_json::Map::new(); layer.insert("type".to_string(), Value::String("ssh".to_string())); layer.insert("id".to_string(), Value::String("legacy".to_string())); layer.insert("enabled".to_string(), Value::Bool(true)); copy_string(object, &mut layer, "ssh_host", "host"); copy_u64(object, &mut layer, "ssh_port", "port", default_ssh_port() as u64); copy_string(object, &mut layer, "ssh_user", "user"); copy_string(object, &mut layer, "ssh_password", "password"); copy_string(object, &mut layer, "ssh_key_path", "key_path"); copy_string(object, &mut layer, "ssh_key_passphrase", "key_passphrase"); copy_u64( object, &mut layer, "ssh_connect_timeout_secs", "connect_timeout_secs", default_ssh_connect_timeout_secs(), ); copy_bool(object, &mut layer, "ssh_expose_lan", "expose_lan"); layers.push(Value::Object(layer)); } } let proxy_enabled = object.get("proxy_enabled").and_then(Value::as_bool).unwrap_or(false); if proxy_enabled && string_field(object, "proxy_host").is_some() { let mut layer = serde_json::Map::new(); layer.insert("type".to_string(), Value::String("proxy".to_string())); layer.insert("id".to_string(), Value::String("legacy-proxy".to_string())); layer.insert("enabled".to_string(), Value::Bool(true)); copy_string(object, &mut layer, "proxy_type", "proxy_type"); copy_string(object, &mut layer, "proxy_host", "host"); copy_u64(object, &mut layer, "proxy_port", "port", default_proxy_port() as u64); copy_string(object, &mut layer, "proxy_username", "username"); copy_string(object, &mut layer, "proxy_password", "password"); layers.push(Value::Object(layer)); } if !layers.is_empty() { object.insert("transport_layers".to_string(), Value::Array(layers)); } } fn string_field(object: &serde_json::Map, key: &str) -> Option { object.get(key).and_then(Value::as_str).map(str::trim).filter(|value| !value.is_empty()).map(str::to_string) } fn copy_string( object: &serde_json::Map, target: &mut serde_json::Map, from: &str, to: &str, ) { if let Some(value) = object.get(from).and_then(Value::as_str) { target.insert(to.to_string(), Value::String(value.to_string())); } } fn copy_bool( object: &serde_json::Map, target: &mut serde_json::Map, from: &str, to: &str, ) { if let Some(value) = object.get(from).and_then(Value::as_bool) { target.insert(to.to_string(), Value::Bool(value)); } } fn copy_u64( object: &serde_json::Map, target: &mut serde_json::Map, from: &str, to: &str, default: u64, ) { let value = object.get(from).and_then(Value::as_u64).filter(|value| *value > 0).unwrap_or(default); target.insert(to.to_string(), Value::Number(value.into())); } impl ConnectionConfig { pub fn effective_transport_layers(&self) -> Vec { self.transport_layers.iter().filter(|layer| layer.enabled()).cloned().collect() } pub fn effective_ssh_tunnels(&self) -> Vec { self.effective_transport_layers() .into_iter() .filter_map(|layer| match layer { TransportLayerConfig::Ssh(ssh) => Some(ssh), TransportLayerConfig::Proxy(_) => None, }) .collect() } pub fn has_effective_transport_layers(&self) -> bool { !self.effective_transport_layers().is_empty() } pub fn has_effective_ssh_tunnels(&self) -> bool { self.effective_transport_layers().iter().any(|layer| matches!(layer, TransportLayerConfig::Ssh(_))) } pub fn effective_connect_timeout_secs(&self) -> u64 { if self.connect_timeout_secs == 0 { default_connect_timeout_secs() } else { self.connect_timeout_secs.clamp(1, 300) } } pub fn effective_query_timeout_secs(&self) -> u64 { if self.query_timeout_secs == 0 { 0 } else { self.query_timeout_secs.max(1) } } pub fn effective_database(&self) -> Option<&str> { self.database .as_deref() .map(str::trim) .filter(|database| !database.is_empty()) .or_else(|| self.default_database()) } fn default_database(&self) -> Option<&'static str> { match self.db_type { DatabaseType::Postgres => match self.driver_profile.as_deref() { Some("cockroachdb") => Some("defaultdb"), _ => Some("postgres"), }, DatabaseType::Redshift => Some("dev"), DatabaseType::ClickHouse => Some("default"), DatabaseType::Rqlite | DatabaseType::Turso => Some("main"), DatabaseType::Gaussdb | DatabaseType::OpenGauss => Some("postgres"), DatabaseType::Kwdb => Some("defaultdb"), DatabaseType::Kingbase | DatabaseType::Vastbase => Some("postgres"), DatabaseType::Highgo => Some("highgo"), DatabaseType::Yashandb => Some("yasdb"), DatabaseType::Firebird => Some("employee"), DatabaseType::H2 => Some("test"), DatabaseType::Informix => Some("sysmaster"), DatabaseType::Neo4j => Some("neo4j"), _ => None, } } pub fn needs_bare_mysql(&self) -> bool { matches!(self.db_type, DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch) || self.driver_profile.as_deref().map(|p| p.to_lowercase()).is_some_and(|p| { matches!(p.as_str(), "doris" | "starrocks" | "manticoresearch" | "selectdb" | "oceanbase") }) } pub fn canonicalized(&self) -> Self { let mut config = self.clone(); if config.db_type == DatabaseType::Mysql && config.driver_profile.as_deref().is_some_and(|profile| profile.eq_ignore_ascii_case("tdengine")) { config.db_type = DatabaseType::Tdengine; if config.port == 0 || config.port == 6030 { config.port = 6041; } config.driver_profile = Some("tdengine".to_string()); if config.driver_label.as_deref().unwrap_or("").trim().is_empty() { config.driver_label = Some("TDengine".to_string()); } } config } pub fn uses_redis_sentinel(&self) -> bool { self.db_type == DatabaseType::Redis && self.redis_connection_mode.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("sentinel")) } pub fn uses_redis_cluster(&self) -> bool { self.db_type == DatabaseType::Redis && self.redis_connection_mode.as_deref().is_some_and(|mode| mode.eq_ignore_ascii_case("cluster")) } pub fn redis_tls_insecure(&self) -> bool { self.db_type == DatabaseType::Redis && redis_url_params_enable_insecure(self.url_params.as_deref()) } 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 raw_host = host; let host = bracket_ipv6(host); let db_part = self.effective_database().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::Access => self.host.clone(), DatabaseType::Redis => { let scheme = if self.ssl { "rediss" } else { "redis" }; let fragment = self.redis_tls_insecure_fragment(); format!("{scheme}://{host}:{port}/{fragment}") } DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch | DatabaseType::Databend => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("mysql://{host}:{port}{db_part}{suffix}") } DatabaseType::Postgres | DatabaseType::Redshift => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("postgres://{host}:{port}{db_part}{suffix}") } DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port), DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port), DatabaseType::Turso => turso_http_url(self, raw_host, port), DatabaseType::SqlServer => { format!("server=tcp:{host},{port};database={}", self.database.as_deref().unwrap_or("master")) } DatabaseType::MongoDb => { let is_tunneled = host != self.host.as_str() || port != self.port; if let Some(cs) = self.connection_string.as_deref().filter(|s| !s.is_empty()) { let cs = normalize_mongo_uri_direct_connection(cs); if is_tunneled { return rewrite_mongo_uri_host(&cs, &host, port); } return cs; } let mut suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; if is_tunneled && !suffix.contains("directConnection=") { if suffix.is_empty() { suffix = "?directConnection=true".to_string(); } else { suffix.push_str("&directConnection=true"); } } format!("mongodb://{host}:{port}{db_part}{suffix}") } DatabaseType::Oracle => format!("oracle://{host}:{port}{db_part}"), DatabaseType::Elasticsearch | DatabaseType::Qdrant | DatabaseType::Milvus => { let scheme = if self.ssl { "https" } else { "http" }; format!("{scheme}://{host}:{port}") } DatabaseType::Dameng => format!("dm://{host}:{port}{db_part}"), DatabaseType::Kingbase => format!("kingbase://{host}:{port}{db_part}"), DatabaseType::Highgo => format!("highgo://{host}:{port}{db_part}"), DatabaseType::Vastbase => format!("vastbase://{host}:{port}{db_part}"), DatabaseType::Goldendb => format!("goldendb://{host}:{port}{db_part}"), DatabaseType::Gaussdb => format!("gaussdb://{host}:{port}{db_part}"), DatabaseType::Kwdb => format!("kwdb://{host}:{port}{db_part}"), DatabaseType::Yashandb => format!("yashandb://{host}:{port}{db_part}"), DatabaseType::Databricks => format!("databricks://{host}:{port}{db_part}"), DatabaseType::SapHana => format!("saphana://{host}:{port}{db_part}"), DatabaseType::Teradata => format!("teradata://{host}:{port}{db_part}"), DatabaseType::Vertica => format!("vertica://{host}:{port}{db_part}"), DatabaseType::Firebird => format!("firebird://{host}:{port}{db_part}"), DatabaseType::Exasol => format!("exasol://{host}:{port}{db_part}"), DatabaseType::OpenGauss => format!("opengauss://{host}:{port}{db_part}"), DatabaseType::OceanbaseOracle => { let base = format!("oceanbase-oracle://{host}:{port}{db_part}"); if params.is_empty() { base } else { format!("{base}?{params}") } } DatabaseType::Questdb => format!("questdb://{host}:{port}{db_part}"), DatabaseType::Gbase => format!("gbase://{host}:{port}{db_part}"), DatabaseType::H2 => format!("h2://{host}:{port}{db_part}"), DatabaseType::Snowflake => format!("snowflake://{host}/{db_part}"), DatabaseType::Trino => format!("trino://{host}:{port}{db_part}"), DatabaseType::Hive => format!("hive://{host}:{port}{db_part}"), DatabaseType::Db2 => format!("db2://{host}:{port}{db_part}"), DatabaseType::Informix => format!("informix://{host}:{port}{db_part}"), DatabaseType::Neo4j => format!("neo4j://{host}:{port}{db_part}"), DatabaseType::Cassandra => format!("cassandra://{host}:{port}{db_part}"), DatabaseType::Bigquery => format!("bigquery://{host}/{db_part}"), DatabaseType::Kylin => format!("kylin://{host}:{port}{db_part}"), DatabaseType::Sundb => format!("sundb://{host}:{port}{db_part}"), DatabaseType::Tdengine => format!("tdengine://{host}:{port}{db_part}"), DatabaseType::Xugu => format!("xugu://{host}:{port}{db_part}"), DatabaseType::Iotdb => { let base = format!("iotdb://{host}:{port}{db_part}"); if params.is_empty() { base } else { format!("{base}?{params}") } } DatabaseType::Etcd => { format!("etcd://{host}:{port}") } DatabaseType::Iris => format!("iris://{host}:{port}{db_part}"), DatabaseType::InfluxDb => { let scheme = if self.ssl { "https" } else { "http" }; format!("{scheme}://{host}:{port}") } DatabaseType::Jdbc => "jdbc:".to_string(), DatabaseType::MessageQueue => self.message_queue_admin_url(), } } pub fn connection_url_with_host(&self, host: &str, port: u16) -> String { let raw_host = host; let host = bracket_ipv6(host); let db_part = self.effective_database().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::Access => self.host.clone(), DatabaseType::Redis => { let scheme = if self.ssl { "rediss" } else { "redis" }; let fragment = self.redis_tls_insecure_fragment(); if self.username.is_empty() && self.password.is_empty() { format!("{scheme}://{host}:{port}/{fragment}") } else if self.username.is_empty() { format!("{scheme}://:{password}@{host}:{port}/{fragment}") } else { format!("{scheme}://{username}:{password}@{host}:{port}/{fragment}") } } DatabaseType::Mysql | DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch | DatabaseType::Databend => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("mysql://{}:{}@{host}:{port}{db_part}{suffix}", username, password) } DatabaseType::Postgres | DatabaseType::Redshift => { let suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; format!("postgres://{}:{}@{host}:{port}{db_part}{suffix}", username, password) } DatabaseType::ClickHouse => clickhouse_http_url(self, raw_host, port), DatabaseType::Rqlite => rqlite_http_url(self, raw_host, port), DatabaseType::Turso => turso_http_url(self, raw_host, port), DatabaseType::SqlServer => format!( "server=tcp:{host},{port};user={};password={};database={}", self.username, self.password, self.database.as_deref().unwrap_or("master") ), DatabaseType::MongoDb => { let is_tunneled = host != self.host.as_str() || port != self.port; if let Some(cs) = self.connection_string.as_deref().filter(|s| !s.is_empty()) { let cs = normalize_mongo_uri_direct_connection(cs); if is_tunneled { return rewrite_mongo_uri_host(&cs, &host, port); } return cs; } let mut suffix = if params.is_empty() { String::new() } else { format!("?{params}") }; if is_tunneled && !suffix.contains("directConnection=") { if suffix.is_empty() { suffix = "?directConnection=true".to_string(); } else { suffix.push_str("&directConnection=true"); } } if self.username.is_empty() { format!("mongodb://{host}:{port}{db_part}{suffix}") } else { format!("mongodb://{username}:{password}@{host}:{port}{db_part}{suffix}") } } DatabaseType::Oracle => { format!("oracle://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Elasticsearch | DatabaseType::Qdrant | DatabaseType::Milvus => { let scheme = if self.ssl { "https" } else { "http" }; format!("{scheme}://{host}:{port}") } DatabaseType::Dameng => { format!("dm://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Kingbase => { format!("kingbase://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Highgo => { format!("highgo://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Vastbase => { format!("vastbase://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Goldendb => { format!("goldendb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Gaussdb => { format!("gaussdb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Kwdb => { format!("kwdb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Yashandb => { format!("yashandb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Databricks => { format!("databricks://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::SapHana => { format!("saphana://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Teradata => { format!("teradata://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Vertica => { format!("vertica://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Firebird => { format!("firebird://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Exasol => { format!("exasol://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::OpenGauss => { format!("opengauss://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::OceanbaseOracle => { let base = format!("oceanbase-oracle://{}:{}@{host}:{port}{db_part}", username, password); if params.is_empty() { base } else { format!("{base}?{params}") } } DatabaseType::Questdb => { format!("questdb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Gbase => { format!("gbase://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::H2 => { format!("h2://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Snowflake => { format!("snowflake://{}:{}@{host}/{db_part}", username, password) } DatabaseType::Trino => { format!("trino://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Hive => { format!("hive://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Db2 => { format!("db2://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Informix => { format!("informix://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Neo4j => { format!("neo4j://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Cassandra => { format!("cassandra://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Bigquery => { format!("bigquery://{}:{}@{host}/{db_part}", username, password) } DatabaseType::Kylin => { format!("kylin://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Sundb => { format!("sundb://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Tdengine => { format!("tdengine://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Xugu => { format!("xugu://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::Iotdb => { let base = format!("iotdb://{}:{}@{host}:{port}{db_part}", username, password); if params.is_empty() { base } else { format!("{base}?{params}") } } DatabaseType::Etcd => { if self.username.is_empty() { format!("etcd://{host}:{port}") } else { format!("etcd://{}:{}@{host}:{port}", username, password) } } DatabaseType::Iris => { format!("iris://{}:{}@{host}:{port}{db_part}", username, password) } DatabaseType::InfluxDb => { let scheme = if self.ssl { "https" } else { "http" }; format!("{scheme}://{host}:{port}") } DatabaseType::Jdbc => { self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string() } DatabaseType::MessageQueue => self.message_queue_admin_url(), } } fn message_queue_admin_url(&self) -> String { self.external_config .as_ref() .and_then(|value| value.get("adminUrl").or_else(|| value.get("admin_url"))) .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("mq://") .to_string() } fn normalized_url_params(&self) -> String { let value = self.url_params.as_deref().unwrap_or("").trim(); if self.needs_bare_mysql() { return normalize_bare_mysql_url_params(value); } match self.db_type { DatabaseType::Mysql => { normalize_mysql_url_params(value, self.mysql_uses_tls(), self.ca_cert_path.trim().is_empty()) } DatabaseType::Doris | DatabaseType::StarRocks | DatabaseType::ManticoreSearch => { let params = normalize_bare_mysql_url_params(value); if params.is_empty() { "enable_cleartext_plugin=true".to_string() } else { format!("{params}&enable_cleartext_plugin=true") } } DatabaseType::Databend => normalize_bare_mysql_url_params(value), DatabaseType::Postgres | DatabaseType::Redshift => normalize_postgres_url_params(value, self.ssl), DatabaseType::MongoDb => normalize_mongo_url_params(value, self.ssl), _ => value.trim_start_matches('?').to_string(), } } pub fn clickhouse_uses_tls(&self) -> bool { self.ssl || url_params_contains_flag(self.url_params.as_deref(), "secure", "true") } fn mysql_uses_tls(&self) -> bool { self.ssl || self.host.to_ascii_lowercase().ends_with(".tidbcloud.com") } fn redis_tls_insecure_fragment(&self) -> &'static str { if self.ssl && self.redis_tls_insecure() { "#insecure" } else { "" } } } fn redis_url_params_enable_insecure(params: Option<&str>) -> bool { params.unwrap_or("").trim().trim_start_matches('?').split(['&', ';']).any(|part| { let part = part.trim(); if part.is_empty() { return false; } let Some((key, value)) = part.split_once('=') else { return part.eq_ignore_ascii_case("insecure"); }; let key = key.trim(); matches!(key.to_ascii_lowercase().as_str(), "insecure" | "tls_insecure" | "accept_invalid_certs") && matches!(value.trim().to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "insecure") }) } fn url_params_contains_flag(params: Option<&str>, key: &str, expected: &str) -> bool { params.unwrap_or("").trim().trim_start_matches('?').split(['&', ';']).filter_map(|part| part.split_once('=')).any( |(part_key, value)| part_key.trim().eq_ignore_ascii_case(key) && value.trim().eq_ignore_ascii_case(expected), ) } fn normalize_bare_mysql_url_params(value: &str) -> String { value .trim_start_matches('?') .split('&') .filter(|part| { !part.is_empty() && !url_param_key_is(part, "charset") && !url_param_key_is(part, "ssl-mode") && !url_param_key_is(part, "sslmode") && !url_param_key_is(part, "require_ssl") && !url_param_key_is(part, "verify_ca") && !url_param_key_is(part, "verify_identity") }) .collect::>() .join("&") } fn normalize_mysql_url_params(value: &str, force_tls: bool, accept_invalid_certs: bool) -> String { let value = value.trim_start_matches('?'); let mut parts: Vec = value.split('&').filter(|part| !part.is_empty()).map(str::to_string).collect(); if force_tls { parts.retain(|part| { !url_param_key_is(part, "ssl-mode") && !url_param_key_is(part, "sslmode") && !url_param_key_is(part, "require_ssl") }); parts.insert(0, "require_ssl=true".to_string()); if accept_invalid_certs && !parts.iter().any(|part| url_param_key_is(part, "verify_ca")) { parts.push("verify_ca=false".to_string()); } if !parts.iter().any(|part| url_param_key_is(part, "verify_identity")) { parts.push("verify_identity=false".to_string()); } } else if !parts.iter().any(|part| { url_param_key_is(part, "ssl-mode") || url_param_key_is(part, "sslmode") || url_param_key_is(part, "require_ssl") }) { parts.insert(0, "ssl-mode=preferred".to_string()); } if !parts.iter().any(|part| url_param_key_is(part, "charset")) { parts.push("charset=utf8mb4".to_string()); } parts.join("&") } fn normalize_mongo_url_params(value: &str, force_tls: bool) -> String { let value = value.trim_start_matches('?'); let mut parts: Vec = value.split('&').filter(|part| !part.is_empty()).map(str::to_string).collect(); if force_tls { parts.retain(|part| !url_param_key_is(part, "tls") && !url_param_key_is(part, "ssl")); parts.insert(0, "tls=true".to_string()); } parts.join("&") } fn normalize_mongo_uri_direct_connection(uri: &str) -> String { if !mongo_uri_has_multiple_seeds(uri) || !mongo_uri_has_direct_connection_true(uri) { return uri.to_string(); } let (before_fragment, fragment) = uri.split_once('#').map(|(base, fragment)| (base, Some(fragment))).unwrap_or((uri, None)); let Some((base, query)) = before_fragment.split_once('?') else { return uri.to_string(); }; let params = query.split('&').filter(|part| !mongo_url_param_is_direct_connection_true(part)).collect::>().join("&"); let mut normalized = if params.is_empty() { base.to_string() } else { format!("{base}?{params}") }; if let Some(fragment) = fragment { normalized.push('#'); normalized.push_str(fragment); } normalized } fn mongo_uri_has_multiple_seeds(uri: &str) -> bool { mongo_uri_host_section(uri) .map(|hosts| hosts.split(',').filter(|host| !host.trim().is_empty()).count() > 1) .unwrap_or(false) } fn mongo_uri_host_section(uri: &str) -> Option<&str> { let rest = uri.strip_prefix("mongodb://").or_else(|| uri.strip_prefix("mongodb+srv://"))?; let authority = rest.split('/').next()?.split('?').next().unwrap_or(rest); Some(match authority.rfind('@') { Some(idx) => &authority[idx + 1..], None => authority, }) } fn mongo_uri_has_direct_connection_true(uri: &str) -> bool { uri.split_once('?') .map(|(_, query)| { query.split('#').next().unwrap_or("").split('&').any(mongo_url_param_is_direct_connection_true) }) .unwrap_or(false) } fn mongo_url_param_is_direct_connection_true(part: &str) -> bool { let Some((key, value)) = part.split_once('=') else { return false; }; percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case("directConnection") && percent_decode_str(value).decode_utf8_lossy().eq_ignore_ascii_case("true") } fn normalize_postgres_url_params(value: &str, force_tls: bool) -> String { let value = value.trim_start_matches('?'); let mut timezone: Option = None; let mut search_path: Option = None; let mut parts: Vec = Vec::new(); for part in value.split('&').filter(|part| !part.is_empty()) { let (raw_key, raw_value) = part.split_once('=').unwrap_or((part, "")); let key = percent_decode_str(raw_key).decode_utf8_lossy(); if key.eq_ignore_ascii_case("timezone") || key.eq_ignore_ascii_case("time_zone") { let decoded_value = percent_decode_str(raw_value).decode_utf8_lossy().trim().to_string(); if !decoded_value.is_empty() { timezone = Some(decoded_value); } } else if key.eq_ignore_ascii_case("schema") || key.eq_ignore_ascii_case("currentSchema") { let decoded_value = percent_decode_str(raw_value).decode_utf8_lossy().trim().to_string(); if !decoded_value.is_empty() { search_path = Some(decoded_value); } } else if key.eq_ignore_ascii_case("ssl-mode") { let decoded_value = percent_decode_str(raw_value).decode_utf8_lossy(); match decoded_value.to_ascii_lowercase().replace('_', "-").as_str() { "require" | "required" => parts.push("sslmode=require".to_string()), "prefer" | "preferred" => parts.push("sslmode=prefer".to_string()), "disable" | "disabled" => parts.push("sslmode=disable".to_string()), "verify-ca" => parts.push("sslmode=verify-ca".to_string()), "verify-full" | "verify-identity" => parts.push("sslmode=verify-full".to_string()), _ => {} } } else if key.eq_ignore_ascii_case("charset") || key.eq_ignore_ascii_case("require_ssl") || key.eq_ignore_ascii_case("verify_ca") || key.eq_ignore_ascii_case("verify_identity") { // These MySQL-style parameters may be present in older/imported // saved connections. tokio-postgres rejects unknown URL keys. } else { parts.push(part.to_string()); } } let mut connection_options: Vec<(&str, String)> = Vec::new(); if let Some(search_path) = search_path { connection_options.push(("search_path=", format!("-c search_path={search_path}"))); } if let Some(timezone) = timezone { connection_options.push(("timezone=", format!("-c TimeZone={timezone}"))); } if connection_options.is_empty() { if force_tls && !parts.iter().any(|part| url_param_key_is(part, "sslmode")) { parts.insert(0, "sslmode=require".to_string()); } return parts.join("&"); } if let Some(options_index) = parts.iter().position(|part| { part.split_once('=') .map(|(raw_key, _)| percent_decode_str(raw_key).decode_utf8_lossy().eq_ignore_ascii_case("options")) .unwrap_or(false) }) { let (raw_key, raw_value) = parts[options_index].split_once('=').unwrap_or(("options", "")); let options_value = percent_decode_str(raw_value).decode_utf8_lossy(); let lower_options = options_value.to_ascii_lowercase(); let appended_options = connection_options .into_iter() .filter_map(|(needle, option)| (!lower_options.contains(needle)).then_some(option)) .collect::>() .join(" "); if !appended_options.is_empty() { let combined = format!("{} {}", options_value.trim(), appended_options).trim().to_string(); parts[options_index] = format!("{raw_key}={}", encode_url_part(&combined)); } } else { let combined = connection_options.into_iter().map(|(_, option)| option).collect::>().join(" "); parts.push(format!("options={}", encode_url_part(&combined))); } if force_tls && !parts.iter().any(|part| url_param_key_is(part, "sslmode")) { parts.insert(0, "sslmode=require".to_string()); } parts.join("&") } fn url_param_key_is(part: &str, expected: &str) -> bool { let key = part.split_once('=').map(|(key, _)| key).unwrap_or(part); percent_decode_str(key).decode_utf8_lossy().eq_ignore_ascii_case(expected) } fn clickhouse_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String { let trimmed = host.trim(); if let Some(rest) = trimmed.strip_prefix("https://") { return format!("https://{}", trim_clickhouse_host_port(rest, port)); } if let Some(rest) = trimmed.strip_prefix("http://") { let scheme = if config.clickhouse_uses_tls() { "https" } else { "http" }; return format!("{scheme}://{}", trim_clickhouse_host_port(rest, port)); } let scheme = if config.clickhouse_uses_tls() { "https" } else { "http" }; format!("{scheme}://{}:{port}", bracket_ipv6(trimmed)) } fn rqlite_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String { let trimmed = host.trim(); if let Some(rest) = trimmed.strip_prefix("https://") { return format!("https://{}", trim_http_host_port(rest, port)); } if let Some(rest) = trimmed.strip_prefix("http://") { let scheme = if config.ssl { "https" } else { "http" }; return format!("{scheme}://{}", trim_http_host_port(rest, port)); } let scheme = if config.ssl { "https" } else { "http" }; format!("{scheme}://{}:{port}", bracket_ipv6(trimmed)) } fn turso_http_url(config: &ConnectionConfig, host: &str, port: u16) -> String { let trimmed = host.trim(); // Handle libsql:// protocol (Turso native) if let Some(rest) = trimmed.strip_prefix("libsql://") { return format!("https://{}", trim_http_host_port(rest, port)); } // Handle explicit https:// if let Some(rest) = trimmed.strip_prefix("https://") { return format!("https://{}", trim_http_host_port(rest, port)); } // Handle explicit http:// (respect ssl config) if let Some(rest) = trimmed.strip_prefix("http://") { let scheme = if config.ssl { "https" } else { "http" }; return format!("{scheme}://{}", trim_http_host_port(rest, port)); } // Default: bare hostname -> prefer HTTPS for Turso (default port 443) let scheme = if port == 443 || config.ssl { "https" } else { "http" }; format!("{scheme}://{}:{port}", bracket_ipv6(trimmed)) } fn trim_http_host_port(value: &str, default_port: u16) -> String { let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value); if authority.starts_with('[') && !authority.contains("]:") { return format!("{authority}:{default_port}"); } if authority.rsplit_once(':').is_some() { authority.to_string() } else { format!("{authority}:{default_port}") } } fn trim_clickhouse_host_port(value: &str, default_port: u16) -> String { let authority = value.trim_end_matches('/').split('/').next().unwrap_or(value).split('?').next().unwrap_or(value); if authority.starts_with('[') && !authority.contains("]:") { return format!("{authority}:{default_port}"); } if authority.rsplit_once(':').is_some() { authority.to_string() } else { format!("{authority}:{default_port}") } } pub fn parse_mongo_first_host(uri: &str) -> Option<(String, u16)> { let rest = uri.strip_prefix("mongodb://").or_else(|| uri.strip_prefix("mongodb+srv://"))?; let authority = rest.split('/').next()?; let host_section = match authority.rfind('@') { Some(idx) => &authority[idx + 1..], None => authority, }; let first = host_section.split(',').next()?; match first.rsplit_once(':') { Some((h, p)) => Some((h.to_string(), p.parse().unwrap_or(27017))), None => Some((first.to_string(), 27017)), } } fn rewrite_mongo_uri_host(uri: &str, new_host: &str, new_port: u16) -> String { let (_scheme, rest) = if let Some(r) = uri.strip_prefix("mongodb+srv://") { ("mongodb://", r) } else if let Some(r) = uri.strip_prefix("mongodb://") { ("mongodb://", r) } else { return uri.to_string(); }; let (creds_prefix, after_creds) = match rest.find('@') { Some(idx) => (&rest[..=idx], &rest[idx + 1..]), None => ("", rest), }; let after_hosts = match after_creds.find('/') { Some(idx) => &after_creds[idx..], None => "", }; let mut result = format!("mongodb://{creds_prefix}{new_host}:{new_port}{after_hosts}"); if !result.contains("directConnection=") { if result.contains('?') { result.push_str("&directConnection=true"); } else { result.push_str("?directConnection=true"); } } result } pub fn parse_jdbc_host_port(url: &str) -> Option<(String, u16)> { let rest = url.strip_prefix("jdbc:")?; // jdbc:oracle:thin:@host:port:SID or jdbc:oracle:thin:@//host:port/service if let Some(after) = rest.strip_prefix("oracle:") { let at_pos = after.find('@')?; let after_at = &after[at_pos + 1..]; if after_at.trim_start().starts_with('(') { let host = oracle_descriptor_value(after_at, "HOST")?; let port = oracle_descriptor_value(after_at, "PORT")?; return Some((host, port.parse().ok()?)); } let after_at = after_at.strip_prefix("//").unwrap_or(after_at); let host_port = after_at.split(&['/', ':', '?'][..]).next()?; let port_str = after_at.strip_prefix(host_port)?.strip_prefix(':')?.split(&[':', '/', ';', '?'][..]).next()?; return Some((host_port.to_string(), port_str.parse().ok()?)); } // jdbc:sqlserver://host:port;prop=val or jdbc:sqlserver://host\instance:port;... if let Some(after) = rest.strip_prefix("sqlserver://") { let authority = after.split(';').next().unwrap_or(after); let authority = authority.split('\\').next().unwrap_or(authority); return match authority.rsplit_once(':') { Some((h, p)) => Some((h.to_string(), p.parse().ok()?)), None => Some((authority.to_string(), 1433)), }; } // Generic: jdbc:subprotocol://[user:pass@]host:port[/path][?query] let scheme_end = rest.find("://")?; let after_scheme = &rest[scheme_end + 3..]; let authority = after_scheme.split('/').next().unwrap_or(after_scheme); let authority = authority.split('?').next().unwrap_or(authority); let host_port = match authority.rfind('@') { Some(idx) => &authority[idx + 1..], None => authority, }; match host_port.rsplit_once(':') { Some((h, p)) => Some((h.to_string(), p.parse().ok()?)), None => None, } } pub fn rewrite_jdbc_url_host(url: &str, new_host: &str, new_port: u16) -> String { let normalized_url = url.to_ascii_uppercase(); if normalized_url.starts_with("JDBC:ORACLE:") && normalized_url.contains("(HOST=") && normalized_url.contains("(PORT=") { return rewrite_oracle_descriptor_host(url, new_host, new_port); } let Some((old_host, old_port)) = parse_jdbc_host_port(url) else { return url.to_string(); }; let old_authority = format!("{old_host}:{old_port}"); let new_authority = format!("{new_host}:{new_port}"); url.replacen(&old_authority, &new_authority, 1) } fn oracle_descriptor_value(descriptor: &str, key: &str) -> Option { let key = format!("({key}="); let start = descriptor.to_ascii_uppercase().find(&key)?; let value_start = start + key.len(); let value_end = descriptor[value_start..].find(')')? + value_start; Some(descriptor[value_start..value_end].trim().to_string()) } fn rewrite_oracle_descriptor_host(url: &str, new_host: &str, new_port: u16) -> String { let rewritten_host = replace_oracle_descriptor_value(url, "HOST", new_host); replace_oracle_descriptor_value(&rewritten_host, "PORT", &new_port.to_string()) } fn replace_oracle_descriptor_value(input: &str, key: &str, value: &str) -> String { let token = format!("({key}="); let Some(start) = input.to_ascii_uppercase().find(&token) else { return input.to_string(); }; let value_start = start + token.len(); let Some(value_end) = input[value_start..].find(')').map(|offset| value_start + offset) else { return input.to_string(); }; format!("{}{}{}", &input[..value_start], value, &input[value_end..]) } fn encode_url_part(value: &str) -> String { utf8_percent_encode(value, NON_ALPHANUMERIC).to_string() } fn bracket_ipv6(host: &str) -> String { if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_string() } } #[cfg(test)] mod tests { use super::{ default_query_timeout_secs, default_redis_key_separator, default_ssh_connect_timeout_secs, ConnectionConfig, DatabaseType, ProxyTunnelConfig, ProxyType, TransportLayerConfig, }; use std::str::FromStr; 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), visible_databases: None, attached_databases: Vec::new(), color: None, transport_layers: Vec::new(), connect_timeout_secs: super::default_connect_timeout_secs(), query_timeout_secs: default_query_timeout_secs(), idle_timeout_secs: super::default_idle_timeout_secs(), keepalive_interval_secs: super::default_keepalive_interval_secs(), ssl: false, ca_cert_path: String::new(), client_cert_path: String::new(), client_key_path: String::new(), sysdba: false, oracle_connection_type: None, connection_string: None, redis_connection_mode: None, redis_sentinel_master: String::new(), redis_sentinel_nodes: String::new(), redis_sentinel_username: String::new(), redis_sentinel_password: String::new(), redis_sentinel_tls: false, redis_cluster_nodes: String::new(), redis_key_separator: default_redis_key_separator(), etcd_endpoints: String::new(), gbase_server: String::new(), informix_server: String::new(), external_config: None, jdbc_driver_class: None, jdbc_driver_paths: Vec::new(), one_time: false, read_only: false, } } fn mongodb_config(username: &str, password: &str, database: Option<&str>) -> ConnectionConfig { let mut config = mysql_config(username, password, database); config.db_type = DatabaseType::MongoDb; config.port = 17000; config } #[test] fn legacy_single_ssh_config_migrates_to_transport_layer() { let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ "id": "id", "name": "name", "db_type": "mysql", "host": "10.1.2.3", "port": 3306, "username": "root", "password": "", "database": null, "ssh_enabled": true, "ssh_host": "bastion.example.com", "ssh_port": 2200, "ssh_user": "deploy", "ssh_password": "secret", "ssh_connect_timeout_secs": 0, "ssh_expose_lan": true })) .unwrap(); let hops = config.effective_ssh_tunnels(); assert_eq!(hops.len(), 1); assert_eq!(hops[0].id, "legacy"); assert_eq!(hops[0].host, "bastion.example.com"); assert_eq!(hops[0].port, 2200); assert_eq!(hops[0].user, "deploy"); assert_eq!(hops[0].password, "secret"); assert_eq!(hops[0].connect_timeout_secs, default_ssh_connect_timeout_secs()); assert!(hops[0].expose_lan); } #[test] fn legacy_ssh_tunnels_migrate_to_ordered_transport_layers() { let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ "id": "id", "name": "name", "db_type": "mysql", "host": "10.1.2.3", "port": 3306, "username": "root", "password": "", "database": null, "ssh_enabled": true, "ssh_tunnels": [ { "id": "first", "host": "a", "port": 22, "user": "u" }, { "id": "second", "host": "b", "port": 2200, "user": "u" } ] })) .unwrap(); let hops = config.effective_ssh_tunnels(); assert_eq!(hops.iter().map(|hop| hop.id.as_str()).collect::>(), vec!["first", "second"]); } #[test] fn legacy_proxy_config_migrates_to_transport_layer() { let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ "id": "id", "name": "name", "db_type": "mysql", "host": "10.1.2.3", "port": 3306, "username": "root", "password": "", "database": null, "proxy_enabled": true, "proxy_type": "http", "proxy_host": "proxy.example.com", "proxy_port": 8080, "proxy_username": "alice", "proxy_password": "secret" })) .unwrap(); assert_eq!(config.transport_layers.len(), 1); match &config.transport_layers[0] { TransportLayerConfig::Proxy(proxy) => { assert_eq!(proxy.id, "legacy-proxy"); assert_eq!(proxy.proxy_type, ProxyType::Http); assert_eq!(proxy.host, "proxy.example.com"); assert_eq!(proxy.port, 8080); assert_eq!(proxy.username, "alice"); assert_eq!(proxy.password, "secret"); } TransportLayerConfig::Ssh(_) => panic!("expected proxy layer"), } } #[test] fn existing_transport_layers_take_precedence_over_legacy_fields() { let config: ConnectionConfig = serde_json::from_value(serde_json::json!({ "id": "id", "name": "name", "db_type": "mysql", "host": "10.1.2.3", "port": 3306, "username": "root", "password": "", "database": null, "ssh_enabled": true, "ssh_host": "legacy.example.com", "transport_layers": [{ "type": "proxy", "id": "proxy", "host": "proxy", "port": 1080 }] })) .unwrap(); assert_eq!(config.transport_layers.len(), 1); assert!(matches!(&config.transport_layers[0], TransportLayerConfig::Proxy(proxy) if proxy.id == "proxy")); } #[test] fn serialized_connection_config_omits_legacy_transport_fields() { let mut config = mysql_config("root", "", None); config.transport_layers = vec![TransportLayerConfig::Proxy(ProxyTunnelConfig { id: "proxy".to_string(), name: String::new(), enabled: true, proxy_type: ProxyType::Socks5, host: "proxy".to_string(), port: 1080, username: String::new(), password: String::new(), })]; let saved = serde_json::to_value(config).unwrap(); assert!(saved.get("transport_layers").is_some()); for key in ["ssh_tunnels", "ssh_host", "ssh_password", "proxy_host", "proxy_password", "proxy_enabled"] { assert!(saved.get(key).is_none(), "legacy key {key} should not serialize"); } } #[test] fn query_timeout_zero_disables_timeout() { let mut config = mysql_config("root", "", None); config.query_timeout_secs = 0; assert_eq!(config.effective_query_timeout_secs(), 0); } #[test] fn query_timeout_preserves_long_running_exports() { let mut config = mysql_config("root", "", None); config.query_timeout_secs = 3600; assert_eq!(config.effective_query_timeout_secs(), 3600); } #[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&charset=utf8mb4" ); } #[test] fn oceanbase_profile_uses_bare_mysql_connection_options() { let mut config = mysql_config("user@tenant#cluster", "secret", None); config.driver_profile = Some("oceanbase".to_string()); assert!(config.needs_bare_mysql()); assert_eq!(config.connection_url(), "mysql://user%40tenant%23cluster:secret@10.1.2.3:2883"); } #[test] fn starrocks_profile_omits_mysql_ssl_mode_param() { let mut config = mysql_config("root", "secret", Some("analytics")); config.driver_profile = Some("starrocks".to_string()); config.url_params = Some( "ssl-mode=disabled&sslmode=required&require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4" .to_string(), ); assert!(config.needs_bare_mysql()); assert_eq!(config.connection_url(), "mysql://root:secret@10.1.2.3:2883/analytics"); } #[test] fn starrocks_profile_keeps_non_mysql_tls_params() { let mut config = mysql_config("root", "secret", Some("analytics")); config.driver_profile = Some("starrocks".to_string()); config.url_params = Some("connect_timeout=10&sessionVariables=query_timeout=60".to_string()); assert_eq!( config.connection_url(), "mysql://root:secret@10.1.2.3:2883/analytics?connect_timeout=10&sessionVariables=query_timeout=60" ); } #[test] fn tdengine_profile_is_canonicalized_to_agent_database_type() { let mut config = mysql_config("root", "taosdata", Some("power")); config.driver_profile = Some("tdengine".to_string()); config.driver_label = None; config.port = 6030; let canonical = config.canonicalized(); assert_eq!(canonical.db_type, DatabaseType::Tdengine); assert_eq!(canonical.port, 6041); assert_eq!(canonical.driver_profile.as_deref(), Some("tdengine")); assert_eq!(canonical.driver_label.as_deref(), Some("TDengine")); assert!(!canonical.needs_bare_mysql()); } #[test] fn informix_empty_database_uses_sysmaster_for_connection() { let mut config = mysql_config("informix", "in4mix", None); config.db_type = DatabaseType::Informix; config.port = 9088; assert_eq!(config.effective_database(), Some("sysmaster")); assert_eq!(config.connection_url(), "informix://informix:in4mix@10.1.2.3:9088/sysmaster"); } #[test] fn h2_empty_database_uses_test_for_connection() { let mut config = mysql_config("sa", "", None); config.db_type = DatabaseType::H2; config.port = 9092; assert_eq!(config.effective_database(), Some("test")); assert_eq!(config.connection_url(), "h2://sa:@10.1.2.3:9092/test"); } #[test] fn neo4j_empty_database_uses_neo4j_for_connection() { let mut config = mysql_config("neo4j", "secret", None); config.db_type = DatabaseType::Neo4j; config.port = 7687; assert_eq!(config.effective_database(), Some("neo4j")); assert_eq!(config.connection_url(), "neo4j://neo4j:secret@10.1.2.3:7687/neo4j"); } #[test] fn clickhouse_empty_database_uses_default() { let mut config = mysql_config("default", "", None); config.db_type = DatabaseType::ClickHouse; config.port = 8123; assert_eq!(config.effective_database(), Some("default")); } #[test] fn clickhouse_tls_uses_https_from_ssl_or_secure_param() { let mut config = mysql_config("default", "", None); config.db_type = DatabaseType::ClickHouse; config.port = 8443; assert_eq!(config.connection_url(), "http://10.1.2.3:8443"); config.ssl = true; assert_eq!(config.connection_url(), "https://10.1.2.3:8443"); config.ssl = false; config.url_params = Some("secure=true".to_string()); assert_eq!(config.connection_url(), "https://10.1.2.3:8443"); } #[test] fn clickhouse_host_may_include_http_scheme() { let mut config = mysql_config("default", "", None); config.db_type = DatabaseType::ClickHouse; config.host = "https://clickhouse.example.com".to_string(); config.port = 8443; assert_eq!(config.connection_url(), "https://clickhouse.example.com:8443"); } #[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&charset=utf8mb4" ); } #[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 mysql_tls_switch_requires_ssl_without_strict_certificate_checks_by_default() { let mut config = mysql_config("root", "secret", Some("test")); config.ssl = true; assert_eq!( config.connection_url(), "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4" ); } #[test] fn mysql_tls_switch_uses_ca_cert_for_ca_validation() { let mut config = mysql_config("root", "secret", Some("test")); config.ssl = true; config.ca_cert_path = "/tmp/tidb-ca.pem".to_string(); assert_eq!( config.connection_url(), "mysql://root:secret@10.1.2.3:2883/test?require_ssl=true&verify_identity=false&charset=utf8mb4" ); } #[test] fn tidb_cloud_mysql_url_requires_tls() { let mut config = mysql_config("root", "secret", Some("test")); config.host = "gateway01.us-west-2.prod.aws.tidbcloud.com".to_string(); config.port = 4000; config.url_params = Some("require_ssl=false&charset=utf8mb4".to_string()); assert_eq!( config.connection_url(), "mysql://root:secret@gateway01.us-west-2.prod.aws.tidbcloud.com:4000/test?require_ssl=true&charset=utf8mb4&verify_ca=false&verify_identity=false" ); } #[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 postgres_tls_switch_adds_require_sslmode() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.ssl = true; assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require"); } #[test] fn postgres_url_normalizes_timezone_param_into_options() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("sslmode=require&timezone=Asia/Shanghai".to_string()); assert_eq!( config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require&options=%2Dc%20TimeZone%3DAsia%2FShanghai" ); let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); assert_eq!(pg_config.get_options(), Some("-c TimeZone=Asia/Shanghai")); } #[test] fn postgres_url_maps_schema_param_into_search_path_options() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("schema=public".to_string()); assert_eq!( config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?options=%2Dc%20search%5Fpath%3Dpublic" ); let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); assert_eq!(pg_config.get_options(), Some("-c search_path=public")); } #[test] fn postgres_url_maps_current_schema_param_into_search_path_options() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("currentSchema=app".to_string()); assert_eq!( config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?options=%2Dc%20search%5Fpath%3Dapp" ); let pg_config = tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); assert_eq!(pg_config.get_options(), Some("-c search_path=app")); } #[test] fn postgres_url_ignores_mysql_only_params_from_saved_connections() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("ssl-mode=preferred&charset=utf8mb4".to_string()); assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=prefer"); tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); } #[test] fn postgres_url_maps_mysql_ssl_mode_require_to_sslmode() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("ssl-mode=required&verify_ca=false&verify_identity=false".to_string()); assert_eq!(config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?sslmode=require"); tokio_postgres::Config::from_str(&config.connection_url()).unwrap(); } #[test] fn postgres_url_appends_timezone_to_existing_options() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("options=-c%20statement_timeout%3D5000&TimeZone=UTC".to_string()); assert_eq!( config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?options=%2Dc%20statement%5Ftimeout%3D5000%20%2Dc%20TimeZone%3DUTC" ); } #[test] fn postgres_url_keeps_existing_options_timezone() { let mut config = mysql_config("postgres", "secret", Some("test")); config.db_type = DatabaseType::Postgres; config.url_params = Some("options=-c%20TimeZone%3DUTC&timezone=Asia/Shanghai".to_string()); assert_eq!( config.connection_url(), "postgres://postgres:secret@10.1.2.3:2883/test?options=-c%20TimeZone%3DUTC" ); } #[test] fn postgres_url_defaults_to_postgres_database_when_omitted() { let mut config = mysql_config("root", "secret", None); config.db_type = DatabaseType::Postgres; assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/postgres"); } #[test] fn postgres_url_defaults_to_postgres_database_when_empty() { let mut config = mysql_config("root", "secret", Some("")); config.db_type = DatabaseType::Postgres; assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/postgres"); } #[test] fn redshift_url_defaults_to_dev_database_when_empty() { let mut config = mysql_config("awsuser", "secret", Some("")); config.db_type = DatabaseType::Redshift; assert_eq!(config.connection_url(), "postgres://awsuser:secret@10.1.2.3:2883/dev"); } #[test] fn cockroachdb_url_defaults_to_defaultdb_database() { let mut config = mysql_config("root", "secret", None); config.db_type = DatabaseType::Postgres; config.driver_profile = Some("cockroachdb".to_string()); assert_eq!(config.connection_url(), "postgres://root:secret@10.1.2.3:2883/defaultdb"); } #[test] fn gaussdb_url_defaults_to_postgres_database() { let mut config = mysql_config("gaussdb", "secret", None); config.db_type = DatabaseType::Gaussdb; assert_eq!(config.connection_url(), "gaussdb://gaussdb:secret@10.1.2.3:2883/postgres"); } #[test] fn kwdb_url_defaults_to_defaultdb_database() { let mut config = mysql_config("root", "secret", None); config.db_type = DatabaseType::Kwdb; assert_eq!(config.connection_url(), "kwdb://root:secret@10.1.2.3:2883/defaultdb"); } #[test] fn yashandb_url_defaults_to_yasdb_database() { let mut config = mysql_config("sys", "secret", None); config.db_type = DatabaseType::Yashandb; assert_eq!(config.connection_url(), "yashandb://sys:secret@10.1.2.3:2883/yasdb"); } #[test] fn mongodb_form_url_without_params_does_not_force_topology_or_auth() { let config = mongodb_config("root", "secret", Some("admin")); assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/admin"); } #[test] fn mongodb_form_url_appends_custom_params() { let mut config = mongodb_config("root", "secret", Some("app")); config.url_params = Some("?authSource=admin&authMechanism=SCRAM-SHA-1&directConnection=true".to_string()); assert_eq!( config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/app?authSource=admin&authMechanism=SCRAM-SHA-1&directConnection=true" ); } #[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&charset=utf8mb4"); 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")); } #[test] fn redis_tls_insecure_url_params_append_insecure_fragment() { let mut config = mysql_config("default", "secret", Some("0")); config.db_type = DatabaseType::Redis; config.ssl = true; config.url_params = Some("insecure=true".to_string()); assert_eq!(config.connection_url(), "rediss://default:secret@10.1.2.3:2883/#insecure"); assert_eq!(config.redacted_connection_url(), "rediss://10.1.2.3:2883/#insecure"); } #[test] fn redis_insecure_url_params_do_not_affect_plain_tcp() { let mut config = mysql_config("default", "secret", Some("0")); config.db_type = DatabaseType::Redis; config.url_params = Some("insecure=true".to_string()); assert_eq!(config.connection_url(), "redis://default:secret@10.1.2.3:2883/"); } #[test] fn redacted_mongodb_url_keeps_custom_params_without_credentials() { let mut config = mongodb_config("root", "secret", Some("admin")); config.url_params = Some("authSource=admin&authMechanism=SCRAM-SHA-1".to_string()); let url = config.redacted_connection_url(); assert_eq!(url, "mongodb://10.1.2.3:17000/admin?authSource=admin&authMechanism=SCRAM-SHA-1"); assert!(!url.contains("root")); assert!(!url.contains("secret")); } #[test] fn mongodb_form_tls_uses_standard_scheme_and_tls_param() { let mut config = mongodb_config("root", "secret", Some("admin")); config.ssl = true; assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/admin?tls=true"); assert_eq!(config.redacted_connection_url(), "mongodb://10.1.2.3:17000/admin?tls=true"); } #[test] fn mongodb_form_tls_replaces_existing_tls_params() { let mut config = mongodb_config("root", "secret", Some("admin")); config.ssl = true; config.url_params = Some("authSource=admin&ssl=false&tls=false".to_string()); assert_eq!(config.connection_url(), "mongodb://root:secret@10.1.2.3:17000/admin?tls=true&authSource=admin"); } #[test] fn parse_mongo_first_host_replica_set() { let uri = "mongodb://user:pass@host1:27017,host2:27017,host3:27017/admin?replicaSet=rs0"; let (host, port) = super::parse_mongo_first_host(uri).unwrap(); assert_eq!(host, "host1"); assert_eq!(port, 27017); } #[test] fn parse_mongo_first_host_single() { let uri = "mongodb://user:pass@myhost:30000/db"; let (host, port) = super::parse_mongo_first_host(uri).unwrap(); assert_eq!(host, "myhost"); assert_eq!(port, 30000); } #[test] fn parse_mongo_first_host_no_creds() { let uri = "mongodb://host1:27017,host2:27017/admin"; let (host, port) = super::parse_mongo_first_host(uri).unwrap(); assert_eq!(host, "host1"); assert_eq!(port, 27017); } #[test] fn parse_mongo_first_host_srv() { let uri = "mongodb+srv://user:pass@cluster0.example.net/db"; let (host, port) = super::parse_mongo_first_host(uri).unwrap(); assert_eq!(host, "cluster0.example.net"); assert_eq!(port, 27017); } #[test] fn mongodb_connection_string_rewritten_when_tunneled() { let mut config = mongodb_config("root", "secret", Some("admin")); config.connection_string = Some("mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0&authSource=admin".to_string()); let url = config.connection_url_with_host("127.0.0.1", 54321); assert_eq!( url, "mongodb://read:pass@127.0.0.1:54321/admin?replicaSet=rs0&authSource=admin&directConnection=true" ); } #[test] fn mongodb_connection_string_unchanged_when_not_tunneled() { let mut config = mongodb_config("root", "secret", Some("admin")); config.connection_string = Some("mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0".to_string()); let url = config.connection_url(); assert_eq!(url, "mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0"); } #[test] fn mongodb_multi_seed_connection_string_removes_direct_connection_true() { let mut config = mongodb_config("root", "secret", Some("admin")); config.connection_string = Some( "mongodb://read:pass@host1:27017,host2:27017/admin?directConnection=true&replicaSet=rs0&authSource=admin" .to_string(), ); let url = config.connection_url(); assert_eq!(url, "mongodb://read:pass@host1:27017,host2:27017/admin?replicaSet=rs0&authSource=admin"); } #[test] fn mongodb_single_seed_connection_string_keeps_direct_connection_true() { let mut config = mongodb_config("root", "secret", Some("admin")); config.connection_string = Some("mongodb://read:pass@host1:27017/admin?directConnection=true&authSource=admin".to_string()); let url = config.connection_url(); assert_eq!(url, "mongodb://read:pass@host1:27017/admin?directConnection=true&authSource=admin"); } #[test] fn mongodb_form_url_adds_direct_connection_when_tunneled() { let mut config = mongodb_config("root", "secret", Some("admin")); config.url_params = Some("replicaSet=rs0&authSource=admin".to_string()); let url = config.connection_url_with_host("127.0.0.1", 54321); assert_eq!( url, "mongodb://root:secret@127.0.0.1:54321/admin?replicaSet=rs0&authSource=admin&directConnection=true" ); } #[test] fn mongodb_form_url_no_duplicate_direct_connection() { let mut config = mongodb_config("root", "secret", Some("admin")); config.url_params = Some("directConnection=true&authSource=admin".to_string()); let url = config.connection_url_with_host("127.0.0.1", 54321); assert!(url.matches("directConnection").count() == 1); } #[test] fn parse_jdbc_host_port_postgresql() { let (h, p) = super::parse_jdbc_host_port("jdbc:postgresql://myhost:5432/mydb").unwrap(); assert_eq!(h, "myhost"); assert_eq!(p, 5432); } #[test] fn parse_jdbc_host_port_mysql() { let (h, p) = super::parse_jdbc_host_port("jdbc:mysql://db.example.com:3306/app?useSSL=false").unwrap(); assert_eq!(h, "db.example.com"); assert_eq!(p, 3306); } #[test] fn parse_jdbc_host_port_with_userinfo() { let (h, p) = super::parse_jdbc_host_port("jdbc:postgresql://user:pass@pghost:5433/db").unwrap(); assert_eq!(h, "pghost"); assert_eq!(p, 5433); } #[test] fn parse_jdbc_host_port_oracle_thin() { let (h, p) = super::parse_jdbc_host_port("jdbc:oracle:thin:@orahost:1521:ORCL").unwrap(); assert_eq!(h, "orahost"); assert_eq!(p, 1521); } #[test] fn parse_jdbc_host_port_oracle_service() { let (h, p) = super::parse_jdbc_host_port("jdbc:oracle:thin:@//orahost:1521/service").unwrap(); assert_eq!(h, "orahost"); assert_eq!(p, 1521); } #[test] fn parse_jdbc_host_port_oracle_descriptor() { let (h, p) = super::parse_jdbc_host_port( "jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=orahost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=orcl)))", ) .unwrap(); assert_eq!(h, "orahost"); assert_eq!(p, 1521); } #[test] fn rewrite_jdbc_url_host_oracle_descriptor() { let url = "jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=orahost)(PORT=1521))(CONNECT_DATA=(SERVICE_NAME=orcl)))"; assert_eq!( super::rewrite_jdbc_url_host(url, "127.0.0.1", 11521), "jdbc:oracle:thin:@(DESCRIPTION=(ADDRESS=(PROTOCOL=TCP)(HOST=127.0.0.1)(PORT=11521))(CONNECT_DATA=(SERVICE_NAME=orcl)))" ); } #[test] fn parse_jdbc_host_port_sqlserver() { let (h, p) = super::parse_jdbc_host_port("jdbc:sqlserver://mshost:1433;databaseName=master").unwrap(); assert_eq!(h, "mshost"); assert_eq!(p, 1433); } #[test] fn parse_jdbc_host_port_sqlserver_no_port() { let (h, p) = super::parse_jdbc_host_port("jdbc:sqlserver://mshost;databaseName=master").unwrap(); assert_eq!(h, "mshost"); assert_eq!(p, 1433); } #[test] fn parse_jdbc_host_port_no_port_returns_none() { assert!(super::parse_jdbc_host_port("jdbc:postgresql://myhost/mydb").is_none()); } #[test] fn parse_jdbc_host_port_invalid_returns_none() { assert!(super::parse_jdbc_host_port("not-a-jdbc-url").is_none()); } #[test] fn rewrite_jdbc_url_postgresql() { let url = "jdbc:postgresql://myhost:5432/mydb"; let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); assert_eq!(rewritten, "jdbc:postgresql://127.0.0.1:54321/mydb"); } #[test] fn rewrite_jdbc_url_oracle() { let url = "jdbc:oracle:thin:@orahost:1521:ORCL"; let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); assert_eq!(rewritten, "jdbc:oracle:thin:@127.0.0.1:54321:ORCL"); } #[test] fn rewrite_jdbc_url_sqlserver() { let url = "jdbc:sqlserver://mshost:1433;databaseName=master"; let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); assert_eq!(rewritten, "jdbc:sqlserver://127.0.0.1:54321;databaseName=master"); } #[test] fn rewrite_jdbc_url_unparseable_returns_original() { let url = "jdbc:custom:some-opaque-string"; let rewritten = super::rewrite_jdbc_url_host(url, "127.0.0.1", 54321); assert_eq!(rewritten, url); } }