use std::path::Path;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use pulpo_common::session::InterventionCode;
use sqlx::migrate::Migrator;
use sqlx::sqlite::{SqliteConnectOptions, SqliteConnection};
use sqlx::{ConnectOptions, Connection, SqlitePool};
use tracing::{error, info, warn};
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
const MAX_PRE_MIGRATION_BACKUPS: usize = 3;
#[derive(Debug, Clone)]
pub struct InterventionEvent {
pub id: i64,
pub session_id: String,
pub code: Option<InterventionCode>,
pub reason: String,
pub created_at: DateTime<Utc>,
}
#[derive(Clone)]
pub struct Store {
pub(super) pool: SqlitePool,
pub(super) data_dir: String,
}
impl Store {
pub async fn new(data_dir: &str) -> Result<Self> {
std::fs::create_dir_all(data_dir)?;
let db_path = format!("{data_dir}/state.db");
let options = SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true)
.statement_cache_capacity(0);
let pool = SqlitePool::connect_with(options).await?;
Ok(Self {
pool,
data_dir: data_dir.to_owned(),
})
}
pub async fn migrate(&self) -> Result<()> {
let db_path = format!("{}/state.db", self.data_dir);
let mut conn = SqliteConnectOptions::new()
.filename(&db_path)
.statement_cache_capacity(0)
.connect()
.await
.with_context(|| {
format!("failed to open a dedicated migration connection to {db_path}")
})?;
self.reject_unsupported_legacy_schema(&mut conn).await?;
self.warn_before_dropping_secrets(&mut conn).await?;
self.warn_before_dropping_push_subscriptions(&mut conn)
.await?;
if self.has_pending_migrations(&mut conn).await? {
self.backup_before_migrating(&mut conn).await?;
}
MIGRATOR.run_direct(&mut conn).await?;
conn.close()
.await
.context("failed to close the dedicated migration connection")?;
self.enforce_db_permissions();
Ok(())
}
async fn has_pending_migrations(&self, conn: &mut SqliteConnection) -> Result<bool> {
let has_sqlx_migrations: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'",
)
.fetch_one(&mut *conn)
.await?;
if has_sqlx_migrations == 0 {
return Ok(false);
}
let applied_versions: Vec<i64> = sqlx::query_scalar("SELECT version FROM _sqlx_migrations")
.fetch_all(&mut *conn)
.await?;
let applied: std::collections::HashSet<i64> = applied_versions.into_iter().collect();
Ok(MIGRATOR.iter().any(|m| !applied.contains(&m.version)))
}
async fn backup_before_migrating(&self, conn: &mut SqliteConnection) -> Result<()> {
let db_path = format!("{}/state.db", self.data_dir);
let highest_applied: i64 =
sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) FROM _sqlx_migrations")
.fetch_one(&mut *conn)
.await
.unwrap_or(0);
let backup_path = format!("{db_path}.pre-m{highest_applied}");
std::fs::copy(&db_path, &backup_path)
.with_context(|| format!("failed to back up {db_path} to {backup_path}"))?;
info!(backup = %backup_path, "store: backed up database before running pending migrations");
self.prune_old_backups()?;
Ok(())
}
fn prune_old_backups(&self) -> Result<()> {
let prefix = "state.db.pre-";
let mut backups: Vec<(std::time::SystemTime, std::path::PathBuf)> =
std::fs::read_dir(&self.data_dir)?
.filter_map(std::result::Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().starts_with(prefix))
.filter_map(|entry| {
let modified = entry.metadata().ok()?.modified().ok()?;
Some((modified, entry.path()))
})
.collect();
backups.sort_by(|a, b| b.0.cmp(&a.0));
for (_, path) in backups.into_iter().skip(MAX_PRE_MIGRATION_BACKUPS) {
let _ = std::fs::remove_file(path);
}
Ok(())
}
async fn warn_before_dropping_secrets(&self, conn: &mut SqliteConnection) -> Result<()> {
let has_secrets_table: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'secrets'",
)
.fetch_one(&mut *conn)
.await?;
if has_secrets_table == 0 {
return Ok(());
}
let secret_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM secrets")
.fetch_one(&mut *conn)
.await?;
if secret_count > 0 {
warn!(
secret_count,
"store: the secrets table is about to be dropped irreversibly by migration \
0008 — {secret_count} stored secret(s) will be lost. There is no export \
tool; downgrade to pulpo 0.1.1 first if you need to read them out before \
upgrading."
);
}
Ok(())
}
async fn warn_before_dropping_push_subscriptions(
&self,
conn: &mut SqliteConnection,
) -> Result<()> {
let has_table: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'push_subscriptions'",
)
.fetch_one(&mut *conn)
.await?;
if has_table == 0 {
return Ok(());
}
let subscription_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM push_subscriptions")
.fetch_one(&mut *conn)
.await?;
if subscription_count > 0 {
warn!(
subscription_count,
"store: the push_subscriptions table is about to be dropped by migration 0009 \
(Web Push was removed) — {subscription_count} stored subscription(s) will be \
lost. Notifications now flow only through [[webhooks]]."
);
}
Ok(())
}
async fn reject_unsupported_legacy_schema(&self, conn: &mut SqliteConnection) -> Result<()> {
let has_sqlx_migrations: i32 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = '_sqlx_migrations'",
)
.fetch_one(&mut *conn)
.await?;
if has_sqlx_migrations > 0 {
return Ok(());
}
let has_sessions_table: i32 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sessions'",
)
.fetch_one(&mut *conn)
.await?;
if has_sessions_table > 0 {
anyhow::bail!(
"unsupported legacy database schema detected; delete {}/state.db to reinitialize",
self.data_dir
);
}
Ok(())
}
fn enforce_db_permissions(&self) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let db_path = format!("{}/state.db", self.data_dir);
if let Ok(metadata) = std::fs::metadata(&db_path) {
let mut perms = metadata.permissions();
perms.set_mode(0o600);
let _ = std::fs::set_permissions(&db_path, perms);
}
}
}
}
#[derive(Debug, Clone)]
pub struct RecoveredUnusableDb {
pub reason: String,
pub moved_to: String,
}
pub async fn open_and_migrate(data_dir: &str) -> Result<(Store, Option<RecoveredUnusableDb>)> {
match try_open_and_migrate(data_dir).await {
Ok(store) => Ok((store, None)),
Err(first_err) => {
let reason = format!("{first_err:#}");
if !is_quarantine_worthy(&first_err) {
error!(
reason = %reason,
"store: database open/migrate failed for a reason that is neither \
corruption nor a schema incompatibility (e.g. a lock held by another \
process, an I/O failure, or a failed pre-migration backup) — refusing to \
start rather than risk quarantining a possibly-healthy database"
);
return Err(first_err);
}
let moved_to = quarantine_unusable_db(data_dir).with_context(|| {
format!("database at {data_dir}/state.db is unusable ({reason}) and could not be quarantined")
})?;
error!(
reason = %reason,
moved_to = %moved_to,
"store: database unusable — quarantined and starting fresh"
);
let store = try_open_and_migrate(data_dir).await.with_context(|| {
format!(
"quarantined unusable database to {moved_to}, but creating a fresh one also failed"
)
})?;
Ok((store, Some(RecoveredUnusableDb { reason, moved_to })))
}
}
}
async fn try_open_and_migrate(data_dir: &str) -> Result<Store> {
let store = Store::new(data_dir).await?;
store.migrate().await?;
Ok(store)
}
fn is_quarantine_worthy(err: &anyhow::Error) -> bool {
if let Some(migrate_err) = err.downcast_ref::<sqlx::migrate::MigrateError>() {
return matches!(
migrate_err,
sqlx::migrate::MigrateError::VersionMissing(_)
| sqlx::migrate::MigrateError::VersionMismatch(_)
);
}
let rendered = format!("{err:#}").to_lowercase();
rendered.contains("unsupported legacy database schema")
|| rendered.contains("file is not a database")
|| rendered.contains("malformed")
}
fn quarantine_unusable_db(data_dir: &str) -> Result<String> {
let db_path = format!("{data_dir}/state.db");
if !Path::new(&db_path).exists() {
anyhow::bail!("no {db_path} to quarantine");
}
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let quarantined = format!("{db_path}.unusable-{timestamp}");
std::fs::rename(&db_path, &quarantined)?;
for suffix in ["-wal", "-shm"] {
let sidecar = format!("{db_path}{suffix}");
if Path::new(&sidecar).exists() {
let _ = std::fs::rename(&sidecar, format!("{quarantined}{suffix}"));
}
}
Ok(quarantined)
}
#[cfg(test)]
pub async fn test_store() -> Store {
let tmpdir = tempfile::tempdir().unwrap();
let tmpdir = Box::leak(Box::new(tmpdir));
let store = Store::new(tmpdir.path().to_str().unwrap()).await.unwrap();
store.migrate().await.unwrap();
store
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_quarantine_unusable_db_bails_when_file_missing() {
let tmpdir = tempfile::tempdir().unwrap();
let err = quarantine_unusable_db(tmpdir.path().to_str().unwrap()).unwrap_err();
assert!(err.to_string().contains("no"));
assert!(err.to_string().contains("state.db"));
}
#[test]
fn test_quarantine_unusable_db_renames_file_and_siblings() {
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
std::fs::write(tmpdir.path().join("state.db"), b"garbage").unwrap();
std::fs::write(tmpdir.path().join("state.db-wal"), b"wal").unwrap();
std::fs::write(tmpdir.path().join("state.db-shm"), b"shm").unwrap();
let quarantined = quarantine_unusable_db(dir).unwrap();
assert!(!tmpdir.path().join("state.db").exists());
assert!(!tmpdir.path().join("state.db-wal").exists());
assert!(!tmpdir.path().join("state.db-shm").exists());
assert!(Path::new(&quarantined).exists());
assert!(quarantined.contains("state.db.unusable-"));
assert!(Path::new(&format!("{quarantined}-wal")).exists());
assert!(Path::new(&format!("{quarantined}-shm")).exists());
assert_eq!(std::fs::read(&quarantined).unwrap(), b"garbage");
}
#[test]
fn test_quarantine_unusable_db_ignores_missing_siblings() {
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
std::fs::write(tmpdir.path().join("state.db"), b"garbage").unwrap();
let quarantined = quarantine_unusable_db(dir).unwrap();
assert!(Path::new(&quarantined).exists());
assert!(!Path::new(&format!("{quarantined}-wal")).exists());
assert!(!Path::new(&format!("{quarantined}-shm")).exists());
}
#[tokio::test]
async fn test_prune_old_backups_keeps_only_most_recent() {
let store = test_store().await;
for i in 0..5 {
std::fs::write(
format!("{}/state.db.pre-m{i}", store.data_dir),
format!("backup-{i}"),
)
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
}
store.prune_old_backups().unwrap();
let remaining: Vec<String> = std::fs::read_dir(&store.data_dir)
.unwrap()
.filter_map(std::result::Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with("state.db.pre-"))
.collect();
assert_eq!(remaining.len(), MAX_PRE_MIGRATION_BACKUPS);
for kept in ["state.db.pre-m2", "state.db.pre-m3", "state.db.pre-m4"] {
assert!(remaining.contains(&kept.to_owned()), "{remaining:?}");
}
}
#[tokio::test]
async fn test_prune_old_backups_noop_under_the_cap() {
let store = test_store().await;
std::fs::write(format!("{}/state.db.pre-m1", store.data_dir), b"a").unwrap();
store.prune_old_backups().unwrap();
assert!(Path::new(&format!("{}/state.db.pre-m1", store.data_dir)).exists());
}
#[tokio::test]
async fn test_backup_before_migrating_names_backup_after_highest_applied_migration() {
let store = test_store().await;
let mut conn = SqliteConnectOptions::new()
.filename(format!("{}/state.db", store.data_dir))
.connect()
.await
.unwrap();
store.backup_before_migrating(&mut conn).await.unwrap();
let backup_path = format!("{}/state.db.pre-m10", store.data_dir);
assert!(
Path::new(&backup_path).exists(),
"expected a backup named after migration 0010, the highest applied \
version after test_store()'s full migration run"
);
}
#[tokio::test]
async fn test_open_and_migrate_success_reports_no_recovery() {
let tmpdir = tempfile::tempdir().unwrap();
let (store, recovered) = open_and_migrate(tmpdir.path().to_str().unwrap())
.await
.unwrap();
assert!(recovered.is_none());
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions")
.fetch_one(&store.pool)
.await
.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn test_open_and_migrate_recovers_from_garbage_file() {
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
std::fs::write(tmpdir.path().join("state.db"), b"not a sqlite database").unwrap();
let (store, recovered) = open_and_migrate(dir).await.unwrap();
let recovered = recovered.expect("expected recovery from a garbage file");
assert!(Path::new(&recovered.moved_to).exists());
assert_eq!(
std::fs::read(&recovered.moved_to).unwrap(),
b"not a sqlite database"
);
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sessions")
.fetch_one(&store.pool)
.await
.unwrap();
assert_eq!(count, 0);
}
#[cfg(unix)]
#[tokio::test]
async fn test_open_and_migrate_refuses_when_directory_unwritable() {
use std::os::unix::fs::PermissionsExt;
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
let mut perms = std::fs::metadata(tmpdir.path()).unwrap().permissions();
perms.set_mode(0o500); std::fs::set_permissions(tmpdir.path(), perms.clone()).unwrap();
let result = open_and_migrate(dir).await;
perms.set_mode(0o700);
std::fs::set_permissions(tmpdir.path(), perms).unwrap();
assert!(result.is_err(), "expected open_and_migrate to refuse");
}
#[test]
fn test_is_quarantine_worthy_legacy_schema_rejection() {
let err = anyhow::anyhow!(
"unsupported legacy database schema detected; delete /data/state.db to reinitialize"
);
assert!(is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_sqlite_not_a_database() {
let err =
anyhow::anyhow!("error returned from database: (code: 26) file is not a database");
assert!(is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_sqlite_malformed() {
let err = anyhow::anyhow!(
"error returned from database: (code: 11) database disk image is malformed"
);
assert!(is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_migrate_version_missing() {
let err: anyhow::Error = sqlx::migrate::MigrateError::VersionMissing(9999).into();
assert!(is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_migrate_version_mismatch() {
let err: anyhow::Error = sqlx::migrate::MigrateError::VersionMismatch(3).into();
assert!(is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_false_for_locked_database() {
let err = anyhow::anyhow!("error returned from database: (code: 5) database is locked");
assert!(!is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_false_for_plain_io_error() {
let err = anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Permission denied (os error 13)",
));
assert!(!is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_false_for_failed_backup() {
let err = anyhow::anyhow!("failed to back up /data/state.db to /data/state.db.pre-0.3.1");
assert!(!is_quarantine_worthy(&err));
}
#[test]
fn test_is_quarantine_worthy_false_for_other_migrate_error_variants() {
let dirty: anyhow::Error = sqlx::migrate::MigrateError::Dirty(1).into();
assert!(!is_quarantine_worthy(&dirty));
let too_old: anyhow::Error = sqlx::migrate::MigrateError::VersionTooOld(1, 2).into();
assert!(!is_quarantine_worthy(&too_old));
}
}