Skip to main content

schema_core/common/
connection_url.rs

1use nutype::nutype;
2use std::fmt;
3
4// `Display` and `Debug` are hand-written below to redact the password, so a
5// printed or logged `ConnectionUrl` never leaks it. `Serialize` emits the real
6// value: a resolved URL is only serialized into artifacts the operator already
7// holds, and the real value is needed to reconstruct it. Read the real value
8// through `AsRef`/`Deref` (`.as_ref()`), never through `Display`.
9#[nutype(
10    sanitize(trim),
11    validate(regex = r"^(postgresql|postgres)://\S+$"),
12    derive(Clone, AsRef, Deref, Hash, Eq, PartialEq, Serialize, Deserialize)
13)]
14pub struct ConnectionUrl(String);
15
16/// Mask the password in a `scheme://user:password@host…` URL, leaving the rest
17/// intact: `postgres://user:s3cr3t@host/db` → `postgres://user:***@host/db`. A
18/// URL with no password (or no userinfo) is returned unchanged.
19fn redact_password(url: &str) -> String {
20    let Some(after_scheme) = url.find("://").map(|i| i + 3) else {
21        return url.to_owned();
22    };
23    let Some(at) = url[after_scheme..].find('@').map(|i| after_scheme + i) else {
24        return url.to_owned();
25    };
26    let userinfo = &url[after_scheme..at];
27    match userinfo.find(':') {
28        Some(colon) => format!(
29            "{}{}:***{}",
30            &url[..after_scheme],
31            &userinfo[..colon],
32            &url[at..]
33        ),
34        None => url.to_owned(),
35    }
36}
37
38/// Redacts the password — safe to log or print.
39impl fmt::Display for ConnectionUrl {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(&redact_password(self.as_ref()))
42    }
43}
44
45/// Redacts the password — safe to log or print.
46impl fmt::Debug for ConnectionUrl {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "ConnectionUrl({})", redact_password(self.as_ref()))
49    }
50}
51
52#[derive(Debug, Clone, Default)]
53pub enum Scheme {
54    #[default]
55    Postgresql,
56    Postgres,
57}
58
59impl fmt::Display for Scheme {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        match self {
62            Self::Postgresql => write!(f, "postgresql"),
63            Self::Postgres => write!(f, "postgres"),
64        }
65    }
66}
67
68#[bon::bon]
69impl ConnectionUrl {
70    #[builder]
71    pub fn from_parts(
72        #[builder(default)] scheme: Scheme,
73        #[builder(into)] username: String,
74        #[builder(into)] password: Option<String>,
75        #[builder(into)] host: String,
76        port: Option<u16>,
77        #[builder(into)] database: Option<String>,
78    ) -> Result<Self, ConnectionUrlError> {
79        let mut url = format!("{}://{}", scheme, username);
80
81        if let Some(pwd) = password {
82            url.push(':');
83            url.push_str(&pwd);
84        }
85
86        url.push('@');
87        url.push_str(&host);
88
89        if let Some(p) = port {
90            url.push(':');
91            url.push_str(&p.to_string());
92        }
93
94        if let Some(db) = database {
95            url.push('/');
96            url.push_str(&db);
97        }
98
99        Self::try_new(url)
100    }
101}
102
103#[cfg(test)]
104#[allow(clippy::unwrap_used)]
105mod tests {
106    use super::*;
107
108    // --- try_new: valid inputs ---
109
110    #[test]
111    fn valid_full_url() {
112        assert!(ConnectionUrl::try_new("postgresql://user:pass@localhost:5432/mydb").is_ok());
113    }
114
115    #[test]
116    fn valid_minimal_url() {
117        assert!(ConnectionUrl::try_new("postgresql://user@localhost").is_ok());
118    }
119
120    #[test]
121    fn valid_postgres_alias_scheme() {
122        assert!(ConnectionUrl::try_new("postgres://user@localhost/db").is_ok());
123    }
124
125    #[test]
126    fn valid_no_port() {
127        assert!(ConnectionUrl::try_new("postgresql://user:pass@db.example.com/mydb").is_ok());
128    }
129
130    #[test]
131    fn valid_no_database() {
132        assert!(ConnectionUrl::try_new("postgresql://user@localhost:5432").is_ok());
133    }
134
135    #[test]
136    fn sanitizes_surrounding_whitespace() {
137        let url = ConnectionUrl::try_new("  postgresql://user@localhost  ").unwrap();
138        assert_eq!(url.to_string(), "postgresql://user@localhost");
139    }
140
141    // --- try_new: invalid inputs ---
142
143    #[test]
144    fn invalid_empty_string() {
145        assert!(ConnectionUrl::try_new("").is_err());
146    }
147
148    #[test]
149    fn invalid_no_scheme() {
150        assert!(ConnectionUrl::try_new("user:pass@localhost/db").is_err());
151    }
152
153    #[test]
154    fn invalid_unsupported_scheme() {
155        assert!(ConnectionUrl::try_new("mysql://user@localhost/db").is_err());
156    }
157
158    #[test]
159    fn invalid_http_scheme() {
160        assert!(ConnectionUrl::try_new("http://user@localhost").is_err());
161    }
162
163    #[test]
164    fn invalid_scheme_only() {
165        assert!(ConnectionUrl::try_new("postgresql://").is_err());
166    }
167
168    #[test]
169    fn invalid_whitespace_inside_url() {
170        assert!(ConnectionUrl::try_new("postgresql://user @localhost").is_err());
171    }
172
173    // --- builder: valid combinations ---
174
175    #[test]
176    fn builder_full_url() {
177        let url = ConnectionUrl::from_parts()
178            .scheme(Scheme::Postgresql)
179            .username("user")
180            .password("s3cr3t")
181            .host("db.example.com")
182            .port(5432_u16)
183            .database("mydb")
184            .call()
185            .unwrap();
186        // The real value is read through `AsRef`; `Display` redacts.
187        assert_eq!(
188            url.as_ref(),
189            "postgresql://user:s3cr3t@db.example.com:5432/mydb"
190        );
191        assert_eq!(
192            url.to_string(),
193            "postgresql://user:***@db.example.com:5432/mydb"
194        );
195    }
196
197    #[test]
198    fn builder_minimal_url() {
199        let url = ConnectionUrl::from_parts()
200            .username("user")
201            .host("localhost")
202            .call()
203            .unwrap();
204        assert_eq!(url.to_string(), "postgresql://user@localhost");
205    }
206
207    #[test]
208    fn builder_default_scheme_is_postgresql() {
209        let url = ConnectionUrl::from_parts()
210            .username("user")
211            .host("localhost")
212            .call()
213            .unwrap();
214        assert!(url.to_string().starts_with("postgresql://"));
215    }
216
217    #[test]
218    fn builder_postgres_scheme() {
219        let url = ConnectionUrl::from_parts()
220            .scheme(Scheme::Postgres)
221            .username("user")
222            .host("localhost")
223            .call()
224            .unwrap();
225        assert!(url.to_string().starts_with("postgres://"));
226    }
227
228    #[test]
229    fn builder_omits_optional_parts_when_absent() {
230        let url = ConnectionUrl::from_parts()
231            .username("user")
232            .host("localhost")
233            .call()
234            .unwrap()
235            .to_string();
236        assert_eq!(url, "postgresql://user@localhost");
237    }
238}