use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::SqlitePool;
use sqlx::{Sqlite, Transaction};
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Sqlx(#[from] sqlx::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
pub struct HardenedSqlite {
url: String,
journal_mode: SqliteJournalMode,
foreign_keys: bool,
busy_timeout: Duration,
synchronous: SqliteSynchronous,
create_if_missing: bool,
max_connections: u32,
min_connections: Option<u32>,
memory_name: Option<String>,
}
impl HardenedSqlite {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
journal_mode: SqliteJournalMode::Wal,
foreign_keys: true,
busy_timeout: Duration::from_secs(5),
synchronous: SqliteSynchronous::Normal,
create_if_missing: true,
max_connections: 8,
min_connections: None,
memory_name: None,
}
}
pub fn journal_mode(mut self, m: SqliteJournalMode) -> Self {
self.journal_mode = m;
self
}
pub fn foreign_keys(mut self, on: bool) -> Self {
self.foreign_keys = on;
self
}
pub fn busy_timeout(mut self, d: Duration) -> Self {
self.busy_timeout = d;
self
}
pub fn synchronous(mut self, s: SqliteSynchronous) -> Self {
self.synchronous = s;
self
}
pub fn create_if_missing(mut self, on: bool) -> Self {
self.create_if_missing = on;
self
}
pub fn max_connections(mut self, n: u32) -> Self {
self.max_connections = n;
self
}
pub fn min_connections(mut self, n: u32) -> Self {
self.min_connections = Some(n);
self
}
pub fn memory_name(mut self, name: impl Into<String>) -> Self {
self.memory_name = Some(name.into());
self
}
fn is_memory(&self) -> bool {
is_memory_url(&self.url)
}
pub fn connect_options(&self) -> Result<SqliteConnectOptions> {
let opts = if self.is_memory() {
let name = self.memory_name.clone().unwrap_or_else(salted_memory_name);
SqliteConnectOptions::new()
.filename(format!("file:{name}?mode=memory&cache=shared"))
.create_if_missing(true)
.foreign_keys(self.foreign_keys)
.busy_timeout(self.busy_timeout)
.synchronous(self.synchronous)
} else {
SqliteConnectOptions::from_str(&self.url)?
.create_if_missing(self.create_if_missing)
.journal_mode(self.journal_mode)
.foreign_keys(self.foreign_keys)
.busy_timeout(self.busy_timeout)
.synchronous(self.synchronous)
};
Ok(opts)
}
pub fn pragma_statements(&self) -> Vec<String> {
let mut v = Vec::new();
if !self.is_memory() {
let jm = match self.journal_mode {
SqliteJournalMode::Wal => "WAL",
SqliteJournalMode::Delete => "DELETE",
SqliteJournalMode::Truncate => "TRUNCATE",
SqliteJournalMode::Persist => "PERSIST",
SqliteJournalMode::Memory => "MEMORY",
SqliteJournalMode::Off => "OFF",
};
v.push(format!("PRAGMA journal_mode = {jm};"));
}
v.push(format!(
"PRAGMA foreign_keys = {};",
if self.foreign_keys { "ON" } else { "OFF" }
));
v.push(format!(
"PRAGMA busy_timeout = {};",
self.busy_timeout.as_millis()
));
let sync = match self.synchronous {
SqliteSynchronous::Off => "OFF",
SqliteSynchronous::Normal => "NORMAL",
SqliteSynchronous::Full => "FULL",
SqliteSynchronous::Extra => "EXTRA",
};
v.push(format!("PRAGMA synchronous = {sync};"));
v
}
pub async fn connect(self) -> Result<SqlitePool> {
let opts = self.connect_options()?;
let mut pool_opts = SqlitePoolOptions::new().max_connections(self.max_connections);
let min = self
.min_connections
.map(|m| if self.is_memory() { m.max(1) } else { m })
.or(if self.is_memory() { Some(1) } else { None });
if let Some(m) = min {
pool_opts = pool_opts.min_connections(m);
}
Ok(pool_opts.connect_with(opts).await?)
}
}
pub fn is_memory_url(url: &str) -> bool {
matches!(url, ":memory:" | "sqlite::memory:" | "sqlite://:memory:")
}
static MEM_SERIAL: AtomicU64 = AtomicU64::new(0);
fn salted_memory_name() -> String {
let n = MEM_SERIAL.fetch_add(1, Ordering::Relaxed);
format!("sqlite-hardened-{}-{n}", std::process::id())
}
pub async fn begin_immediate(pool: &SqlitePool) -> Result<Transaction<'static, Sqlite>> {
Ok(pool.begin_with("BEGIN IMMEDIATE").await?)
}
#[allow(async_fn_in_trait)]
pub trait SqlitePoolExt {
async fn begin_immediate(&self) -> Result<Transaction<'static, Sqlite>>;
}
impl SqlitePoolExt for SqlitePool {
async fn begin_immediate(&self) -> Result<Transaction<'static, Sqlite>> {
begin_immediate(self).await
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn scalar(pool: &SqlitePool, sql: &str) -> i64 {
sqlx::query_scalar::<_, i64>(sql)
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn file_pool_applies_hardened_pragmas() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("t.db");
let pool = HardenedSqlite::new(format!("sqlite://{}", db.display()))
.connect()
.await
.unwrap();
let jm: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(jm.to_lowercase(), "wal");
assert_eq!(scalar(&pool, "PRAGMA foreign_keys").await, 1);
assert_eq!(scalar(&pool, "PRAGMA busy_timeout").await, 5000);
assert_eq!(scalar(&pool, "PRAGMA synchronous").await, 1); }
#[tokio::test]
async fn overrides_are_honored() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("t.db");
let pool = HardenedSqlite::new(format!("sqlite://{}", db.display()))
.foreign_keys(false)
.busy_timeout(Duration::from_secs(2))
.connect()
.await
.unwrap();
assert_eq!(scalar(&pool, "PRAGMA foreign_keys").await, 0);
assert_eq!(scalar(&pool, "PRAGMA busy_timeout").await, 2000);
}
#[tokio::test]
async fn in_memory_survives_drop_to_zero() {
let pool = HardenedSqlite::new(":memory:").connect().await.unwrap();
sqlx::query("CREATE TABLE t (id INTEGER PRIMARY KEY)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO t (id) VALUES (1)")
.execute(&pool)
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
let n = scalar(&pool, "SELECT COUNT(*) FROM t").await;
assert_eq!(n, 1);
}
#[tokio::test]
async fn two_memory_pools_are_isolated() {
let a = HardenedSqlite::new(":memory:").connect().await.unwrap();
let b = HardenedSqlite::new(":memory:").connect().await.unwrap();
sqlx::query("CREATE TABLE only_in_a (x INTEGER)")
.execute(&a)
.await
.unwrap();
let seen: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='only_in_a'",
)
.fetch_one(&b)
.await
.unwrap();
assert_eq!(seen, 0);
}
#[tokio::test]
async fn begin_immediate_serializes_writers() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("t.db");
let pool = HardenedSqlite::new(format!("sqlite://{}", db.display()))
.connect()
.await
.unwrap();
sqlx::query("CREATE TABLE c (n INTEGER)")
.execute(&pool)
.await
.unwrap();
sqlx::query("INSERT INTO c (n) VALUES (0)")
.execute(&pool)
.await
.unwrap();
let inc = |p: SqlitePool| async move {
let mut tx = p.begin_immediate().await.unwrap();
let cur: i64 = sqlx::query_scalar("SELECT n FROM c")
.fetch_one(&mut *tx)
.await
.unwrap();
sqlx::query("UPDATE c SET n = ?")
.bind(cur + 1)
.execute(&mut *tx)
.await
.unwrap();
tx.commit().await.unwrap();
};
let (a, b) = (pool.clone(), pool.clone());
let (ra, rb) = tokio::join!(tokio::spawn(inc(a)), tokio::spawn(inc(b)));
ra.unwrap();
rb.unwrap();
let n: i64 = sqlx::query_scalar("SELECT n FROM c")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(n, 2, "both writers committed without a lost update");
}
#[test]
fn pragma_statements_reflect_settings() {
let stmts = HardenedSqlite::new("sqlite://x.db").pragma_statements();
assert!(stmts.iter().any(|s| s == "PRAGMA journal_mode = WAL;"));
assert!(stmts.iter().any(|s| s == "PRAGMA foreign_keys = ON;"));
assert!(stmts.iter().any(|s| s == "PRAGMA busy_timeout = 5000;"));
assert!(stmts.iter().any(|s| s == "PRAGMA synchronous = NORMAL;"));
}
#[test]
fn pragma_statements_omit_wal_for_memory() {
let stmts = HardenedSqlite::new(":memory:").pragma_statements();
assert!(!stmts.iter().any(|s| s.contains("journal_mode")));
}
#[tokio::test]
async fn hardened_settings_enforced_with_url_params_present() {
let tmp = tempfile::tempdir().unwrap();
let db = tmp.path().join("t.db");
let url = format!("sqlite://{}?mode=rwc", db.display());
let pool = HardenedSqlite::new(url).connect().await.unwrap();
assert_eq!(scalar(&pool, "PRAGMA foreign_keys").await, 1);
assert_eq!(scalar(&pool, "PRAGMA busy_timeout").await, 5000);
}
}