1use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
53use serde::{Deserialize, Serialize};
54
55use crate::SensitiveString;
56
57const USERINFO: &AsciiSet = &NON_ALPHANUMERIC
64 .remove(b'-')
65 .remove(b'.')
66 .remove(b'_')
67 .remove(b'~');
68
69const PATH_SEGMENT: &AsciiSet = USERINFO;
73
74pub trait DatabaseUrl {
76 fn to_url(&self) -> String;
81
82 fn db_type(&self) -> &'static str;
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct DbConnection {
100 #[serde(default = "default_localhost")]
101 pub host: String,
102 pub port: u16,
103 #[serde(default)]
104 pub user: String,
105 #[serde(default)]
109 pub password: SensitiveString,
110 #[serde(default)]
111 pub db: String,
112 #[serde(default)]
114 pub params: Option<String>,
115}
116
117fn default_localhost() -> String {
118 "localhost".into()
119}
120
121impl DbConnection {
122 fn from_env_with_defaults(prefix: &str, default_port: u16) -> Self {
123 Self {
124 host: std::env::var(format!("{prefix}_HOST")).unwrap_or_else(|_| "localhost".into()),
125 port: std::env::var(format!("{prefix}_PORT"))
126 .ok()
127 .and_then(|v| v.parse().ok())
128 .unwrap_or(default_port),
129 user: std::env::var(format!("{prefix}_USER")).unwrap_or_default(),
130 password: std::env::var(format!("{prefix}_PASSWORD"))
131 .map(SensitiveString::from)
132 .unwrap_or_default(),
133 db: std::env::var(format!("{prefix}_DB")).unwrap_or_default(),
134 params: std::env::var(format!("{prefix}_PARAMS")).ok(),
135 }
136 }
137
138 fn url_with_scheme(&self, scheme: &str) -> String {
145 let user_enc = utf8_percent_encode(&self.user, USERINFO);
146 let pass_raw = self.password.expose();
147 let pass_enc = utf8_percent_encode(pass_raw, USERINFO);
148
149 let auth = if self.user.is_empty() && pass_raw.is_empty() {
150 String::new()
151 } else if pass_raw.is_empty() {
152 format!("{user_enc}@")
153 } else {
154 format!("{user_enc}:{pass_enc}@")
155 };
156
157 let db_path = if self.db.is_empty() {
158 String::new()
159 } else {
160 format!("/{}", utf8_percent_encode(&self.db, PATH_SEGMENT))
161 };
162
163 let params = self
167 .params
168 .as_ref()
169 .map(|p| format!("?{p}"))
170 .unwrap_or_default();
171
172 let host_fmt = if self.host.contains(':') && !self.host.starts_with('[') {
175 format!("[{}]", self.host)
176 } else {
177 self.host.clone()
178 };
179
180 format!("{scheme}://{auth}{host_fmt}:{}{db_path}{params}", self.port)
181 }
182}
183
184#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct PostgresUrl(pub DbConnection);
187
188impl PostgresUrl {
189 #[must_use]
190 pub fn new(host: &str, port: u16, user: &str, password: &str, db: &str) -> Self {
191 Self(DbConnection {
192 host: host.into(),
193 port,
194 user: user.into(),
195 password: password.into(),
196 db: db.into(),
197 params: None,
198 })
199 }
200
201 #[must_use]
203 pub fn from_env(prefix: &str) -> Self {
204 Self(DbConnection::from_env_with_defaults(prefix, 5432))
205 }
206
207 #[must_use]
209 pub fn with_params(mut self, params: &str) -> Self {
210 self.0.params = Some(params.into());
211 self
212 }
213}
214
215impl DatabaseUrl for PostgresUrl {
216 fn to_url(&self) -> String {
217 self.0.url_with_scheme("postgresql")
218 }
219
220 fn db_type(&self) -> &'static str {
221 "postgresql"
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ClickHouseUrl(pub DbConnection);
228
229impl ClickHouseUrl {
230 #[must_use]
231 pub fn new(host: &str, port: u16, user: &str, password: &str, db: &str) -> Self {
232 Self(DbConnection {
233 host: host.into(),
234 port,
235 user: user.into(),
236 password: password.into(),
237 db: db.into(),
238 params: None,
239 })
240 }
241
242 #[must_use]
244 pub fn from_env(prefix: &str) -> Self {
245 Self(DbConnection::from_env_with_defaults(prefix, 8123))
246 }
247
248 #[must_use]
250 pub fn from_env_native(prefix: &str) -> Self {
251 Self(DbConnection::from_env_with_defaults(prefix, 9000))
252 }
253}
254
255impl DatabaseUrl for ClickHouseUrl {
256 fn to_url(&self) -> String {
257 self.0.url_with_scheme("http")
258 }
259
260 fn db_type(&self) -> &'static str {
261 "clickhouse"
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct RedisUrl(pub DbConnection);
268
269impl RedisUrl {
270 #[must_use]
271 pub fn new(host: &str, port: u16, password: &str, db: &str) -> Self {
272 Self(DbConnection {
273 host: host.into(),
274 port,
275 user: String::new(),
276 password: password.into(),
277 db: db.into(),
278 params: None,
279 })
280 }
281
282 #[must_use]
284 pub fn from_env(prefix: &str) -> Self {
285 Self(DbConnection::from_env_with_defaults(prefix, 6379))
286 }
287}
288
289impl DatabaseUrl for RedisUrl {
290 fn to_url(&self) -> String {
291 self.0.url_with_scheme("redis")
292 }
293
294 fn db_type(&self) -> &'static str {
295 "redis"
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct MongoUrl(pub DbConnection);
302
303impl MongoUrl {
304 #[must_use]
305 pub fn new(host: &str, port: u16, user: &str, password: &str, db: &str) -> Self {
306 Self(DbConnection {
307 host: host.into(),
308 port,
309 user: user.into(),
310 password: password.into(),
311 db: db.into(),
312 params: None,
313 })
314 }
315
316 #[must_use]
318 pub fn from_env(prefix: &str) -> Self {
319 Self(DbConnection::from_env_with_defaults(prefix, 27017))
320 }
321
322 #[must_use]
324 pub fn with_params(mut self, params: &str) -> Self {
325 self.0.params = Some(params.into());
326 self
327 }
328}
329
330impl DatabaseUrl for MongoUrl {
331 fn to_url(&self) -> String {
332 self.0.url_with_scheme("mongodb")
333 }
334
335 fn db_type(&self) -> &'static str {
336 "mongodb"
337 }
338}
339
340impl std::fmt::Display for PostgresUrl {
342 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343 write!(
344 f,
345 "postgresql://{}:***@{}:{}/{}",
346 self.0.user, self.0.host, self.0.port, self.0.db
347 )
348 }
349}
350
351impl std::fmt::Display for ClickHouseUrl {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 write!(
354 f,
355 "http://{}:***@{}:{}/{}",
356 self.0.user, self.0.host, self.0.port, self.0.db
357 )
358 }
359}
360
361impl std::fmt::Display for RedisUrl {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 write!(
364 f,
365 "redis://***@{}:{}/{}",
366 self.0.host, self.0.port, self.0.db
367 )
368 }
369}
370
371impl std::fmt::Display for MongoUrl {
372 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373 write!(
374 f,
375 "mongodb://{}:***@{}:{}/{}",
376 self.0.user, self.0.host, self.0.port, self.0.db
377 )
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn postgres_url_with_all_fields() {
387 let url = PostgresUrl::new("db.prod", 5432, "app", "secret", "mydb");
388 assert_eq!(url.to_url(), "postgresql://app:secret@db.prod:5432/mydb");
389 assert_eq!(url.db_type(), "postgresql");
390 }
391
392 #[test]
393 fn postgres_url_with_params() {
394 let url = PostgresUrl::new("db.prod", 5432, "app", "secret", "mydb")
395 .with_params("sslmode=require");
396 assert_eq!(
397 url.to_url(),
398 "postgresql://app:secret@db.prod:5432/mydb?sslmode=require"
399 );
400 }
401
402 #[test]
403 fn postgres_url_no_password() {
404 let url = PostgresUrl::new("db.prod", 5432, "app", "", "mydb");
405 assert_eq!(url.to_url(), "postgresql://app@db.prod:5432/mydb");
406 }
407
408 #[test]
409 fn postgres_url_no_auth() {
410 let url = PostgresUrl::new("db.prod", 5432, "", "", "mydb");
411 assert_eq!(url.to_url(), "postgresql://db.prod:5432/mydb");
412 }
413
414 #[test]
415 fn postgres_display_redacts_password() {
416 let url = PostgresUrl::new("db.prod", 5432, "app", "hunter2", "mydb");
417 let display = format!("{url}");
418 assert!(!display.contains("hunter2"));
419 assert!(display.contains("***"));
420 }
421
422 #[test]
423 fn clickhouse_http_url() {
424 let url = ClickHouseUrl::new("ch.prod", 8123, "default", "secret", "dfe");
425 assert_eq!(url.to_url(), "http://default:secret@ch.prod:8123/dfe");
426 assert_eq!(url.db_type(), "clickhouse");
427 }
428
429 #[test]
430 fn redis_url() {
431 let url = RedisUrl::new("redis.prod", 6379, "secret", "0");
432 assert_eq!(url.to_url(), "redis://:secret@redis.prod:6379/0");
433 assert_eq!(url.db_type(), "redis");
434 }
435
436 #[test]
437 fn redis_url_no_password() {
438 let url = RedisUrl::new("redis.prod", 6379, "", "0");
439 assert_eq!(url.to_url(), "redis://redis.prod:6379/0");
440 }
441
442 #[test]
443 fn redis_display_redacts() {
444 let url = RedisUrl::new("redis.prod", 6379, "secret123", "0");
445 let display = format!("{url}");
446 assert!(!display.contains("secret123"));
447 }
448
449 #[test]
450 fn mongo_url() {
451 let url = MongoUrl::new("mongo.prod", 27017, "admin", "secret", "mydb");
452 assert_eq!(url.to_url(), "mongodb://admin:secret@mongo.prod:27017/mydb");
453 assert_eq!(url.db_type(), "mongodb");
454 }
455
456 #[test]
457 fn mongo_url_with_params() {
458 let url = MongoUrl::new("mongo.prod", 27017, "admin", "secret", "mydb")
459 .with_params("authSource=admin&replicaSet=rs0");
460 assert_eq!(
461 url.to_url(),
462 "mongodb://admin:secret@mongo.prod:27017/mydb?authSource=admin&replicaSet=rs0"
463 );
464 }
465
466 #[test]
467 fn mongo_display_redacts() {
468 let url = MongoUrl::new("mongo.prod", 27017, "admin", "hunter2", "mydb");
469 let display = format!("{url}");
470 assert!(!display.contains("hunter2"));
471 }
472
473 #[test]
474 fn url_percent_encodes_password_with_special_chars() {
475 let url = PostgresUrl::new("db.prod", 5432, "user", "p@ss/w:rd#1=2", "mydb");
479 let s = url.to_url();
480 assert!(s.contains("p%40ss%2Fw%3Ard%231%3D2"), "got: {s}");
482 assert_eq!(s.matches('@').count(), 1, "got: {s}");
485 }
486
487 #[test]
488 fn url_percent_encodes_user_with_special_chars() {
489 let url = PostgresUrl::new("db", 5432, "user@example.com", "pw", "mydb");
490 let s = url.to_url();
491 assert!(s.contains("user%40example.com:pw@"), "got: {s}");
492 }
493
494 #[test]
495 fn url_percent_encodes_db_with_special_chars() {
496 let url = PostgresUrl::new("db", 5432, "u", "p", "tenant/db");
499 let s = url.to_url();
500 assert!(s.contains("/tenant%2Fdb"), "got: {s}");
501 }
502
503 #[test]
504 fn debug_of_dbconnection_redacts_password() {
505 let dbc = DbConnection {
506 host: "db".into(),
507 port: 5432,
508 user: "u".into(),
509 password: SensitiveString::new("the_real_secret"),
510 db: "mydb".into(),
511 params: None,
512 };
513 let debug = format!("{dbc:?}");
514 assert!(!debug.contains("the_real_secret"), "debug leaked: {debug}");
515 assert!(debug.contains("REDACTED"));
516 }
517
518 #[test]
519 fn serialize_dbconnection_redacts_by_default() {
520 let dbc = DbConnection {
521 host: "db".into(),
522 port: 5432,
523 user: "u".into(),
524 password: SensitiveString::new("the_real_secret"),
525 db: "mydb".into(),
526 params: None,
527 };
528 let json = serde_json::to_string(&dbc).unwrap();
529 assert!(!json.contains("the_real_secret"));
530 assert!(json.contains("REDACTED"));
531 }
532
533 #[test]
534 fn round_trip_via_expose_during_preserves_password() {
535 let dbc = DbConnection {
538 host: "db".into(),
539 port: 5432,
540 user: "u".into(),
541 password: SensitiveString::new("the_real_secret"),
542 db: "mydb".into(),
543 params: None,
544 };
545 let round_tripped: DbConnection = crate::expose_during(|| {
546 let v = serde_json::to_value(&dbc).unwrap();
547 serde_json::from_value(v).unwrap()
548 });
549 assert_eq!(round_tripped.password.expose(), "the_real_secret");
550 }
551
552 #[test]
554 fn ipv6_host_is_bracketed() {
555 let dbc = DbConnection {
556 host: "::1".into(),
557 port: 5432,
558 user: "u".into(),
559 password: SensitiveString::new("p"),
560 db: "d".into(),
561 params: None,
562 };
563 let url = dbc.url_with_scheme("postgresql");
564 assert!(url.contains("@[::1]:5432/"), "got: {url}");
565 }
566
567 #[test]
569 fn pre_bracketed_ipv6_host_not_double_bracketed() {
570 let dbc = DbConnection {
571 host: "[fe80::1]".into(),
572 port: 5432,
573 user: "u".into(),
574 password: SensitiveString::new("p"),
575 db: "d".into(),
576 params: None,
577 };
578 let url = dbc.url_with_scheme("postgresql");
579 assert!(url.contains("@[fe80::1]:5432/"), "got: {url}");
580 assert!(!url.contains("[[fe80::1]]"));
581 }
582}