1use crate::DbPool;
5use crate::error::DbError;
6
7pub struct DbConfig {
9 pub url: String,
11 pub max_connections: u32,
13 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 #[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 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 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 #[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 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#[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 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}