use anyhow::Result;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use std::path::Path;
use std::str::FromStr;
use std::time::Duration;
#[derive(Clone)]
pub struct Database {
pool: SqlitePool,
}
impl Database {
pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
let db_file = path.as_ref().join("actrix.db");
let options = SqliteConnectOptions::from_str(&format!("sqlite:{}", db_file.display()))?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(10)
.connect_with(options)
.await?;
let db = Self { pool };
db.initialize_schema().await?;
Ok(db)
}
async fn initialize_schema(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS realm (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'Active',
enabled INTEGER NOT NULL DEFAULT 1,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER,
secret_current TEXT NOT NULL DEFAULT '',
secret_previous_hash TEXT,
secret_previous_valid_until INTEGER
)",
)
.execute(&self.pool)
.await?;
sqlx::query("INSERT OR IGNORE INTO sqlite_sequence(name, seq) VALUES('realm', 33554431)")
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS actoracl (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
realm_id INTEGER NOT NULL,
source_realm_id INTEGER,
from_type TEXT NOT NULL,
to_type TEXT NOT NULL,
access INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_realm_name
ON realm(name)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_actoracl_realm_id
ON actoracl(realm_id)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_actoracl_lookup
ON actoracl(realm_id, source_realm_id, from_type, to_type)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS pending_registration (
serial_number INTEGER PRIMARY KEY,
realm_id INTEGER NOT NULL,
service_spec_blob BLOB,
ws_address TEXT,
created_at INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await?;
let _ = sqlx::query("ALTER TABLE pending_registration ADD COLUMN ws_address TEXT")
.execute(&self.pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS mfr (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL DEFAULT '',
contact TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
updated_at INTEGER,
verified_at INTEGER,
suspended_at INTEGER,
revoked_at INTEGER,
key_expires_at INTEGER
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS mfr_challenge (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mfr_id INTEGER NOT NULL REFERENCES mfr(id),
token TEXT NOT NULL,
verify_url TEXT NOT NULL DEFAULT '',
expires_at INTEGER NOT NULL,
verified_at INTEGER,
created_at INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS mfr_package (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mfr_id INTEGER NOT NULL REFERENCES mfr(id),
manufacturer TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
type_str TEXT NOT NULL,
target TEXT NOT NULL,
manifest TEXT NOT NULL,
signature TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
published_at INTEGER NOT NULL,
revoked_at INTEGER,
UNIQUE(manufacturer, name, version, target)
)",
)
.execute(&self.pool)
.await?;
let _ = sqlx::query(
"ALTER TABLE mfr_package ADD COLUMN target TEXT NOT NULL DEFAULT 'wasm32-wasip1'",
)
.execute(&self.pool)
.await;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_mfr_package_type ON mfr_package(type_str)")
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_mfr_package_mfr ON mfr_package(mfr_id, status)",
)
.execute(&self.pool)
.await?;
let _ = sqlx::query("ALTER TABLE mfr_package ADD COLUMN proto_files TEXT")
.execute(&self.pool)
.await;
let _ = sqlx::query("ALTER TABLE mfr ADD COLUMN key_id TEXT NOT NULL DEFAULT ''")
.execute(&self.pool)
.await;
sqlx::query(
"CREATE TABLE IF NOT EXISTS mfr_key_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mfr_id INTEGER NOT NULL REFERENCES mfr(id),
key_id TEXT NOT NULL,
public_key TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'retired',
created_at INTEGER NOT NULL,
retired_at INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_mfr_key_history_lookup
ON mfr_key_history(mfr_id, key_id)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS mfr_publish_nonce (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mfr_id INTEGER NOT NULL REFERENCES mfr(id),
nonce BLOB NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_mfr_publish_nonce_expires
ON mfr_publish_nonce(expires_at)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS ais_manufacturer_auth_nonce (
id INTEGER PRIMARY KEY AUTOINCREMENT,
manufacturer TEXT NOT NULL,
key_id TEXT NOT NULL,
nonce BLOB NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
UNIQUE(manufacturer, key_id, nonce)
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_ais_manufacturer_auth_nonce_expires
ON ais_manufacturer_auth_nonce(expires_at)",
)
.execute(&self.pool)
.await?;
{
use base64::Engine as _;
use sha2::{Digest, Sha256};
let rows: Vec<(i64, String)> = sqlx::query_as(
"SELECT id, public_key FROM mfr WHERE key_id = '' AND public_key != ''",
)
.fetch_all(&self.pool)
.await
.unwrap_or_default();
for (id, public_key_b64) in rows {
if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(&public_key_b64)
{
let hash = Sha256::digest(&bytes);
let hex_str: String = hash.iter().map(|b| format!("{b:02x}")).collect();
let key_id = format!("mfr-{}", &hex_str[..16]);
let _ = sqlx::query("UPDATE mfr SET key_id = ? WHERE id = ?")
.bind(&key_id)
.bind(id)
.execute(&self.pool)
.await;
crate::recording::info!(
"backfilled key_id from public_key fingerprint: id={}, key_id={}",
id,
key_id
);
}
}
}
sqlx::query(
"CREATE TABLE IF NOT EXISTS ais_renewal_token (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id TEXT NOT NULL,
token_hash BLOB NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_ais_renewal_token_actor
ON ais_renewal_token(actor_id)",
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_ais_renewal_token_expires
ON ais_renewal_token(expires_at)",
)
.execute(&self.pool)
.await?;
Ok(())
}
pub fn get_pool(&self) -> &SqlitePool {
&self.pool
}
pub async fn execute(&self, sql: &str) -> Result<u64> {
let result = sqlx::query(sql).execute(&self.pool).await?;
Ok(result.rows_affected())
}
}
use tokio::sync::OnceCell;
static GLOBAL_DATABASE: OnceCell<Database> = OnceCell::const_new();
pub async fn set_db_path(path: &Path) -> Result<()> {
let database = Database::new(path).await?;
GLOBAL_DATABASE
.set(database)
.map_err(|_| anyhow::anyhow!("Database already initialized"))?;
Ok(())
}
pub fn get_database() -> &'static Database {
GLOBAL_DATABASE
.get()
.expect("Database not initialized. Call set_db_path first.")
}
pub fn is_database_initialized() -> bool {
GLOBAL_DATABASE.get().is_some()
}