1use crate::DbPool;
5use crate::error::DbError;
6
7pub struct DbConfig {
9 pub url: String,
11 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 #[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 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 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 #[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 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#[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 return Some("[redacted]".to_string());
208 }
209 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
232fn 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 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 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 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 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 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}