Skip to main content

kellnr_db/
con_string.rs

1use std::fmt::{Display, Formatter};
2use std::path::{Path, PathBuf};
3use std::time::Duration;
4
5use kellnr_settings::Settings;
6use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
7
8#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9pub enum ConString {
10    Postgres(PgConString),
11    Sqlite(SqliteConString),
12}
13
14impl Display for ConString {
15    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
16        let con_string = match self {
17            ConString::Postgres(p) => p.to_string(),
18            ConString::Sqlite(s) => s.to_string(),
19        };
20        write!(f, "{con_string}")
21    }
22}
23
24impl ConString {
25    pub fn admin_pwd(&self) -> String {
26        match self {
27            ConString::Postgres(p) => p.admin.pwd.clone(),
28            ConString::Sqlite(s) => s.admin_pwd.clone(),
29        }
30    }
31
32    pub fn admin_token(&self) -> Option<String> {
33        match self {
34            ConString::Postgres(p) => p.admin.token.clone(),
35            ConString::Sqlite(s) => s.admin_token.clone(),
36        }
37    }
38
39    /// Maximum lifetime of a session before it is considered expired.
40    pub fn session_age(&self) -> Duration {
41        match self {
42            ConString::Postgres(p) => p.session_age,
43            ConString::Sqlite(s) => s.session_age,
44        }
45    }
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49pub struct AdminUser {
50    pub pwd: String,
51    pub token: Option<String>,
52}
53
54impl AdminUser {
55    pub fn new(pwd: String, token: Option<String>) -> Self {
56        Self { pwd, token }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Hash)]
61pub enum PgConStringInner {
62    /// A connection string constructed from individual fields.
63    Structured {
64        addr: String,
65        port: u16,
66        db: String,
67        user: String,
68        pwd: String,
69    },
70    /// A raw URL that gets passed directly to sqlx.
71    Raw(String),
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct PgConString {
76    inner: PgConStringInner,
77    admin: AdminUser,
78    pub session_age: Duration,
79}
80
81impl PgConString {
82    pub fn new(
83        addr: &str,
84        port: u16,
85        db: &str,
86        user: &str,
87        pwd: &str,
88        admin: AdminUser,
89        session_age: Duration,
90    ) -> Self {
91        Self {
92            inner: PgConStringInner::Structured {
93                addr: addr.to_owned(),
94                port,
95                db: db.to_owned(),
96                user: user.to_owned(),
97                pwd: pwd.to_owned(),
98            },
99            admin,
100            session_age,
101        }
102    }
103}
104
105impl From<&Settings> for PgConString {
106    fn from(s: &Settings) -> Self {
107        let inner = if let Some(string) = s.postgresql.connection_string.clone() {
108            PgConStringInner::Raw(string)
109        } else {
110            PgConStringInner::Structured {
111                addr: s.postgresql.address.clone(),
112                port: s.postgresql.port,
113                db: s.postgresql.db.clone(),
114                user: s.postgresql.user.clone(),
115                pwd: s.postgresql.pwd.clone(),
116            }
117        };
118
119        Self {
120            inner,
121            admin: AdminUser {
122                pwd: s.setup.admin_pwd.clone(),
123                token: s.setup.admin_token.clone(),
124            },
125            session_age: Duration::from_secs(s.registry.session_age_seconds),
126        }
127    }
128}
129
130/// Characters that must be percent-encoded in the user, password and database
131/// parts of the connection URL. Everything except the RFC 3986 unreserved characters.
132const URL_COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
133    .remove(b'-')
134    .remove(b'.')
135    .remove(b'_')
136    .remove(b'~');
137
138impl Display for PgConString {
139    fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
140        match &self.inner {
141            PgConStringInner::Raw(s) => write!(f, "{s}"),
142            PgConStringInner::Structured {
143                user,
144                pwd,
145                addr,
146                port,
147                db,
148            } => {
149                write!(
150                    f,
151                    "postgres://{}:{}@{}:{}/{}",
152                    utf8_percent_encode(user, URL_COMPONENT),
153                    utf8_percent_encode(pwd, URL_COMPONENT),
154                    addr,
155                    port,
156                    utf8_percent_encode(db, URL_COMPONENT)
157                )
158            }
159        }
160    }
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Hash)]
164pub struct SqliteConString {
165    pub path: PathBuf,
166    pub admin_pwd: String,
167    pub admin_token: Option<String>,
168    pub session_age: Duration,
169}
170
171impl SqliteConString {
172    pub fn new(
173        path: &Path,
174        admin_pwd: &str,
175        admin_token: Option<String>,
176        session_age: Duration,
177    ) -> Self {
178        Self {
179            path: path.to_owned(),
180            admin_pwd: admin_pwd.to_owned(),
181            admin_token,
182            session_age,
183        }
184    }
185}
186
187impl From<&Settings> for SqliteConString {
188    fn from(settings: &Settings) -> Self {
189        Self {
190            path: settings.sqlite_path(),
191            admin_pwd: settings.setup.admin_pwd.clone(),
192            admin_token: settings.setup.admin_token.clone(),
193            session_age: Duration::from_secs(settings.registry.session_age_seconds),
194        }
195    }
196}
197
198impl Display for SqliteConString {
199    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
200        if self.path.to_str() == Some(":memory:") {
201            write!(f, "sqlite::memory:")
202        } else {
203            write!(f, "sqlite://{}?mode=rwc", self.path.display())
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use std::str::FromStr;
211
212    use percent_encoding::percent_decode_str;
213    use sea_orm::sqlx::postgres::PgConnectOptions;
214    use url::Url;
215
216    use super::*;
217
218    fn pg_con_string(user: &str, pwd: &str, db: &str) -> PgConString {
219        PgConString::new(
220            "localhost",
221            5432,
222            db,
223            user,
224            pwd,
225            AdminUser::new("admin".to_string(), None),
226            Duration::from_mins(1),
227        )
228    }
229
230    #[test]
231    fn pg_con_string_plain_credentials_are_unchanged() {
232        let con = pg_con_string("kellnr", "secret", "kellnr-db");
233
234        assert_eq!(
235            con.to_string(),
236            "postgres://kellnr:secret@localhost:5432/kellnr-db"
237        );
238    }
239
240    #[test]
241    fn pg_con_string_encodes_special_characters() {
242        let con = pg_con_string("us@er", "a/b@c:d%e#f?g", "my db");
243
244        assert_eq!(
245            con.to_string(),
246            "postgres://us%40er:a%2Fb%40c%3Ad%25e%23f%3Fg@localhost:5432/my%20db"
247        );
248    }
249
250    #[test]
251    fn pg_con_string_round_trips_through_sqlx() {
252        let user = "us@er:name";
253        let pwd = "cDlJH15F/Xj@:%2F#?&=+ รครถ";
254        let db = "kellnr/db";
255        let con = pg_con_string(user, pwd, db);
256
257        let opts = PgConnectOptions::from_str(&con.to_string()).unwrap();
258        // sqlx has no getter for the password, so decode it the same way sqlx does.
259        let url = Url::parse(&con.to_string()).unwrap();
260        let decoded_pwd = percent_decode_str(url.password().unwrap())
261            .decode_utf8()
262            .unwrap();
263
264        assert_eq!(opts.get_username(), user);
265        assert_eq!(decoded_pwd, pwd);
266        assert_eq!(opts.get_host(), "localhost");
267        assert_eq!(opts.get_port(), 5432);
268        assert_eq!(opts.get_database(), Some(db));
269    }
270}