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 struct PgConString {
72    addr: String,
73    port: u16,
74    db: String,
75    user: String,
76    pwd: String,
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            addr: addr.to_owned(),
93            port,
94            db: db.to_owned(),
95            user: user.to_owned(),
96            pwd: pwd.to_owned(),
97            admin,
98            session_age,
99        }
100    }
101}
102
103impl From<&Settings> for PgConString {
104    fn from(s: &Settings) -> Self {
105        Self {
106            addr: s.postgresql.address.clone(),
107            port: s.postgresql.port,
108            db: s.postgresql.db.clone(),
109            user: s.postgresql.user.clone(),
110            pwd: s.postgresql.pwd.clone(),
111            admin: AdminUser {
112                pwd: s.setup.admin_pwd.clone(),
113                token: s.setup.admin_token.clone(),
114                salt: generate_salt(),
115            },
116            session_age: Duration::from_secs(s.registry.session_age_seconds),
117        }
118    }
119}
120
121/// Characters that must be percent-encoded in the user, password and database
122/// parts of the connection URL. Everything except the RFC 3986 unreserved characters.
123const URL_COMPONENT: &AsciiSet = &NON_ALPHANUMERIC
124    .remove(b'-')
125    .remove(b'.')
126    .remove(b'_')
127    .remove(b'~');
128
129impl Display for PgConString {
130    fn fmt(&self, f: &mut Formatter) -> Result<(), std::fmt::Error> {
131        write!(
132            f,
133            "postgres://{}:{}@{}:{}/{}",
134            utf8_percent_encode(&self.user, URL_COMPONENT),
135            utf8_percent_encode(&self.pwd, URL_COMPONENT),
136            self.addr,
137            self.port,
138            utf8_percent_encode(&self.db, URL_COMPONENT)
139        )
140    }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Hash)]
144pub struct SqliteConString {
145    pub path: PathBuf,
146    pub salt: String,
147    pub admin_pwd: String,
148    pub admin_token: Option<String>,
149    pub session_age: Duration,
150}
151
152impl SqliteConString {
153    pub fn new(
154        path: &Path,
155        salt: &str,
156        admin_pwd: &str,
157        admin_token: Option<String>,
158        session_age: Duration,
159    ) -> Self {
160        Self {
161            path: path.to_owned(),
162            salt: salt.to_owned(),
163            admin_pwd: admin_pwd.to_owned(),
164            admin_token,
165            session_age,
166        }
167    }
168}
169
170impl From<&Settings> for SqliteConString {
171    fn from(settings: &Settings) -> Self {
172        Self {
173            path: settings.sqlite_path(),
174            salt: generate_salt(),
175            admin_pwd: settings.setup.admin_pwd.clone(),
176            admin_token: settings.setup.admin_token.clone(),
177            session_age: Duration::from_secs(settings.registry.session_age_seconds),
178        }
179    }
180}
181
182impl Display for SqliteConString {
183    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
184        if self.path.to_str() == Some(":memory:") {
185            write!(f, "sqlite::memory:")
186        } else {
187            write!(f, "sqlite://{}?mode=rwc", self.path.display())
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use std::str::FromStr;
195
196    use percent_encoding::percent_decode_str;
197    use sea_orm::sqlx::postgres::PgConnectOptions;
198    use url::Url;
199
200    use super::*;
201
202    fn pg_con_string(user: &str, pwd: &str, db: &str) -> PgConString {
203        PgConString::new(
204            "localhost",
205            5432,
206            db,
207            user,
208            pwd,
209            AdminUser::new("admin".to_string(), None, "salt".to_string()),
210            Duration::from_mins(1),
211        )
212    }
213
214    #[test]
215    fn pg_con_string_plain_credentials_are_unchanged() {
216        let con = pg_con_string("kellnr", "secret", "kellnr-db");
217
218        assert_eq!(
219            con.to_string(),
220            "postgres://kellnr:secret@localhost:5432/kellnr-db"
221        );
222    }
223
224    #[test]
225    fn pg_con_string_encodes_special_characters() {
226        let con = pg_con_string("us@er", "a/b@c:d%e#f?g", "my db");
227
228        assert_eq!(
229            con.to_string(),
230            "postgres://us%40er:a%2Fb%40c%3Ad%25e%23f%3Fg@localhost:5432/my%20db"
231        );
232    }
233
234    #[test]
235    fn pg_con_string_round_trips_through_sqlx() {
236        let user = "us@er:name";
237        let pwd = "cDlJH15F/Xj@:%2F#?&=+ รครถ";
238        let db = "kellnr/db";
239        let con = pg_con_string(user, pwd, db);
240
241        let opts = PgConnectOptions::from_str(&con.to_string()).unwrap();
242        // sqlx has no getter for the password, so decode it the same way sqlx does.
243        let url = Url::parse(&con.to_string()).unwrap();
244        let decoded_pwd = percent_decode_str(url.password().unwrap())
245            .decode_utf8()
246            .unwrap();
247
248        assert_eq!(opts.get_username(), user);
249        assert_eq!(decoded_pwd, pwd);
250        assert_eq!(opts.get_host(), "localhost");
251        assert_eq!(opts.get_port(), 5432);
252        assert_eq!(opts.get_database(), Some(db));
253    }
254}