pub mod migrate;
pub mod models;
pub mod queries;
use crossbeam_queue::ArrayQueue;
use rusqlite::{Connection, Transaction, TransactionBehavior};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::error::LificError;
const READ_POOL_SIZE: usize = 8;
#[derive(Clone)]
pub struct DbPool {
writer: Arc<Mutex<Connection>>,
readers: Arc<ArrayQueue<Connection>>,
path: PathBuf,
export_slots: Arc<Semaphore>,
}
pub struct ReadConn {
conn: Option<Connection>,
pool: Arc<ArrayQueue<Connection>>,
}
impl std::ops::Deref for ReadConn {
type Target = Connection;
fn deref(&self) -> &Connection {
self.conn.as_ref().unwrap()
}
}
impl Drop for ReadConn {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
let _ = self.pool.push(conn);
}
}
}
impl DbPool {
pub(crate) fn acquire_export_slot(&self) -> Result<OwnedSemaphorePermit, LificError> {
self.export_slots
.clone()
.try_acquire_owned()
.map_err(|_| LificError::TooManyRequests("too many exports are already running".into()))
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn read(&self) -> Result<ReadConn, LificError> {
match self.readers.pop() {
Some(conn) => Ok(ReadConn {
conn: Some(conn),
pool: Arc::clone(&self.readers),
}),
None => {
let conn = open_read_connection(&self.path)?;
Ok(ReadConn {
conn: Some(conn),
pool: Arc::clone(&self.readers),
})
}
}
}
fn lock_writer(&self) -> Result<std::sync::MutexGuard<'_, Connection>, LificError> {
self.writer
.lock()
.map_err(|error| LificError::Internal(format!("write lock poisoned: {error}")))
}
pub fn write(&self) -> Result<std::sync::MutexGuard<'_, Connection>, LificError> {
let connection = self.lock_writer()?;
crate::actor::stamp(&connection, &crate::actor::current());
Ok(connection)
}
pub fn transaction<T>(
&self,
operation: impl FnOnce(&Transaction<'_>) -> Result<T, LificError>,
) -> Result<T, LificError> {
let mut connection = self.lock_writer()?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
crate::actor::stamp(&transaction, &crate::actor::current());
let result = operation(&transaction)?;
transaction.commit()?;
Ok(result)
}
}
fn apply_pragmas(conn: &Connection) -> Result<(), LificError> {
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -8000;
PRAGMA mmap_size = 67108864;",
)?;
conn.set_prepared_statement_cache_capacity(64);
Ok(())
}
fn disable_sqlite_memstatus() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| unsafe {
rusqlite::ffi::sqlite3_config(rusqlite::ffi::SQLITE_CONFIG_MEMSTATUS, 0i32);
});
}
fn open_read_connection(path: &Path) -> Result<Connection, LificError> {
let conn = Connection::open_with_flags(
path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY
| rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX
| rusqlite::OpenFlags::SQLITE_OPEN_URI,
)?;
conn.execute_batch(
"PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA cache_size = -4000;
PRAGMA mmap_size = 67108864;",
)?;
conn.set_prepared_statement_cache_capacity(64);
Ok(conn)
}
#[cfg(test)]
static TEST_DB_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(test)]
pub fn open_memory() -> Result<DbPool, LificError> {
disable_sqlite_memstatus();
let name = format!(
"file:lific_test_{}?mode=memory&cache=shared",
TEST_DB_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
);
let writer = Connection::open_with_flags(
&name,
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
| rusqlite::OpenFlags::SQLITE_OPEN_CREATE
| rusqlite::OpenFlags::SQLITE_OPEN_URI,
)?;
writer.execute_batch("PRAGMA foreign_keys = ON;")?;
migrate::run(&writer)?;
let readers = ArrayQueue::new(READ_POOL_SIZE);
for _ in 0..READ_POOL_SIZE {
let conn = Connection::open_with_flags(
&name,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI,
)?;
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let _ = readers.push(conn);
}
Ok(DbPool {
writer: Arc::new(Mutex::new(writer)),
readers: Arc::new(readers),
path: PathBuf::from(&name),
export_slots: Arc::new(Semaphore::new(2)),
})
}
pub fn open(path: &Path) -> Result<DbPool, LificError> {
disable_sqlite_memstatus();
secure_parent(path)?;
ensure_private_file(path)?;
let writer = Connection::open(path)?;
secure_file(path)?;
apply_pragmas(&writer)?;
secure_sidecars(path)?;
migrate::run(&writer)?;
secure_sidecars(path)?;
crate::actor::stamp(
&writer,
&crate::actor::ActorCtx {
user_id: None,
transport: crate::actor::Transport::System,
},
);
let readers = ArrayQueue::new(READ_POOL_SIZE);
for _ in 0..READ_POOL_SIZE {
let conn = open_read_connection(path)?;
let _ = readers.push(conn);
}
Ok(DbPool {
writer: Arc::new(Mutex::new(writer)),
readers: Arc::new(readers),
path: path.to_path_buf(),
export_slots: Arc::new(Semaphore::new(2)),
})
}
fn ensure_private_file(path: &Path) -> Result<(), LificError> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
}
match options.open(path) {
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
let metadata = std::fs::symlink_metadata(path)
.map_err(|error| LificError::Internal(format!("inspect database file: {error}")))?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
return Err(LificError::Internal(
"database path must be a regular file, not a link".into(),
));
}
#[cfg(unix)]
if std::os::unix::fs::MetadataExt::nlink(&metadata) != 1 {
return Err(LificError::Internal(
"database path must not have multiple hard links".into(),
));
}
Ok(())
}
Err(error) => Err(LificError::Internal(format!(
"create database file: {error}"
))),
}
}
fn secure_parent(path: &Path) -> Result<(), LificError> {
let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) else {
return Ok(());
};
#[cfg(unix)]
let existed = parent.exists();
std::fs::create_dir_all(parent)
.map_err(|error| LificError::Internal(format!("create database directory: {error}")))?;
#[cfg(unix)]
{
use std::os::unix::fs::{MetadataExt, PermissionsExt};
let metadata = parent.symlink_metadata().map_err(|error| {
LificError::Internal(format!("inspect database directory: {error}"))
})?;
if metadata.file_type().is_symlink() {
return Err(LificError::Internal(
"database directory must not be a symlink".into(),
));
}
let mode = metadata.mode() & 0o777;
if existed && mode & 0o022 != 0 {
return Err(LificError::Internal(format!(
"database directory {} is writable by group/others; remove group/other write permissions or choose a private data directory",
parent.display()
)));
}
if !existed {
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(
|error| LificError::Internal(format!("secure database directory: {error}")),
)?;
}
}
Ok(())
}
#[cfg_attr(
not(unix),
expect(clippy::unnecessary_wraps, reason = "fallible on Unix")
)]
fn secure_file(_path: &Path) -> Result<(), LificError> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(_path, std::fs::Permissions::from_mode(0o600))
.map_err(|error| LificError::Internal(format!("secure database file: {error}")))?;
}
Ok(())
}
fn secure_sidecars(path: &Path) -> Result<(), LificError> {
for suffix in ["-wal", "-shm"] {
let mut sidecar = path.as_os_str().to_os_string();
sidecar.push(suffix);
let sidecar = PathBuf::from(sidecar);
if sidecar.exists() {
secure_file(&sidecar)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::{models::CreateProject, queries};
#[test]
fn transaction_rolls_back_when_operation_fails() {
let db = open_memory().expect("test db");
let result: Result<(), LificError> = db.transaction(|conn| {
queries::create_project(
conn,
&CreateProject {
name: "Rolled back".into(),
identifier: "RBK".into(),
..Default::default()
},
)?;
Err(LificError::BadRequest("abort transaction".into()))
});
assert!(matches!(result, Err(LificError::BadRequest(_))));
let conn = db.read().unwrap();
assert!(queries::list_projects(&conn).unwrap().is_empty());
}
#[cfg(unix)]
#[test]
fn secure_parent_allows_traversal_but_rejects_shared_writes() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("lific.db");
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
secure_parent(&db_path).unwrap();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o775)).unwrap();
assert!(secure_parent(&db_path).is_err());
}
#[cfg(unix)]
#[test]
fn secure_parent_rejects_symlinked_data_directory() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
let link = dir.path().join("link");
std::fs::create_dir(&real).unwrap();
symlink(&real, &link).unwrap();
assert!(secure_parent(&link.join("lific.db")).is_err());
}
#[cfg(unix)]
#[test]
fn ensure_private_file_rejects_symlinks_and_hard_links() {
use std::os::unix::fs::symlink;
let dir = tempfile::tempdir().unwrap();
let original = dir.path().join("original.db");
ensure_private_file(&original).unwrap();
let symlinked = dir.path().join("symlinked.db");
symlink(&original, &symlinked).unwrap();
assert!(ensure_private_file(&symlinked).is_err());
let linked = dir.path().join("linked.db");
std::fs::hard_link(&original, &linked).unwrap();
assert!(ensure_private_file(&linked).is_err());
}
}