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;