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