Skip to main content

cdk_postgres/
lib.rs

1//! CDK Postgres
2
3use std::fmt;
4use std::sync::atomic::AtomicBool;
5use std::sync::{Arc, OnceLock};
6use std::time::Duration;
7
8use cdk_common::database::Error;
9use cdk_sql_common::database::{DatabaseConnector, DatabaseExecutor, GenericTransactionHandler};
10use cdk_sql_common::mint::SQLMintAuthDatabase;
11use cdk_sql_common::pool::{DatabaseConfig, DatabasePool};
12use cdk_sql_common::stmt::{Column, Statement};
13use cdk_sql_common::{SQLMintDatabase, SQLWalletDatabase};
14use db::{pg_batch, pg_execute, pg_fetch_all, pg_fetch_one, pg_pluck};
15use tokio::sync::{Mutex, Notify};
16use tokio::time::timeout;
17use tokio_postgres::{Client, Error as PgError, NoTls};
18
19mod db;
20mod tls;
21mod value;
22
23#[derive(Debug)]
24/// Postgres connection pool
25pub struct PgConnectionPool;
26
27#[derive(Clone)]
28/// SSL Mode
29pub enum SslMode {
30    /// No TLS
31    NoTls(NoTls),
32    /// Native TLS
33    NativeTls(postgres_native_tls::MakeTlsConnector),
34}
35impl Default for SslMode {
36    fn default() -> Self {
37        SslMode::NoTls(NoTls {})
38    }
39}
40
41impl fmt::Debug for SslMode {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        let debug_text = match self {
44            Self::NoTls(_) => "NoTls",
45            Self::NativeTls(_) => "NativeTls",
46        };
47
48        write!(f, "SslMode::{debug_text}")
49    }
50}
51
52/// Postgres configuration
53#[derive(Clone)]
54pub struct PgConfig {
55    url: String,
56    schema: Option<String>,
57    tls_mode: Option<String>,
58    max_connections: usize,
59    connection_timeout: Duration,
60}
61
62impl fmt::Debug for PgConfig {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.debug_struct("PgConfig")
65            .field("url", &"[redacted]")
66            .field("schema", &self.schema)
67            .field("tls_mode", &self.tls_mode.as_ref().map(|_| "[configured]"))
68            .field("max_connections", &self.max_connections)
69            .field("connection_timeout", &self.connection_timeout)
70            .finish()
71    }
72}
73
74impl DatabaseConfig for PgConfig {
75    fn default_timeout(&self) -> Duration {
76        self.connection_timeout
77    }
78
79    fn max_size(&self) -> usize {
80        self.max_connections
81    }
82}
83
84/// Default maximum number of connections in the pool
85const DEFAULT_MAX_CONNECTIONS: usize = 20;
86
87/// Default connection timeout in seconds
88const DEFAULT_CONNECTION_TIMEOUT_SECS: u64 = 10;
89
90impl PgConfig {
91    /// Create a new `PgConfig` with explicit TLS mode, pool size, and timeout.
92    ///
93    /// `tls_mode` accepts the same strings as the configuration file:
94    /// `"disable"`, `"prefer"`, `"allow"`, `"require"`, `"verify-ca"`,
95    /// `"verify-full"`.  When `None`, the TLS mode is inferred from
96    /// `sslmode=` in the connection URL. With neither setting, TLS is disabled.
97    /// Invalid modes and TLS connector errors are returned when validating or connecting.
98    /// `allow` uses the same opportunistic TLS policy as `prefer`.
99    pub fn new(
100        conn_str: &str,
101        tls_mode: Option<&str>,
102        max_connections: Option<usize>,
103        connection_timeout_secs: Option<u64>,
104    ) -> Self {
105        let (schema, conn_str) = Self::strip_schema(conn_str);
106        Self {
107            url: conn_str,
108            schema,
109            tls_mode: tls_mode.map(str::to_owned),
110            max_connections: max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS),
111            connection_timeout: Duration::from_secs(
112                connection_timeout_secs.unwrap_or(DEFAULT_CONNECTION_TIMEOUT_SECS),
113            ),
114        }
115    }
116
117    /// Validate connection parameters and construct the configured TLS connector.
118    ///
119    /// Does not open a connection or verify the server's certificate. Uses the
120    /// same TLS policy and connector construction as connection establishment.
121    pub fn validate(&self) -> Result<(), Error> {
122        tls::configure(&self.url, self.tls_mode.as_deref()).map(|_| ())
123    }
124
125    /// Compare effective TLS policies, including certificate verification requirements.
126    ///
127    /// Resolves explicit modes, URL modes, and defaults using the connection policy.
128    /// Invalid settings return an error. Does not connect or construct TLS connectors.
129    pub fn has_same_tls_policy(&self, other: &Self) -> Result<bool, Error> {
130        let (_, policy) = tls::resolve(&self.url, self.tls_mode.as_deref())?;
131        let (_, other_policy) = tls::resolve(&other.url, other.tls_mode.as_deref())?;
132        Ok(policy == other_policy)
133    }
134
135    /// strip schema from the connection string
136    fn strip_schema(input: &str) -> (Option<String>, String) {
137        let mut schema: Option<String> = None;
138
139        // Split by whitespace
140        let mut parts = Vec::new();
141        for token in input.split_whitespace() {
142            if let Some(rest) = token.strip_prefix("schema=") {
143                schema = Some(rest.to_string());
144            } else {
145                parts.push(token);
146            }
147        }
148
149        let cleaned = parts.join(" ");
150        (schema, cleaned)
151    }
152}
153
154impl From<&str> for PgConfig {
155    fn from(conn_str: &str) -> Self {
156        Self::new(conn_str, None, None, None)
157    }
158}
159
160impl DatabasePool for PgConnectionPool {
161    type Config = PgConfig;
162
163    type Connection = PostgresConnection;
164
165    type Error = PgError;
166
167    fn new_resource(
168        config: &Self::Config,
169        stale: Arc<AtomicBool>,
170        timeout: Duration,
171    ) -> Result<Self::Connection, cdk_sql_common::pool::Error<Self::Error>> {
172        Ok(PostgresConnection::new(config.to_owned(), timeout, stale))
173    }
174}
175
176/// A postgres connection
177#[derive(Debug)]
178pub struct PostgresConnection {
179    timeout: Duration,
180    error: Arc<Mutex<Option<cdk_common::database::Error>>>,
181    result: Arc<OnceLock<Client>>,
182    notify: Arc<Notify>,
183}
184
185impl PostgresConnection {
186    /// Creates a new instance
187    pub fn new(config: PgConfig, timeout: Duration, stale: Arc<AtomicBool>) -> Self {
188        let failed = Arc::new(Mutex::new(None));
189        let result = Arc::new(OnceLock::new());
190        let notify = Arc::new(Notify::new());
191        let error_clone = failed.clone();
192        let result_clone = result.clone();
193        let notify_clone = notify.clone();
194
195        async fn select_schema(conn: &Client, schema: &str) -> Result<(), Error> {
196            conn.batch_execute(&format!(
197                r#"
198                    CREATE SCHEMA IF NOT EXISTS "{schema}";
199                    SET search_path TO "{schema}"
200                    "#
201            ))
202            .await
203            .map_err(|e| Error::Database(Box::new(e)))
204        }
205
206        tokio::spawn(async move {
207            let (connection_config, tls) =
208                match tls::configure(&config.url, config.tls_mode.as_deref()) {
209                    Ok(config) => config,
210                    Err(err) => {
211                        *error_clone.lock().await = Some(err);
212                        stale.store(true, std::sync::atomic::Ordering::Release);
213                        notify_clone.notify_waiters();
214                        return;
215                    }
216                };
217            match tls {
218                SslMode::NoTls(tls) => {
219                    let (client, connection) = match connection_config.connect(tls).await {
220                        Ok((client, connection)) => (client, connection),
221                        Err(err) => {
222                            *error_clone.lock().await =
223                                Some(cdk_common::database::Error::Database(Box::new(err)));
224                            stale.store(true, std::sync::atomic::Ordering::Release);
225                            notify_clone.notify_waiters();
226                            return;
227                        }
228                    };
229
230                    let stale_for_spawn = stale.clone();
231                    tokio::spawn(async move {
232                        let _ = connection.await;
233                        stale_for_spawn.store(true, std::sync::atomic::Ordering::Release);
234                    });
235
236                    if let Some(schema) = config.schema.as_ref() {
237                        if let Err(err) = select_schema(&client, schema).await {
238                            *error_clone.lock().await = Some(err);
239                            stale.store(true, std::sync::atomic::Ordering::Release);
240                            notify_clone.notify_waiters();
241                            return;
242                        }
243                    }
244
245                    let _ = result_clone.set(client);
246                    notify_clone.notify_waiters();
247                }
248                SslMode::NativeTls(tls) => {
249                    let (client, connection) = match connection_config.connect(tls).await {
250                        Ok((client, connection)) => (client, connection),
251                        Err(err) => {
252                            *error_clone.lock().await =
253                                Some(cdk_common::database::Error::Database(Box::new(err)));
254                            stale.store(true, std::sync::atomic::Ordering::Release);
255                            notify_clone.notify_waiters();
256                            return;
257                        }
258                    };
259
260                    let stale_for_spawn = stale.clone();
261                    tokio::spawn(async move {
262                        let _ = connection.await;
263                        stale_for_spawn.store(true, std::sync::atomic::Ordering::Release);
264                    });
265
266                    if let Some(schema) = config.schema.as_ref() {
267                        if let Err(err) = select_schema(&client, schema).await {
268                            *error_clone.lock().await = Some(err);
269                            stale.store(true, std::sync::atomic::Ordering::Release);
270                            notify_clone.notify_waiters();
271                            return;
272                        }
273                    }
274
275                    let _ = result_clone.set(client);
276                    notify_clone.notify_waiters();
277                }
278            }
279        });
280
281        Self {
282            error: failed,
283            timeout,
284            result,
285            notify,
286        }
287    }
288
289    /// Gets the wrapped instance or the connection error. The connection is returned as reference,
290    /// and the actual error is returned once, next times a generic error would be returned
291    async fn inner(&self) -> Result<&Client, cdk_common::database::Error> {
292        if let Some(client) = self.result.get() {
293            return Ok(client);
294        }
295
296        if let Some(error) = self.error.lock().await.take() {
297            return Err(error);
298        }
299
300        if timeout(self.timeout, self.notify.notified()).await.is_err() {
301            return Err(cdk_common::database::Error::Internal("Timeout".to_owned()));
302        }
303
304        // Check result again
305        if let Some(client) = self.result.get() {
306            Ok(client)
307        } else if let Some(error) = self.error.lock().await.take() {
308            Err(error)
309        } else {
310            Err(cdk_common::database::Error::Internal(
311                "Failed connection".to_owned(),
312            ))
313        }
314    }
315}
316
317#[async_trait::async_trait]
318impl DatabaseConnector for PostgresConnection {
319    type Transaction = GenericTransactionHandler<Self>;
320}
321
322#[async_trait::async_trait]
323impl DatabaseExecutor for PostgresConnection {
324    fn name() -> &'static str {
325        "postgres"
326    }
327
328    async fn execute(&self, statement: Statement) -> Result<usize, Error> {
329        pg_execute(self.inner().await?, statement).await
330    }
331
332    async fn fetch_one(&self, statement: Statement) -> Result<Option<Vec<Column>>, Error> {
333        pg_fetch_one(self.inner().await?, statement).await
334    }
335
336    async fn fetch_all(&self, statement: Statement) -> Result<Vec<Vec<Column>>, Error> {
337        pg_fetch_all(self.inner().await?, statement).await
338    }
339
340    async fn pluck(&self, statement: Statement) -> Result<Option<Column>, Error> {
341        pg_pluck(self.inner().await?, statement).await
342    }
343
344    async fn batch(&self, statement: Statement) -> Result<(), Error> {
345        pg_batch(self.inner().await?, statement).await
346    }
347}
348
349/// Mint DB implementation with PostgreSQL
350pub type MintPgDatabase = SQLMintDatabase<PgConnectionPool>;
351
352/// Mint Auth database with Postgres
353pub type MintPgAuthDatabase = SQLMintAuthDatabase<PgConnectionPool>;
354
355/// Wallet DB implementation with PostgreSQL
356pub type WalletPgDatabase = SQLWalletDatabase<PgConnectionPool>;
357
358/// Convenience free functions (cannot add inherent impls for a foreign type).
359/// These mirror the Mint patterns and call through to the generic constructors.
360pub async fn new_wallet_pg_database(conn_str: &str) -> Result<WalletPgDatabase, Error> {
361    <SQLWalletDatabase<PgConnectionPool>>::new(conn_str).await
362}
363
364#[cfg(test)]
365mod test {
366    use cdk_common::{mint_db_test, wallet_db_test, QuoteId};
367
368    use super::*;
369
370    async fn provide_mint_db(test_id: String) -> MintPgDatabase {
371        let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
372            .or_else(|_| std::env::var("PG_DB_URL")) // Fallback for compatibility
373            .unwrap_or(
374                "host=localhost user=cdk_user password=cdk_password dbname=cdk_mint port=5432"
375                    .to_owned(),
376            );
377
378        let db_url = format!("{db_url} schema={test_id}");
379
380        MintPgDatabase::new(db_url.as_str())
381            .await
382            .expect("database")
383    }
384
385    mint_db_test!(provide_mint_db);
386
387    #[tokio::test]
388    async fn mint_pool_accepts_single_connection_configuration() {
389        use cdk_common::database::MintDatabase;
390
391        let test_id = format!("test_single_connection_pool_{}", uuid::Uuid::new_v4());
392        let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
393            .or_else(|_| std::env::var("PG_DB_URL"))
394            .unwrap_or(
395                "host=localhost user=cdk_user password=cdk_password dbname=cdk_mint port=5432"
396                    .to_owned(),
397            );
398        let config = PgConfig::new(
399            &format!("{db_url} schema={test_id}"),
400            None,
401            Some(1),
402            Some(10),
403        );
404
405        let db = MintPgDatabase::new(config)
406            .await
407            .expect("single-connection mint pool should remain supported");
408        let regular = MintDatabase::begin_transaction(&db)
409            .await
410            .expect("regular transaction");
411        regular.rollback().await.expect("regular rollback");
412    }
413
414    #[tokio::test]
415    async fn quote_lock_batch_excludes_concurrent_transaction() {
416        use std::sync::Arc;
417        use std::time::Duration;
418
419        use cdk_common::database::MintDatabase;
420
421        let test_id = format!("test_quote_lock_batch_{}", uuid::Uuid::new_v4());
422        let db = Arc::new(provide_mint_db(test_id).await);
423        let first = QuoteId::new();
424        let second = QuoteId::new();
425
426        let mut holder = MintDatabase::begin_transaction(&*db).await.expect("tx");
427        holder
428            .lock_quotes(&[first.clone(), second.clone()])
429            .await
430            .expect("lock");
431
432        let waiter = tokio::spawn({
433            let db = db.clone();
434            async move {
435                let mut tx = MintDatabase::begin_transaction(&*db).await.expect("tx");
436                tx.lock_quotes(&[second, first]).await.expect("lock");
437                tx.commit().await.expect("commit");
438            }
439        });
440
441        tokio::time::sleep(Duration::from_millis(300)).await;
442        assert!(
443            !waiter.is_finished(),
444            "reversed quote batch did not wait for the holder"
445        );
446
447        holder.commit().await.expect("commit");
448        tokio::time::timeout(Duration::from_secs(5), waiter)
449            .await
450            .expect("reversed quote batch remained blocked")
451            .expect("waiter task");
452    }
453
454    #[tokio::test]
455    async fn kvstore_compare_and_swap() {
456        let test_id = format!("test_kvstore_compare_and_swap_{}", uuid::Uuid::new_v4());
457        cdk_common::database::mint::test::kvstore_compare_and_swap(provide_mint_db(test_id).await)
458            .await;
459    }
460
461    #[tokio::test]
462    async fn concurrent_mint_quote_batches_use_consistent_lock_order() {
463        let test_id = format!(
464            "test_concurrent_mint_quote_batches_{}",
465            uuid::Uuid::new_v4()
466        );
467        cdk_common::database::mint::test::concurrent_mint_quote_batches_use_consistent_lock_order(
468            Arc::new(provide_mint_db(test_id).await),
469        )
470        .await;
471    }
472
473    #[tokio::test]
474    async fn concurrent_multi_keyset_spends_use_consistent_lock_order() {
475        let test_id = format!(
476            "test_concurrent_multi_keyset_spends_{}",
477            uuid::Uuid::new_v4()
478        );
479        cdk_common::database::mint::test::concurrent_multi_keyset_spends_use_consistent_lock_order(
480            Arc::new(provide_mint_db(test_id).await),
481        )
482        .await;
483    }
484
485    async fn provide_wallet_db(test_id: String) -> WalletPgDatabase {
486        let db_url = std::env::var("CDK_MINTD_DATABASE_URL")
487            .or_else(|_| std::env::var("PG_DB_URL")) // Fallback for compatibility
488            .unwrap_or(
489                "host=localhost user=cdk_user password=cdk_password dbname=cdk_mint port=5432"
490                    .to_owned(),
491            );
492
493        let db_url = format!("{db_url} schema={test_id}");
494
495        WalletPgDatabase::new(db_url.as_str())
496            .await
497            .expect("database")
498    }
499
500    wallet_db_test!(provide_wallet_db);
501
502    #[tokio::test]
503    async fn failed_initial_connect_marks_connection_stale() {
504        let stale = Arc::new(AtomicBool::new(false));
505        let config = PgConfig::from("host=127.0.0.1 port=1 user=cdk dbname=cdk connect_timeout=1");
506        let conn = PostgresConnection::new(config, Duration::from_secs(5), stale.clone());
507
508        assert!(
509            conn.inner().await.is_err(),
510            "connect to refused port should fail"
511        );
512        tokio::task::yield_now().await;
513
514        assert!(
515            stale.load(std::sync::atomic::Ordering::SeqCst),
516            "failed initial connect should mark the pooled connection stale"
517        );
518    }
519
520    #[test]
521    fn pgconfig_debug_does_not_leak_password() {
522        let config = PgConfig::from("host=localhost user=u password=hunter2secret dbname=d");
523        let rendered = format!("{config:?}");
524
525        assert!(
526            !rendered.contains("hunter2secret"),
527            "PgConfig Debug leaked the DB password: {rendered}"
528        );
529    }
530}