Skip to main content

hyperi_rustlib/database/
mod.rs

1// Project:   hyperi-rustlib
2// File:      src/database/mod.rs
3// Purpose:   Database connection string builders from env vars and config
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Database connection string builders.
10//!
11//! Builds connection URLs from environment variables with standard prefixes.
12//! Each builder reads `{PREFIX}_HOST`, `{PREFIX}_PORT`, `{PREFIX}_USER`,
13//! `{PREFIX}_PASSWORD`, `{PREFIX}_DB` and constructs the appropriate URL.
14//!
15//! Password fields use [`crate::SensitiveString`] for compile-time safe redaction.
16//!
17//! # Supported Databases
18//!
19//! | Database | Default Port | URL Format |
20//! |----------|-------------|------------|
21//! | PostgreSQL | 5432 | `postgresql://user:pass@host:port/db` |
22//! | ClickHouse | 8123 | `http://user:pass@host:port/db` (HTTP) |
23//! | ClickHouse Native | 9000 | `tcp://user:pass@host:port/db` |
24//! | Redis/Valkey | 6379 | `redis://user:pass@host:port/db` |
25//! | MongoDB | 27017 | `mongodb://user:pass@host:port/db` |
26//!
27//! # Usage
28//!
29//! ```rust
30//! use hyperi_rustlib::database::{PostgresUrl, DatabaseUrl};
31//!
32//! // From explicit values
33//! let url = PostgresUrl::new("db.prod.internal", 5432, "app_user", "secret", "dfe_db");
34//! assert!(url.to_url().starts_with("postgresql://"));
35//!
36//! // From env vars (reads POSTGRES_HOST, POSTGRES_PORT, etc.)
37//! let url = PostgresUrl::from_env("POSTGRES");
38//! ```
39//!
40//! # Config Cascade
41//!
42//! ```yaml
43//! database:
44//!   postgres:
45//!     host: db.prod.internal
46//!     port: 5432
47//!     user: app_user
48//!     password: secret
49//!     db: dfe_db
50//! ```
51
52use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
53use serde::{Deserialize, Serialize};
54
55use crate::SensitiveString;
56
57/// Userinfo-encoding set: every byte that needs percent-encoding in
58/// the `user:password` section of a URL. Per RFC 3986 the userinfo
59/// production allows `unreserved / pct-encoded / sub-delims / ":"`,
60/// but several sub-delims (`?`, `#`, `/`, `@`) would break parsing if
61/// they appear before they're meant to. The safe set is everything
62/// non-alphanumeric except the unreserved punctuation (`-._~`).
63const USERINFO: &AsciiSet = &NON_ALPHANUMERIC
64    .remove(b'-')
65    .remove(b'.')
66    .remove(b'_')
67    .remove(b'~');
68
69/// Path-segment encoding set: same as userinfo for our purposes
70/// (database names are sometimes treated as path components and
71/// must not contain `/`, `?`, or `#`).
72const PATH_SEGMENT: &AsciiSet = USERINFO;
73
74/// Trait for database connection URL builders.
75pub trait DatabaseUrl {
76    /// Build the connection URL string.
77    ///
78    /// Password is included in the URL -- use `.to_url()` only for passing
79    /// to database drivers, never for logging. Use `Display` for safe output.
80    fn to_url(&self) -> String;
81
82    /// The database type name (for logging/metrics).
83    fn db_type(&self) -> &'static str;
84}
85
86/// Common database connection fields.
87///
88/// `password` is a [`SensitiveString`] so its value is redacted in
89/// `Debug` / `serde::Serialize` output -- never use the field directly
90/// in logs. Code that needs to round-trip a `DbConnection` through
91/// `serde` (e.g. figment env overlays) MUST wrap the serialise/deserialise
92/// in [`crate::expose_during`] so the value survives -- that's the same
93/// shape every other consumer needs for `SensitiveString` fields.
94///
95/// `Debug` is derived because `SensitiveString::Debug` already prints
96/// `SensitiveString(***REDACTED***)`, so deriving Debug for the wrapper
97/// no longer leaks the password.
98#[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    /// Password as a redacting type. The previous `String` typing meant
106    /// any accidental Debug / serde dump of a `DbConnection` leaked the
107    /// plaintext password.
108    #[serde(default)]
109    pub password: SensitiveString,
110    #[serde(default)]
111    pub db: String,
112    /// Extra query parameters (e.g., `sslmode=require`).
113    #[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    /// Build a URL with the given scheme, percent-encoding each
139    /// component so that special characters (`:`, `/`, `@`, `?`, `#`,
140    /// `=`, `&`) in user/password/db don't break the parser. The
141    /// previous string-interpolation shape silently corrupted URLs for
142    /// any credential containing one of those bytes -- and those bytes
143    /// are common in generated/random passwords.
144    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        // Query parameters are passed through verbatim -- callers are
164        // expected to provide a properly-encoded `key=value&key2=value2`
165        // string (typically a small fixed set like `sslmode=require`).
166        let params = self
167            .params
168            .as_ref()
169            .map(|p| format!("?{p}"))
170            .unwrap_or_default();
171
172        // IPv6 literals must be bracketed in URL host position
173        // (RFC 3986 ยง3.2.2). Don't bracket what's already bracketed.
174        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/// PostgreSQL connection URL builder.
185#[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    /// Build from env vars: `{prefix}_HOST`, `{prefix}_PORT`, etc.
202    #[must_use]
203    pub fn from_env(prefix: &str) -> Self {
204        Self(DbConnection::from_env_with_defaults(prefix, 5432))
205    }
206
207    /// Add query parameters (e.g., `sslmode=require`).
208    #[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/// ClickHouse HTTP connection URL builder (port 8123).
226#[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    /// Build from env vars with HTTP default port (8123).
243    #[must_use]
244    pub fn from_env(prefix: &str) -> Self {
245        Self(DbConnection::from_env_with_defaults(prefix, 8123))
246    }
247
248    /// Build from env vars with native protocol default port (9000).
249    #[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/// Redis/Valkey connection URL builder.
266#[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    /// Build from env vars: `{prefix}_HOST`, `{prefix}_PORT`, etc.
283    #[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/// MongoDB connection URL builder.
300#[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    /// Build from env vars: `{prefix}_HOST`, `{prefix}_PORT`, etc.
317    #[must_use]
318    pub fn from_env(prefix: &str) -> Self {
319        Self(DbConnection::from_env_with_defaults(prefix, 27017))
320    }
321
322    /// Add query parameters (e.g., `authSource=admin&replicaSet=rs0`).
323    #[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
340/// Safe `Display` implementation -- redacts password.
341impl 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        // Real-world generated passwords routinely contain `@`, `/`, `:`,
476        // `#`, `=`. Without encoding the URL parser misroutes them -- the
477        // `@` ends the userinfo early, the `/` starts the path early.
478        let url = PostgresUrl::new("db.prod", 5432, "user", "p@ss/w:rd#1=2", "mydb");
479        let s = url.to_url();
480        // `@` -> %40, `/` -> %2F, `:` -> %3A, `#` -> %23, `=` -> %3D
481        assert!(s.contains("p%40ss%2Fw%3Ard%231%3D2"), "got: {s}");
482        // Userinfo terminator should be the SINGLE @ separating creds
483        // from host -- never a raw @ from the password.
484        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        // Database names containing `/` are uncommon but legal in some
497        // engines (clickhouse multi-tenant prefixes, for example).
498        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        // Mirrors the dfe-loader figment cascade pattern. Without
536        // expose_during the password becomes the literal REDACTED string.
537        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    /// IPv6 literals get RFC 3986 brackets.
553    #[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    /// Pre-bracketed host stays single-bracketed.
568    #[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}