use std::fs; use std::os::unix::fs::OpenOptionsExt; use std::path::Path; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use rand::RngCore; use rusqlite::Connection; use sha2::{Digest, Sha256}; pub const FILE_NAME: &str = "ratlogin.sqlite3"; pub const SCHEMA_VERSION: i64 = 2; const SCHEMA_V1: &str = " CREATE TABLE flows ( state TEXT PRIMARY KEY, status INTEGER NOT NULL, created_at INTEGER NOT NULL, data TEXT NOT NULL ); CREATE TABLE codes ( digest TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, data TEXT NOT NULL ); CREATE TABLE tokens ( digest TEXT PRIMARY KEY, expires_at INTEGER NOT NULL, data TEXT NOT NULL ); CREATE TABLE sessions ( digest TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL, revoked_at INTEGER ); CREATE TABLE clients ( id TEXT PRIMARY KEY, secret_digest TEXT NOT NULL, redirect_uris TEXT NOT NULL, email_policy TEXT NOT NULL DEFAULT 'none', signing_alg TEXT NOT NULL DEFAULT 'RS256', created_at INTEGER NOT NULL, revoked_at INTEGER ); CREATE TABLE oauth_requests ( state TEXT PRIMARY KEY, created_at INTEGER NOT NULL, data TEXT NOT NULL ); CREATE TABLE oauth_sessions ( did TEXT NOT NULL, session_id TEXT NOT NULL, data TEXT NOT NULL, created_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (did, session_id) ); "; const SCHEMA_V2: &str = " ALTER TABLE clients ADD COLUMN allow_list TEXT; ALTER TABLE clients ADD COLUMN deny_list TEXT NOT NULL DEFAULT '[]'; ALTER TABLE clients ADD COLUMN insecure_redirects INTEGER NOT NULL DEFAULT 0; "; #[derive(Clone)] pub struct Database { connection: Arc>, } impl Database { pub fn open(path: &Path) -> Result { match fs::OpenOptions::new() .write(true) .create_new(true) .mode(0o600) .open(path) { Ok(_) => {} Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} Err(error) => return Err(error.into()), } let connection = Connection::open(path)?; connection.pragma_update(None, "journal_mode", "WAL")?; Self::prepare(connection) } pub fn in_memory() -> Result { Self::prepare(Connection::open_in_memory()?) } fn prepare(connection: Connection) -> Result { connection.busy_timeout(std::time::Duration::from_secs(5))?; connection.pragma_update(None, "foreign_keys", "ON")?; let version: i64 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; if version > SCHEMA_VERSION { return Err(DatabaseError::Newer(version)); } if version < 1 { connection.execute_batch(SCHEMA_V1)?; } if version < 2 { connection.execute_batch(SCHEMA_V2)?; } if version < SCHEMA_VERSION { connection.pragma_update(None, "user_version", SCHEMA_VERSION)?; } Ok(Self { connection: Arc::new(Mutex::new(connection)), }) } pub fn lock(&self) -> MutexGuard<'_, Connection> { self.connection .lock() .unwrap_or_else(PoisonError::into_inner) } } pub fn digest(value: &str) -> String { Sha256::digest(value.as_bytes()) .iter() .map(|byte| format!("{byte:02x}")) .collect() } pub fn random_token() -> String { let mut bytes = [0u8; 32]; rand::rngs::OsRng.fill_bytes(&mut bytes); bytes.iter().map(|byte| format!("{byte:02x}")).collect() } #[derive(Debug, thiserror::Error)] pub enum DatabaseError { #[error("database: {0}")] Sqlite(String), #[error("database file: {0}")] Io(String), #[error("database schema version {0} is newer than this build supports ({SCHEMA_VERSION})")] Newer(i64), #[error("database record: {0}")] Encoding(String), } impl From for DatabaseError { fn from(error: rusqlite::Error) -> Self { Self::Sqlite(error.to_string()) } } impl From for DatabaseError { fn from(error: std::io::Error) -> Self { Self::Io(error.to_string()) } } impl From for DatabaseError { fn from(error: serde_json::Error) -> Self { Self::Encoding(error.to_string()) } } #[cfg(test)] mod tests { use super::*; fn version(db: &Database) -> i64 { db.lock() .query_row("PRAGMA user_version", [], |row| row.get(0)) .expect("version") } #[test] fn a_fresh_database_is_private_stamped_and_refuses_newer_schemas() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(FILE_NAME); let db = Database::open(&path).expect("create"); let mode = std::os::unix::fs::MetadataExt::mode(&fs::metadata(&path).expect("metadata")); assert_eq!(mode & 0o777, 0o600); assert_eq!(version(&db), SCHEMA_VERSION); drop(db); let db = Database::open(&path).expect("reopen"); assert_eq!(version(&db), SCHEMA_VERSION); db.lock() .pragma_update(None, "user_version", 99) .expect("bump version"); drop(db); assert!(matches!( Database::open(&path), Err(DatabaseError::Newer(99)) )); assert!(matches!( Database::open(&dir.path().join("absent").join(FILE_NAME)), Err(DatabaseError::Io(_)) )); } #[test] fn a_version_one_database_gains_the_new_client_columns() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(FILE_NAME); let db = Database::open(&path).expect("create"); db.lock() .execute_batch( "ALTER TABLE clients DROP COLUMN allow_list; ALTER TABLE clients DROP COLUMN deny_list; ALTER TABLE clients DROP COLUMN insecure_redirects; PRAGMA user_version = 1;", ) .expect("downgrade"); drop(db); let db = Database::open(&path).expect("migrate"); assert_eq!(version(&db), SCHEMA_VERSION); db.lock() .execute( "SELECT allow_list, deny_list, insecure_redirects FROM clients", [], ) .expect("columns"); } }