Skip to main content

zeph_db/
pool.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::DbPool;
5use crate::error::DbError;
6
7/// Configuration for database pool construction.
8pub struct DbConfig {
9    /// Database URL. `SQLite`: file path or `:memory:`. `PostgreSQL`: connection URL.
10    pub url: String,
11    /// Maximum number of connections in the pool, passed to `sqlx`'s
12    /// `.max_connections()` builder call for both backends. Default 5.
13    ///
14    /// `SQLite`: `BEGIN IMMEDIATE` serializes concurrent writers at the `SQLite` level,
15    /// so this bound controls read concurrency only. In-memory databases are always
16    /// forced to a single connection regardless of this value, since each new
17    /// `:memory:` connection opens an isolated, unmigrated database.
18    pub pool_size: u32,
19}
20
21impl Default for DbConfig {
22    fn default() -> Self {
23        Self {
24            url: String::new(),
25            pool_size: 5,
26        }
27    }
28}
29
30impl DbConfig {
31    /// Connect to the database and run migrations.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`DbError`] if connection or migration fails.
36    #[tracing::instrument(name = "db.pool.connect", skip_all, err)]
37    pub async fn connect(&self) -> Result<DbPool, DbError> {
38        #[cfg(all(feature = "sqlite", not(feature = "postgres")))]
39        {
40            Self::connect_sqlite(&self.url, self.pool_size).await
41        }
42        #[cfg(feature = "postgres")]
43        {
44            Self::connect_postgres(&self.url, self.pool_size).await
45        }
46    }
47
48    #[cfg(all(feature = "sqlite", not(feature = "postgres")))]
49    async fn connect_sqlite(path: &str, pool_size: u32) -> Result<DbPool, DbError> {
50        use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
51        use std::str::FromStr;
52
53        let url = if path == ":memory:" {
54            "sqlite::memory:".to_string()
55        } else {
56            let db_path = std::path::PathBuf::from(path);
57
58            if let Some(parent) = db_path.parent()
59                && !parent.as_os_str().is_empty()
60            {
61                tokio::fs::create_dir_all(parent).await?;
62            }
63            // Pre-create with 0o600 so sqlx inherits the mode rather than using the
64            // process umask. sqlx reopens the existing file via SQLITE_OPEN_CREATE.
65            // WAL/SHM sidecars are created by sqlx after the pool opens and will still
66            // inherit the process umask (sqlx limitation — best-effort chmod below).
67            if tokio::fs::metadata(&db_path).await.is_err() {
68                let p = db_path.clone();
69                tokio::task::spawn_blocking(move || {
70                    zeph_common::fs_secure::open_private_truncate(&p)
71                })
72                .await
73                .map_err(|e| std::io::Error::other(format!("spawn_blocking panicked: {e}")))??;
74            }
75            format!("sqlite:{path}?mode=rwc")
76        };
77
78        let opts = SqliteConnectOptions::from_str(&url)
79            .map_err(DbError::Sqlx)?
80            .create_if_missing(true)
81            .foreign_keys(true)
82            .busy_timeout(std::time::Duration::from_secs(5))
83            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
84            .synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
85
86        // BEGIN IMMEDIATE serializes concurrent writers at the SQLite level; pool_size
87        // controls read concurrency only. In-memory databases are connection-scoped:
88        // each new connection is a separate empty DB. Force a single connection so all
89        // queries share the migrated schema.
90        let effective_max = if path == ":memory:" { 1 } else { pool_size };
91        let pool = SqlitePoolOptions::new()
92            .max_connections(effective_max)
93            .min_connections(1)
94            .acquire_timeout(std::time::Duration::from_secs(30))
95            .connect_with(opts)
96            .await
97            .map_err(DbError::Sqlx)?;
98
99        crate::migrate::run_migrations(&pool).await?;
100
101        // Best-effort chmod for .db, .db-wal, and .db-shm. The .db itself was
102        // pre-created with 0o600 above; the WAL/SHM sidecars are created by sqlx
103        // after the pool opens and inherit the process umask, so we fix them here.
104        // There is a small race window between sidecar creation and this chmod;
105        // there is no way to close it without upstream sqlx support.
106        #[cfg(unix)]
107        if path != ":memory:" {
108            let path_owned = path.to_owned();
109            tokio::task::spawn_blocking(move || {
110                use std::os::unix::fs::PermissionsExt as _;
111                for suffix in &["", "-wal", "-shm", "-journal"] {
112                    let p = format!("{path_owned}{suffix}");
113                    if let Ok(metadata) = std::fs::metadata(&p) {
114                        let mut perms = metadata.permissions();
115                        perms.set_mode(0o600);
116                        let _ = std::fs::set_permissions(&p, perms);
117                    }
118                }
119            })
120            .await
121            .map_err(|e| std::io::Error::other(format!("spawn_blocking panicked: {e}")))?;
122        }
123
124        // Run a passive WAL checkpoint after migrations to avoid unbounded WAL growth.
125        // Skipped for in-memory databases (no WAL file).
126        if path != ":memory:" {
127            sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
128                .execute(&pool)
129                .await
130                .map_err(DbError::Sqlx)?;
131        }
132
133        Ok(pool)
134    }
135
136    #[cfg(feature = "postgres")]
137    async fn connect_postgres(url: &str, pool_size: u32) -> Result<DbPool, DbError> {
138        use sqlx::postgres::PgPoolOptions;
139
140        if !url.contains("sslmode=") {
141            tracing::warn!(
142                "postgres connection string has no sslmode; plaintext connections are allowed"
143            );
144        }
145
146        let pool = PgPoolOptions::new()
147            .max_connections(pool_size)
148            .acquire_timeout(std::time::Duration::from_secs(30))
149            .connect(url)
150            .await
151            .map_err(|e| DbError::Connection {
152                url: redact_url(url).unwrap_or_else(|| "[redacted]".into()),
153                source: e,
154            })?;
155
156        crate::migrate::run_migrations(&pool).await?;
157
158        Ok(pool)
159    }
160}
161
162/// Strip credentials from a database URL for safe logging.
163///
164/// Replaces the whole userinfo component (`user[:password]@`) with `[redacted]@`.
165/// Uses [`url::Url`] rather than a regex so the split between userinfo and host
166/// follows the same rule real clients use: the LAST unescaped `@` before the
167/// path/port delimits userinfo, so a password containing `@` (e.g.
168/// `postgres://user:p@ss@host/db`) is redacted in full instead of leaving its
169/// tail exposed.
170///
171/// Also recognizes two non-userinfo credential forms accepted by libpq and
172/// redacts the whole URL for them, rather than attempting a partial rewrite:
173/// - query-param URIs, e.g. `postgresql://host/db?password=secret`
174/// - key-value DSNs, e.g. `host=localhost dbname=zeph password=secret`
175///
176/// Returns `None` if the URL matches none of the recognized credential forms
177/// above (already safe). Returns `Some(redacted)` otherwise. This covers only
178/// the known common forms listed above — a URL carrying credentials in some
179/// other, unrecognized shape can still return `None`, so callers that fall
180/// back to the raw URL on `None` should not treat that as an absolute
181/// guarantee against leaking credentials.
182///
183/// # Examples
184///
185/// ```
186/// use zeph_db::redact_url;
187///
188/// assert_eq!(
189///     redact_url("postgres://user:secret@host:5432/db").unwrap(),
190///     "postgres://[redacted]@host:5432/db"
191/// );
192/// assert_eq!(redact_url("sqlite:///data/zeph.db"), None);
193/// ```
194#[must_use]
195pub fn redact_url(url: &str) -> Option<String> {
196    match url::Url::parse(url) {
197        Ok(mut parsed) => {
198            let has_userinfo = !parsed.username().is_empty() || parsed.password().is_some();
199            let has_password_param = parsed.query().is_some_and(contains_password_assignment);
200            if !has_userinfo && !has_password_param {
201                return None;
202            }
203            if has_password_param {
204                // Query-string credentials (libpq's `?password=...`) aren't safe
205                // to selectively strip without parsing every possible param
206                // dialect; redact the whole URL instead.
207                return Some("[redacted]".to_string());
208            }
209            // Clear userinfo entirely (an empty username + no password serializes
210            // with no "@" at all), then splice in the literal "[redacted]@" marker.
211            // Setting the username directly to "[redacted]" would percent-encode
212            // the brackets (`%5Bredacted%5D`), which is correct but not the
213            // human-readable marker this function promises callers.
214            if parsed.set_username("").is_err() || parsed.set_password(None).is_err() {
215                return Some("[redacted]".to_string());
216            }
217            let without_userinfo = parsed.as_str();
218            let host_start = without_userinfo.find("://")? + "://".len();
219            let mut redacted = String::with_capacity(without_userinfo.len() + 11);
220            redacted.push_str(&without_userinfo[..host_start]);
221            redacted.push_str("[redacted]@");
222            redacted.push_str(&without_userinfo[host_start..]);
223            Some(redacted)
224        }
225        Err(_) if url.contains('@') || contains_password_assignment(url) => {
226            Some("[redacted]".to_string())
227        }
228        Err(_) => None,
229    }
230}
231
232/// Case-insensitive check for a `password=` key-value assignment, as used by
233/// libpq query-param URIs (`?password=...`) and key-value DSNs
234/// (`host=... password=...`). Whitespace between `password` and `=` is
235/// tolerated since libpq DSNs allow it (`password = secret`).
236fn contains_password_assignment(s: &str) -> bool {
237    let lower = s.to_ascii_lowercase();
238    let mut offset = 0;
239    while let Some(idx) = lower[offset..].find("password") {
240        let abs = offset + idx;
241        if lower[abs + "password".len()..]
242            .trim_start()
243            .starts_with('=')
244        {
245            return true;
246        }
247        offset = abs + 1;
248    }
249    false
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn redact_url_replaces_credentials() {
258        let url = "postgres://user:secret@localhost:5432/zeph";
259        let redacted = redact_url(url).unwrap();
260        assert_eq!(redacted, "postgres://[redacted]@localhost:5432/zeph");
261        assert!(!redacted.contains("secret"));
262    }
263
264    #[test]
265    fn redact_url_returns_none_for_no_credentials() {
266        // URL without credentials — no match, returns None
267        let url = "postgres://localhost:5432/zeph";
268        assert!(redact_url(url).is_none());
269    }
270
271    #[test]
272    fn redact_url_handles_sqlite_path() {
273        let url = "sqlite:/path/to/db";
274        assert!(redact_url(url).is_none());
275    }
276
277    #[test]
278    fn redact_url_fully_redacts_password_with_single_at() {
279        // Regression test for #5969: a password containing `@` used to leave
280        // its tail exposed because the old regex stopped at the first `@`.
281        let url = "postgres://user:p@ss@host:5432/db";
282        let redacted = redact_url(url).unwrap();
283        assert_eq!(redacted, "postgres://[redacted]@host:5432/db");
284        assert!(!redacted.contains("ss@host") && !redacted.contains("p@ss"));
285    }
286
287    #[test]
288    fn redact_url_fully_redacts_password_with_multiple_at() {
289        let url = "postgres://user:pa@ss@wo@rd@host:5432/db";
290        let redacted = redact_url(url).unwrap();
291        assert_eq!(redacted, "postgres://[redacted]@host:5432/db");
292        assert!(!redacted.contains("pa@ss@wo@rd"));
293    }
294
295    #[test]
296    fn redact_url_fully_redacts_username_with_at() {
297        let url = "postgres://us@er:pass@host:5432/db";
298        let redacted = redact_url(url).unwrap();
299        assert_eq!(redacted, "postgres://[redacted]@host:5432/db");
300        assert!(!redacted.contains("pass") && !redacted.contains("us@er"));
301    }
302
303    #[test]
304    fn redact_url_redacts_username_only_no_password() {
305        // No password separator — the bare username is still userinfo and is
306        // redacted too (a change from the old regex, which required a `:` and
307        // left a lone username exposed).
308        let url = "postgres://user@host:5432/db";
309        let redacted = redact_url(url).unwrap();
310        assert_eq!(redacted, "postgres://[redacted]@host:5432/db");
311    }
312
313    #[test]
314    fn redact_url_unparseable_with_at_is_conservatively_redacted() {
315        let url = "not a valid url but has user:pass@host in it";
316        let redacted = redact_url(url).unwrap();
317        assert_eq!(redacted, "[redacted]");
318    }
319
320    #[test]
321    fn redact_url_unparseable_without_at_returns_none() {
322        let url = "not a valid url at all";
323        assert!(redact_url(url).is_none());
324    }
325
326    #[test]
327    fn redact_url_redacts_libpq_query_param_password() {
328        // libpq accepts credentials as query params, not just userinfo.
329        let url = "postgresql://localhost/db?user=admin&password=s3cr3t";
330        let redacted = redact_url(url).unwrap();
331        assert_eq!(redacted, "[redacted]");
332        assert!(!redacted.contains("s3cr3t"));
333    }
334
335    #[test]
336    fn redact_url_redacts_libpq_key_value_dsn_password() {
337        // libpq key-value DSNs are not URLs at all and fail url::Url::parse.
338        let url = "host=localhost dbname=zeph password=s3cr3t";
339        let redacted = redact_url(url).unwrap();
340        assert_eq!(redacted, "[redacted]");
341        assert!(!redacted.contains("s3cr3t"));
342    }
343
344    #[cfg(all(unix, feature = "sqlite", not(feature = "postgres")))]
345    #[tokio::test]
346    async fn sqlite_precreated_with_0600() {
347        use std::os::unix::fs::PermissionsExt as _;
348        let dir = tempfile::tempdir().unwrap();
349        let db_path = dir.path().join("test.db");
350        let cfg = DbConfig {
351            url: db_path.to_str().unwrap().to_owned(),
352            pool_size: 1,
353        };
354        cfg.connect().await.unwrap();
355        let mode = std::fs::metadata(&db_path).unwrap().permissions().mode() & 0o777;
356        assert_eq!(
357            mode, 0o600,
358            "SQLite DB file must be created with mode 0o600"
359        );
360    }
361}