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.
12    pub max_connections: u32,
13    /// `SQLite` only: connection pool size. Default 5.
14    ///
15    /// `BEGIN IMMEDIATE` serializes concurrent writers at the `SQLite` level;
16    /// the pool size controls read concurrency only.
17    pub pool_size: u32,
18}
19
20impl Default for DbConfig {
21    fn default() -> Self {
22        Self {
23            url: String::new(),
24            max_connections: 5,
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.max_connections, 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(
50        path: &str,
51        max_connections: u32,
52        pool_size: u32,
53    ) -> Result<DbPool, DbError> {
54        use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
55        use std::str::FromStr;
56
57        let url = if path == ":memory:" {
58            "sqlite::memory:".to_string()
59        } else {
60            let db_path = std::path::PathBuf::from(path);
61
62            if let Some(parent) = db_path.parent()
63                && !parent.as_os_str().is_empty()
64            {
65                tokio::fs::create_dir_all(parent).await?;
66            }
67            // Pre-create with 0o600 so sqlx inherits the mode rather than using the
68            // process umask. sqlx reopens the existing file via SQLITE_OPEN_CREATE.
69            // WAL/SHM sidecars are created by sqlx after the pool opens and will still
70            // inherit the process umask (sqlx limitation — best-effort chmod below).
71            if tokio::fs::metadata(&db_path).await.is_err() {
72                let p = db_path.clone();
73                tokio::task::spawn_blocking(move || {
74                    zeph_common::fs_secure::open_private_truncate(&p)
75                })
76                .await
77                .map_err(|e| std::io::Error::other(format!("spawn_blocking panicked: {e}")))??;
78            }
79            format!("sqlite:{path}?mode=rwc")
80        };
81
82        let opts = SqliteConnectOptions::from_str(&url)
83            .map_err(DbError::Sqlx)?
84            .create_if_missing(true)
85            .foreign_keys(true)
86            .busy_timeout(std::time::Duration::from_secs(5))
87            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
88            .synchronous(sqlx::sqlite::SqliteSynchronous::Normal);
89
90        // BEGIN IMMEDIATE serializes concurrent writers at the SQLite level.
91        // pool_size controls the connection count; max_connections is the upper bound.
92        // In-memory databases are connection-scoped: each new connection is a separate
93        // empty DB. Force a single connection so all queries share the migrated schema.
94        let effective_max = if path == ":memory:" {
95            1
96        } else {
97            max_connections.max(pool_size)
98        };
99        let pool = SqlitePoolOptions::new()
100            .max_connections(effective_max)
101            .min_connections(1)
102            .acquire_timeout(std::time::Duration::from_secs(30))
103            .connect_with(opts)
104            .await
105            .map_err(DbError::Sqlx)?;
106
107        crate::migrate::run_migrations(&pool).await?;
108
109        // Best-effort chmod for .db, .db-wal, and .db-shm. The .db itself was
110        // pre-created with 0o600 above; the WAL/SHM sidecars are created by sqlx
111        // after the pool opens and inherit the process umask, so we fix them here.
112        // There is a small race window between sidecar creation and this chmod;
113        // there is no way to close it without upstream sqlx support.
114        #[cfg(unix)]
115        if path != ":memory:" {
116            let path_owned = path.to_owned();
117            tokio::task::spawn_blocking(move || {
118                use std::os::unix::fs::PermissionsExt as _;
119                for suffix in &["", "-wal", "-shm", "-journal"] {
120                    let p = format!("{path_owned}{suffix}");
121                    if let Ok(metadata) = std::fs::metadata(&p) {
122                        let mut perms = metadata.permissions();
123                        perms.set_mode(0o600);
124                        let _ = std::fs::set_permissions(&p, perms);
125                    }
126                }
127            })
128            .await
129            .map_err(|e| std::io::Error::other(format!("spawn_blocking panicked: {e}")))?;
130        }
131
132        // Run a passive WAL checkpoint after migrations to avoid unbounded WAL growth.
133        // Skipped for in-memory databases (no WAL file).
134        if path != ":memory:" {
135            sqlx::query("PRAGMA wal_checkpoint(PASSIVE)")
136                .execute(&pool)
137                .await
138                .map_err(DbError::Sqlx)?;
139        }
140
141        Ok(pool)
142    }
143
144    #[cfg(feature = "postgres")]
145    async fn connect_postgres(url: &str, pool_size: u32) -> Result<DbPool, DbError> {
146        use sqlx::postgres::PgPoolOptions;
147
148        if !url.contains("sslmode=") {
149            tracing::warn!(
150                "postgres connection string has no sslmode; plaintext connections are allowed"
151            );
152        }
153
154        let pool = PgPoolOptions::new()
155            .max_connections(pool_size)
156            .acquire_timeout(std::time::Duration::from_secs(30))
157            .connect(url)
158            .await
159            .map_err(|e| DbError::Connection {
160                url: redact_url(url).unwrap_or_else(|| "[redacted]".into()),
161                source: e,
162            })?;
163
164        crate::migrate::run_migrations(&pool).await?;
165
166        Ok(pool)
167    }
168}
169
170/// Strip password from a database URL for safe logging.
171///
172/// Replaces `://user:password@` with `://[redacted]@`.
173///
174/// Returns `None` if the URL contains no embedded credentials (already safe).
175/// Returns `Some(redacted)` if credentials were found and replaced.
176#[must_use]
177pub fn redact_url(url: &str) -> Option<String> {
178    use std::sync::LazyLock;
179    static RE: LazyLock<regex::Regex> =
180        LazyLock::new(|| regex::Regex::new(r"://[^:]+:[^@]+@").expect("static regex"));
181    if RE.is_match(url) {
182        Some(RE.replace(url, "://[redacted]@").into_owned())
183    } else {
184        None
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn redact_url_replaces_credentials() {
194        let url = "postgres://user:secret@localhost:5432/zeph";
195        let redacted = redact_url(url).unwrap();
196        assert_eq!(redacted, "postgres://[redacted]@localhost:5432/zeph");
197        assert!(!redacted.contains("secret"));
198    }
199
200    #[test]
201    fn redact_url_returns_none_for_no_credentials() {
202        // URL without credentials — no match, returns None
203        let url = "postgres://localhost:5432/zeph";
204        assert!(redact_url(url).is_none());
205    }
206
207    #[test]
208    fn redact_url_handles_sqlite_path() {
209        let url = "sqlite:/path/to/db";
210        assert!(redact_url(url).is_none());
211    }
212
213    #[cfg(all(unix, feature = "sqlite", not(feature = "postgres")))]
214    #[tokio::test]
215    async fn sqlite_precreated_with_0600() {
216        use std::os::unix::fs::PermissionsExt as _;
217        let dir = tempfile::tempdir().unwrap();
218        let db_path = dir.path().join("test.db");
219        let cfg = DbConfig {
220            url: db_path.to_str().unwrap().to_owned(),
221            max_connections: 1,
222            pool_size: 1,
223        };
224        cfg.connect().await.unwrap();
225        let mode = std::fs::metadata(&db_path).unwrap().permissions().mode() & 0o777;
226        assert_eq!(
227            mode, 0o600,
228            "SQLite DB file must be created with mode 0o600"
229        );
230    }
231}