Skip to main content

mas_config/sections/
database.rs

1// Copyright 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::{num::NonZeroU32, time::Duration};
9
10use camino::Utf8PathBuf;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use serde_with::serde_as;
14
15use super::ConfigurationSection;
16use crate::schema;
17
18#[expect(clippy::unnecessary_wraps)]
19fn default_connection_string() -> Option<String> {
20    Some("postgresql://".to_owned())
21}
22
23fn default_max_connections() -> NonZeroU32 {
24    NonZeroU32::new(10).unwrap()
25}
26
27fn default_connect_timeout() -> Duration {
28    Duration::from_secs(30)
29}
30
31#[expect(clippy::unnecessary_wraps)]
32fn default_idle_timeout() -> Option<Duration> {
33    Some(Duration::from_mins(10))
34}
35
36#[expect(clippy::unnecessary_wraps)]
37fn default_max_lifetime() -> Option<Duration> {
38    Some(Duration::from_mins(30))
39}
40
41impl Default for DatabaseConfig {
42    fn default() -> Self {
43        Self {
44            uri: default_connection_string(),
45            host: None,
46            port: None,
47            socket: None,
48            username: None,
49            password: None,
50            password_file: None,
51            database: None,
52            ssl_mode: None,
53            ssl_ca: None,
54            ssl_ca_file: None,
55            ssl_certificate: None,
56            ssl_certificate_file: None,
57            ssl_key: None,
58            ssl_key_file: None,
59            max_connections: default_max_connections(),
60            min_connections: Default::default(),
61            connect_timeout: default_connect_timeout(),
62            idle_timeout: default_idle_timeout(),
63            max_lifetime: default_max_lifetime(),
64        }
65    }
66}
67
68/// Options for controlling the level of protection provided for PostgreSQL SSL
69/// connections.
70#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "kebab-case")]
72pub enum PgSslMode {
73    /// Only try a non-SSL connection.
74    Disable,
75
76    /// First try a non-SSL connection; if that fails, try an SSL connection.
77    Allow,
78
79    /// First try an SSL connection; if that fails, try a non-SSL connection.
80    Prefer,
81
82    /// Only try an SSL connection. If a root CA file is present, verify the
83    /// connection in the same way as if `VerifyCa` was specified.
84    Require,
85
86    /// Only try an SSL connection, and verify that the server certificate is
87    /// issued by a trusted certificate authority (CA).
88    VerifyCa,
89
90    /// Only try an SSL connection; verify that the server certificate is issued
91    /// by a trusted CA and that the requested server host name matches that
92    /// in the certificate.
93    VerifyFull,
94}
95
96/// Database connection configuration
97#[serde_as]
98#[derive(Debug, Serialize, Deserialize, JsonSchema)]
99pub struct DatabaseConfig {
100    /// Connection URI
101    ///
102    /// This must not be specified if `host`, `port`, `socket`, `username`,
103    /// `password`, or `database` are specified.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    #[schemars(url, default = "default_connection_string")]
106    pub uri: Option<String>,
107
108    /// Name of host to connect to
109    ///
110    /// This must not be specified if `uri` is specified.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    #[schemars(with = "Option::<schema::Hostname>")]
113    pub host: Option<String>,
114
115    /// Port number to connect at the server host
116    ///
117    /// This must not be specified if `uri` is specified.
118    #[serde(skip_serializing_if = "Option::is_none")]
119    #[schemars(range(min = 1, max = 65535))]
120    pub port: Option<u16>,
121
122    /// Directory containing the UNIX socket to connect to
123    ///
124    /// This must not be specified if `uri` is specified.
125    #[serde(skip_serializing_if = "Option::is_none")]
126    #[schemars(with = "Option<String>")]
127    pub socket: Option<Utf8PathBuf>,
128
129    /// PostgreSQL user name to connect as
130    ///
131    /// This must not be specified if `uri` is specified.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub username: Option<String>,
134
135    /// Password to be used if the server demands password authentication
136    ///
137    /// This must not be specified if `uri` is specified.
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub password: Option<String>,
140
141    /// Path to the password to be used if the server demands password
142    /// authentication
143    ///
144    /// This must not be specified if the `password` or `uri` option is
145    /// specified.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    #[schemars(with = "Option<String>")]
148    pub password_file: Option<Utf8PathBuf>,
149
150    /// The database name
151    ///
152    /// This must not be specified if `uri` is specified.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub database: Option<String>,
155
156    /// How to handle SSL connections
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub ssl_mode: Option<PgSslMode>,
159
160    /// The PEM-encoded root certificate for SSL connections
161    ///
162    /// This must not be specified if the `ssl_ca_file` option is specified.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub ssl_ca: Option<String>,
165
166    /// Path to the root certificate for SSL connections
167    ///
168    /// This must not be specified if the `ssl_ca` option is specified.
169    #[serde(skip_serializing_if = "Option::is_none")]
170    #[schemars(with = "Option<String>")]
171    pub ssl_ca_file: Option<Utf8PathBuf>,
172
173    /// The PEM-encoded client certificate for SSL connections
174    ///
175    /// This must not be specified if the `ssl_certificate_file` option is
176    /// specified.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub ssl_certificate: Option<String>,
179
180    /// Path to the client certificate for SSL connections
181    ///
182    /// This must not be specified if the `ssl_certificate` option is specified.
183    #[serde(skip_serializing_if = "Option::is_none")]
184    #[schemars(with = "Option<String>")]
185    pub ssl_certificate_file: Option<Utf8PathBuf>,
186
187    /// The PEM-encoded client key for SSL connections
188    ///
189    /// This must not be specified if the `ssl_key_file` option is specified.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub ssl_key: Option<String>,
192
193    /// Path to the client key for SSL connections
194    ///
195    /// This must not be specified if the `ssl_key` option is specified.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    #[schemars(with = "Option<String>")]
198    pub ssl_key_file: Option<Utf8PathBuf>,
199
200    /// Set the maximum number of connections the pool should maintain
201    #[serde(default = "default_max_connections")]
202    pub max_connections: NonZeroU32,
203
204    /// Set the minimum number of connections the pool should maintain
205    #[serde(default)]
206    pub min_connections: u32,
207
208    /// Set the amount of time to attempt connecting to the database
209    #[schemars(with = "u64")]
210    #[serde(default = "default_connect_timeout")]
211    #[serde_as(as = "serde_with::DurationSeconds<u64>")]
212    pub connect_timeout: Duration,
213
214    /// Set a maximum idle duration for individual connections
215    #[schemars(with = "Option<u64>")]
216    #[serde(
217        default = "default_idle_timeout",
218        skip_serializing_if = "Option::is_none"
219    )]
220    #[serde_as(as = "Option<serde_with::DurationSeconds<u64>>")]
221    pub idle_timeout: Option<Duration>,
222
223    /// Set the maximum lifetime of individual connections
224    #[schemars(with = "u64")]
225    #[serde(
226        default = "default_max_lifetime",
227        skip_serializing_if = "Option::is_none"
228    )]
229    #[serde_as(as = "Option<serde_with::DurationSeconds<u64>>")]
230    pub max_lifetime: Option<Duration>,
231}
232
233impl ConfigurationSection for DatabaseConfig {
234    const PATH: Option<&'static str> = Some("database");
235
236    fn validate(
237        &self,
238        figment: &figment::Figment,
239    ) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
240        let metadata = figment.find_metadata(Self::PATH.unwrap());
241        let annotate = |mut error: figment::Error| {
242            error.metadata = metadata.cloned();
243            error.profile = Some(figment::Profile::Default);
244            error.path = vec![Self::PATH.unwrap().to_owned()];
245            error
246        };
247
248        // Check that the user did not specify both `uri` and the split options at the
249        // same time
250        let has_split_options = self.host.is_some()
251            || self.port.is_some()
252            || self.socket.is_some()
253            || self.username.is_some()
254            || self.password.is_some()
255            || self.password_file.is_some()
256            || self.database.is_some();
257
258        if self.uri.is_some() && has_split_options {
259            return Err(annotate(figment::error::Error::from(
260                "uri must not be specified if host, port, socket, username, password, password_file, or database are specified".to_owned(),
261            )).into());
262        }
263
264        if self.password.is_some() && self.password_file.is_some() {
265            return Err(annotate(figment::error::Error::from(
266                "password must not be specified if password_file is specified".to_owned(),
267            ))
268            .into());
269        }
270
271        if self.ssl_ca.is_some() && self.ssl_ca_file.is_some() {
272            return Err(annotate(figment::error::Error::from(
273                "ssl_ca must not be specified if ssl_ca_file is specified".to_owned(),
274            ))
275            .into());
276        }
277
278        if self.ssl_certificate.is_some() && self.ssl_certificate_file.is_some() {
279            return Err(annotate(figment::error::Error::from(
280                "ssl_certificate must not be specified if ssl_certificate_file is specified"
281                    .to_owned(),
282            ))
283            .into());
284        }
285
286        if self.ssl_key.is_some() && self.ssl_key_file.is_some() {
287            return Err(annotate(figment::error::Error::from(
288                "ssl_key must not be specified if ssl_key_file is specified".to_owned(),
289            ))
290            .into());
291        }
292
293        if (self.ssl_key.is_some() || self.ssl_key_file.is_some())
294            ^ (self.ssl_certificate.is_some() || self.ssl_certificate_file.is_some())
295        {
296            return Err(annotate(figment::error::Error::from(
297                "both a ssl_certificate and a ssl_key must be set at the same time or none of them"
298                    .to_owned(),
299            ))
300            .into());
301        }
302
303        Ok(())
304    }
305}
306#[cfg(test)]
307mod tests {
308    // The closures passed to `Jail::expect_with` return `figment::Error`, which is
309    // large, and we can't change figment's API.
310    #![expect(clippy::result_large_err)]
311
312    use figment::{
313        Figment, Jail,
314        providers::{Format, Yaml},
315    };
316
317    use super::*;
318
319    #[test]
320    fn load_config() {
321        Jail::expect_with(|jail| {
322            jail.create_file(
323                "config.yaml",
324                r"
325                    database:
326                      uri: postgresql://user:password@host/database
327                ",
328            )?;
329
330            let config = Figment::new()
331                .merge(Yaml::file("config.yaml"))
332                .extract_inner::<DatabaseConfig>("database")?;
333
334            assert_eq!(
335                config.uri.as_deref(),
336                Some("postgresql://user:password@host/database")
337            );
338
339            Ok(())
340        });
341    }
342}