mod backend;
mod schema_validation;
pub use backend::{AnyConnection, AnyPool, BackendType};
pub use schema_validation::{
escape_password, validate_schema_name, validate_username, SchemaError, UsernameError,
};
#[cfg(feature = "postgres")]
pub use backend::{DbConnection, DbConnectionManager, DbPool};
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
pub use backend::{DbConnection, DbPool};
#[cfg(feature = "sqlite")]
use std::sync::Arc;
#[cfg(feature = "sqlite")]
use tempfile::NamedTempFile;
use thiserror::Error;
use tracing::info;
use url::Url;
#[cfg(feature = "postgres")]
use deadpool_diesel::postgres::{Manager as PgManager, Pool as PgPool, Runtime as PgRuntime};
#[cfg(feature = "sqlite")]
use deadpool_diesel::sqlite::{
Manager as SqliteManager, Pool as SqlitePool, Runtime as SqliteRuntime,
};
#[cfg(feature = "sqlite")]
const SQLITE_POOL_SIZE: usize = 4;
const POOL_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Debug, Error)]
pub enum DatabaseError {
#[error("Failed to create {backend} connection pool: {source}")]
PoolCreation {
backend: &'static str,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("Invalid database URL: {0}")]
InvalidUrl(#[from] url::ParseError),
#[error("Schema validation failed: {0}")]
Schema(#[from] SchemaError),
#[error("Migration failed: {0}")]
Migration(String),
}
static STRICT_SEARCH_PATH: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub fn set_strict_search_path(enabled: bool) {
STRICT_SEARCH_PATH.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
pub fn is_strict_search_path() -> bool {
STRICT_SEARCH_PATH.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(feature = "postgres")]
#[derive(diesel::QueryableByName, Debug)]
struct CurrentSchemaRow {
#[diesel(sql_type = diesel::sql_types::Nullable<diesel::sql_types::Text>)]
s: Option<String>,
}
#[cfg(feature = "postgres")]
fn search_path_pool_error(
tenant_schema: &str,
cause: &str,
) -> deadpool::managed::PoolError<deadpool_diesel::Error> {
let inner = diesel::result::Error::QueryBuilderError(
format!(
"search_path setup failed for tenant '{}': {} (CLOACI-T-0582)",
tenant_schema, cause
)
.into(),
);
deadpool::managed::PoolError::Backend(deadpool_diesel::Error::Ping(inner))
}
#[derive(Clone)]
pub struct Database {
pool: AnyPool,
backend: BackendType,
schema: Option<String>,
#[cfg(feature = "sqlite")]
_memory_tempfile: Option<Arc<NamedTempFile>>,
}
impl std::fmt::Debug for Database {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Database")
.field("backend", &self.backend)
.field("schema", &self.schema)
.field("pool", &"<connection pool>")
.finish()
}
}
impl Database {
pub fn new(connection_string: &str, database_name: &str, max_size: u32) -> Self {
Self::new_with_schema(connection_string, database_name, max_size, None)
}
pub fn new_with_schema(
connection_string: &str,
database_name: &str,
max_size: u32,
schema: Option<&str>,
) -> Self {
Self::try_new_with_schema(connection_string, database_name, max_size, schema)
.expect("Failed to create database connection pool")
}
pub fn try_new_with_schema(
connection_string: &str,
_database_name: &str,
max_size: u32,
schema: Option<&str>,
) -> Result<Self, DatabaseError> {
let backend = BackendType::from_url(connection_string);
let validated_schema = schema
.map(|s| validate_schema_name(s).map(|v| v.to_string()))
.transpose()?;
#[cfg(all(feature = "postgres", feature = "sqlite"))]
match backend {
BackendType::Postgres => {
let connection_url = Self::build_postgres_url(connection_string, _database_name)?;
let manager = PgManager::new(connection_url, PgRuntime::Tokio1);
let pool = PgPool::builder(manager)
.max_size(max_size as usize)
.runtime(PgRuntime::Tokio1)
.wait_timeout(Some(POOL_WAIT_TIMEOUT))
.build()
.map_err(|e| DatabaseError::PoolCreation {
backend: "PostgreSQL",
source: Box::new(e),
})?;
info!(
"PostgreSQL connection pool initialized{}",
validated_schema
.as_ref()
.map_or(String::new(), |s| format!(" with schema '{}'", s))
);
Ok(Self {
pool: AnyPool::Postgres(pool),
backend,
schema: validated_schema,
#[cfg(feature = "sqlite")]
_memory_tempfile: None,
})
}
BackendType::Sqlite => {
let (connection_url, memory_tempfile) =
Self::materialize_sqlite_connection(connection_string)?;
let manager = SqliteManager::new(connection_url, SqliteRuntime::Tokio1);
let sqlite_pool_size = SQLITE_POOL_SIZE;
let pool = SqlitePool::builder(manager)
.max_size(sqlite_pool_size)
.runtime(SqliteRuntime::Tokio1)
.wait_timeout(Some(POOL_WAIT_TIMEOUT))
.build()
.map_err(|e| DatabaseError::PoolCreation {
backend: "SQLite",
source: Box::new(e),
})?;
info!(
"SQLite connection pool initialized (size: {})",
sqlite_pool_size
);
Ok(Self {
pool: AnyPool::Sqlite(pool),
backend,
schema: validated_schema,
_memory_tempfile: memory_tempfile,
})
}
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
{
let _ = backend; let connection_url = Self::build_postgres_url(connection_string, _database_name)?;
let manager = PgManager::new(connection_url, PgRuntime::Tokio1);
let pool = PgPool::builder(manager)
.max_size(max_size as usize)
.runtime(PgRuntime::Tokio1)
.wait_timeout(Some(POOL_WAIT_TIMEOUT))
.build()
.map_err(|e| DatabaseError::PoolCreation {
backend: "PostgreSQL",
source: Box::new(e),
})?;
info!(
"PostgreSQL connection pool initialized{}",
validated_schema
.as_ref()
.map_or(String::new(), |s| format!(" with schema '{}'", s))
);
return Ok(Self {
pool,
backend: BackendType::Postgres,
schema: validated_schema,
#[cfg(feature = "sqlite")]
_memory_tempfile: None,
});
}
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
{
let _ = backend; let (connection_url, memory_tempfile) =
Self::materialize_sqlite_connection(connection_string)?;
let manager = SqliteManager::new(connection_url, SqliteRuntime::Tokio1);
let sqlite_pool_size = SQLITE_POOL_SIZE;
let pool = SqlitePool::builder(manager)
.max_size(sqlite_pool_size)
.runtime(SqliteRuntime::Tokio1)
.wait_timeout(Some(POOL_WAIT_TIMEOUT))
.build()
.map_err(|e| DatabaseError::PoolCreation {
backend: "SQLite",
source: Box::new(e),
})?;
info!(
"SQLite connection pool initialized (size: {})",
sqlite_pool_size
);
return Ok(Self {
pool,
backend: BackendType::Sqlite,
schema: validated_schema,
_memory_tempfile: memory_tempfile,
});
}
}
pub fn backend(&self) -> BackendType {
self.backend
}
pub fn schema(&self) -> Option<&str> {
self.schema.as_deref()
}
pub fn pool(&self) -> AnyPool {
self.pool.clone()
}
pub fn get_connection(&self) -> AnyPool {
self.pool.clone()
}
pub fn close(&self) {
tracing::info!("Closing database connection pool");
self.pool.close();
}
fn build_postgres_url(base_url: &str, database_name: &str) -> Result<String, url::ParseError> {
let mut url = Url::parse(base_url)?;
let has_explicit_db = !url.path().trim_start_matches('/').is_empty();
if !has_explicit_db {
url.set_path(database_name);
}
Ok(url.to_string())
}
#[cfg(feature = "sqlite")]
fn materialize_sqlite_connection(
connection_string: &str,
) -> Result<(String, Option<Arc<NamedTempFile>>), DatabaseError> {
let stripped = connection_string
.strip_prefix("sqlite://")
.unwrap_or(connection_string);
if stripped != ":memory:" {
return Ok((stripped.to_string(), None));
}
let tempfile = NamedTempFile::new().map_err(|e| DatabaseError::PoolCreation {
backend: "SQLite",
source: Box::new(e),
})?;
let path = tempfile
.path()
.to_str()
.ok_or_else(|| DatabaseError::PoolCreation {
backend: "SQLite",
source: Box::new(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"tempfile path is not valid UTF-8",
)),
})?
.to_string();
info!(
"SQLite `:memory:` substituted with tempfile path '{}' (per-Database, cleaned on drop)",
path
);
Ok((path, Some(Arc::new(tempfile))))
}
pub async fn run_migrations(&self) -> Result<(), String> {
use diesel_migrations::MigrationHarness;
#[cfg(all(feature = "postgres", feature = "sqlite"))]
match &self.pool {
AnyPool::Postgres(pool) => {
let conn = pool.get().await.map_err(|e| e.to_string())?;
conn.interact(|conn| {
conn.run_pending_migrations(crate::database::POSTGRES_MIGRATIONS)
.map(|_| ())
.map_err(|e| format!("Failed to run PostgreSQL migrations: {}", e))
})
.await
.map_err(|e| format!("Failed to run migrations: {}", e))??;
}
AnyPool::Sqlite(pool) => {
let conn = pool.get().await.map_err(|e| e.to_string())?;
conn.interact(|conn| {
use diesel::prelude::*;
diesel::sql_query("PRAGMA journal_mode=WAL;")
.execute(conn)
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
diesel::sql_query("PRAGMA busy_timeout=30000;")
.execute(conn)
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
conn.run_pending_migrations(crate::database::SQLITE_MIGRATIONS)
.map(|_| ())
.map_err(|e| format!("Failed to run SQLite migrations: {}", e))
})
.await
.map_err(|e| format!("Failed to run migrations: {}", e))??;
}
}
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
{
let conn = self.pool.get().await.map_err(|e| e.to_string())?;
conn.interact(|conn| {
conn.run_pending_migrations(crate::database::POSTGRES_MIGRATIONS)
.map(|_| ())
.map_err(|e| format!("Failed to run PostgreSQL migrations: {}", e))
})
.await
.map_err(|e| format!("Failed to run migrations: {}", e))?
.map_err(|e| e)?;
}
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
{
let conn = self.pool.get().await.map_err(|e| e.to_string())?;
conn.interact(|conn| {
use diesel::prelude::*;
diesel::sql_query("PRAGMA journal_mode=WAL;")
.execute(conn)
.map_err(|e| format!("Failed to set WAL mode: {}", e))?;
diesel::sql_query("PRAGMA busy_timeout=30000;")
.execute(conn)
.map_err(|e| format!("Failed to set busy_timeout: {}", e))?;
conn.run_pending_migrations(crate::database::SQLITE_MIGRATIONS)
.map(|_| ())
.map_err(|e| format!("Failed to run SQLite migrations: {}", e))
})
.await
.map_err(|e| format!("Failed to run migrations: {}", e))?
.map_err(|e| e)?;
}
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn setup_schema(&self, schema: &str) -> Result<(), String> {
use diesel::prelude::*;
let validated_schema = validate_schema_name(schema).map_err(|e| e.to_string())?;
#[cfg(all(feature = "postgres", feature = "sqlite"))]
let pool = match &self.pool {
AnyPool::Postgres(pool) => pool,
AnyPool::Sqlite(_) => {
return Err("Schema setup is not supported for SQLite".to_string());
}
};
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
let pool = &self.pool;
let conn = pool.get().await.map_err(|e| e.to_string())?;
let schema_name = validated_schema.to_string();
let schema_name_clone = schema_name.clone();
conn.interact(move |conn| {
let create_schema_sql = format!("CREATE SCHEMA IF NOT EXISTS {}", schema_name);
diesel::sql_query(&create_schema_sql).execute(conn)
})
.await
.map_err(|e| format!("Failed to create schema: {}", e))?
.map_err(|e| format!("Failed to create schema: {}", e))?;
conn.interact(move |conn| {
let set_search_path_sql = format!("SET search_path TO {}, public", schema_name_clone);
diesel::sql_query(&set_search_path_sql).execute(conn)
})
.await
.map_err(|e| format!("Failed to set search path: {}", e))?
.map_err(|e| format!("Failed to set search path: {}", e))?;
conn.interact(|conn| {
use diesel_migrations::MigrationHarness;
conn.run_pending_migrations(crate::database::POSTGRES_MIGRATIONS)
.map(|_| ())
.map_err(|e| format!("Failed to run migrations: {}", e))
})
.await
.map_err(|e| format!("Failed to run migrations in schema: {}", e))??;
info!("Schema '{}' set up successfully", schema);
Ok(())
}
#[cfg(feature = "postgres")]
pub async fn get_connection_with_schema(
&self,
) -> Result<
deadpool::managed::Object<PgManager>,
deadpool::managed::PoolError<deadpool_diesel::Error>,
> {
use diesel::prelude::*;
#[cfg(all(feature = "postgres", feature = "sqlite"))]
let pool = match &self.pool {
AnyPool::Postgres(pool) => pool,
AnyPool::Sqlite(_) => {
panic!("get_connection_with_schema called on SQLite backend");
}
};
#[cfg(all(feature = "postgres", not(feature = "sqlite")))]
let pool = &self.pool;
let conn = pool.get().await?;
if let Some(ref schema) = self.schema {
let validated_schema = match validate_schema_name(schema) {
Ok(v) => v.to_string(),
Err(e) => {
drop(conn);
return Err(search_path_pool_error(schema, &format!("{}", e)));
}
};
let schema_name = validated_schema.clone();
let set_result: Result<Result<usize, diesel::result::Error>, _> = conn
.interact(move |conn| {
let set_search_path_sql = format!("SET search_path TO {}, public", schema_name);
diesel::sql_query(&set_search_path_sql).execute(conn)
})
.await;
match set_result {
Ok(Ok(_)) => { }
Ok(Err(diesel_err)) => {
tracing::error!(
tenant_schema = %validated_schema,
error = %diesel_err,
"SET search_path failed; rejecting tenant-scoped connection (CLOACI-T-0582)"
);
drop(conn);
return Err(search_path_pool_error(
&validated_schema,
&format!("{}", diesel_err),
));
}
Err(interact_err) => {
tracing::error!(
tenant_schema = %validated_schema,
error = %interact_err,
"SET search_path interact failed; rejecting connection (CLOACI-T-0582)"
);
drop(conn);
return Err(search_path_pool_error(
&validated_schema,
&format!("{}", interact_err),
));
}
}
if is_strict_search_path() {
let expected_schema = validated_schema.clone();
let probe: Result<Result<CurrentSchemaRow, diesel::result::Error>, _> = conn
.interact(move |conn| {
diesel::sql_query("SELECT current_schema() AS s").get_result(conn)
})
.await;
match probe {
Ok(Ok(row)) if row.s.as_deref() == Some(expected_schema.as_str()) => {
}
Ok(Ok(row)) => {
tracing::error!(
tenant_schema = %expected_schema,
actual = ?row.s,
"current_schema() mismatch — connection search_path is not the expected tenant schema (CLOACI-T-0582)"
);
drop(conn);
return Err(search_path_pool_error(
&expected_schema,
&format!(
"search_path mismatch: expected '{}', got {:?}",
expected_schema, row.s
),
));
}
Ok(Err(diesel_err)) => {
tracing::error!(
tenant_schema = %expected_schema,
error = %diesel_err,
"current_schema() probe failed; rejecting connection (CLOACI-T-0582)"
);
drop(conn);
return Err(search_path_pool_error(
&expected_schema,
&format!("{}", diesel_err),
));
}
Err(interact_err) => {
tracing::error!(
tenant_schema = %expected_schema,
error = %interact_err,
"current_schema() interact failed; rejecting connection (CLOACI-T-0582)"
);
drop(conn);
return Err(search_path_pool_error(
&expected_schema,
&format!("{}", interact_err),
));
}
}
}
}
Ok(conn)
}
#[cfg(feature = "postgres")]
pub async fn get_postgres_connection(
&self,
) -> Result<
deadpool::managed::Object<PgManager>,
deadpool::managed::PoolError<deadpool_diesel::Error>,
> {
self.get_connection_with_schema().await
}
#[cfg(feature = "sqlite")]
pub async fn get_sqlite_connection(
&self,
) -> Result<
deadpool::managed::Object<SqliteManager>,
deadpool::managed::PoolError<deadpool_diesel::Error>,
> {
#[cfg(all(feature = "postgres", feature = "sqlite"))]
let pool = match &self.pool {
AnyPool::Sqlite(pool) => pool,
AnyPool::Postgres(_) => {
panic!("get_sqlite_connection called on PostgreSQL backend");
}
};
#[cfg(all(feature = "sqlite", not(feature = "postgres")))]
let pool = &self.pool;
let conn = pool.get().await?;
conn.interact(|conn| {
use diesel::prelude::*;
let _ = diesel::sql_query("PRAGMA journal_mode=WAL;").execute(conn);
let _ = diesel::sql_query("PRAGMA busy_timeout=30000;").execute(conn);
})
.await
.ok();
Ok(conn)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_postgres_url_respects_explicit_dbname() {
let url =
Database::build_postgres_url("postgres://u:p@host:5432/mydb", "cloacina").unwrap();
assert!(
url.contains("/mydb") && !url.contains("/cloacina"),
"explicit dbname must win: {url}"
);
}
#[test]
fn build_postgres_url_falls_back_when_no_dbname() {
let url = Database::build_postgres_url("postgres://u:p@host:5432", "cloacina").unwrap();
assert!(
url.contains("/cloacina"),
"should fall back to param: {url}"
);
let url2 = Database::build_postgres_url("postgres://u:p@host:5432/", "cloacina").unwrap();
assert!(
url2.contains("/cloacina"),
"bare slash should fall back: {url2}"
);
}
#[test]
fn strict_search_path_default_off() {
let prev = is_strict_search_path();
set_strict_search_path(false);
assert!(!is_strict_search_path());
set_strict_search_path(prev);
}
#[test]
fn strict_search_path_set_round_trip() {
let prev = is_strict_search_path();
set_strict_search_path(true);
assert!(is_strict_search_path());
set_strict_search_path(false);
assert!(!is_strict_search_path());
set_strict_search_path(prev);
}
#[cfg(feature = "postgres")]
#[test]
fn search_path_pool_error_carries_tenant_and_cause() {
let err = search_path_pool_error("tenant_acme", "SET failed: permission denied");
let s = format!("{}", err);
assert!(
s.contains("tenant_acme"),
"error should name the tenant: {s}"
);
assert!(
s.contains("CLOACI-T-0582"),
"error should be marked with the ticket id: {s}"
);
assert!(
s.contains("permission denied"),
"error should carry the underlying cause: {s}"
);
}
#[test]
fn test_postgres_url_parsing_scenarios() {
let mut url = Url::parse("postgres://postgres:postgres@localhost:5432").unwrap();
url.set_path("test_db");
assert_eq!(url.path(), "/test_db");
assert_eq!(url.scheme(), "postgres");
assert_eq!(url.host_str(), Some("localhost"));
assert_eq!(url.port(), Some(5432));
assert_eq!(url.username(), "postgres");
assert_eq!(url.password(), Some("postgres"));
let mut url = Url::parse("postgres://postgres:postgres@localhost").unwrap();
url.set_path("test_db");
assert_eq!(url.port(), None);
let mut url = Url::parse("postgres://localhost:5432").unwrap();
url.set_path("test_db");
assert_eq!(url.username(), "");
assert_eq!(url.password(), None);
assert!(Url::parse("not-a-url").is_err());
}
#[cfg(feature = "sqlite")]
#[test]
fn test_sqlite_connection_strings_passthrough() {
let (url, owner) = Database::materialize_sqlite_connection("/path/to/database.db").unwrap();
assert_eq!(url, "/path/to/database.db");
assert!(owner.is_none());
let (url, owner) = Database::materialize_sqlite_connection("./database.db").unwrap();
assert_eq!(url, "./database.db");
assert!(owner.is_none());
let (url, owner) =
Database::materialize_sqlite_connection("sqlite:///path/to/db.sqlite").unwrap();
assert_eq!(url, "/path/to/db.sqlite");
assert!(owner.is_none());
}
#[cfg(feature = "sqlite")]
#[test]
fn test_sqlite_memory_substitutes_tempfile() {
for input in [":memory:", "sqlite://:memory:"] {
let (url, owner) = Database::materialize_sqlite_connection(input).unwrap();
assert_ne!(url, ":memory:", "input '{}' was not substituted", input);
let owner =
owner.unwrap_or_else(|| panic!("input '{}' returned no tempfile owner", input));
assert!(
std::path::Path::new(&url).exists(),
"substituted path '{}' for input '{}' does not exist on disk",
url,
input
);
drop(owner);
assert!(
!std::path::Path::new(&url).exists(),
"tempfile '{}' for input '{}' was not cleaned on owner drop",
url,
input
);
}
}
#[test]
fn test_backend_type_detection() {
#[cfg(feature = "postgres")]
{
assert_eq!(
BackendType::from_url("postgres://localhost/db"),
BackendType::Postgres
);
assert_eq!(
BackendType::from_url("postgresql://localhost/db"),
BackendType::Postgres
);
}
#[cfg(feature = "sqlite")]
{
assert_eq!(
BackendType::from_url("sqlite:///path/to/db"),
BackendType::Sqlite
);
assert_eq!(
BackendType::from_url("/absolute/path.db"),
BackendType::Sqlite
);
assert_eq!(
BackendType::from_url("./relative/path.db"),
BackendType::Sqlite
);
assert_eq!(BackendType::from_url(":memory:"), BackendType::Sqlite);
assert_eq!(
BackendType::from_url("database.sqlite"),
BackendType::Sqlite
);
assert_eq!(
BackendType::from_url("database.sqlite3"),
BackendType::Sqlite
);
assert_eq!(
BackendType::from_url("file:test?mode=memory&cache=shared"),
BackendType::Sqlite
);
assert_eq!(
BackendType::from_url("file:cloacina_test?mode=memory&cache=shared"),
BackendType::Sqlite
);
}
}
}