use rusqlite::Connection;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use crate::app::paths;
const SCHEMA_V1_SQL: &str = "
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
request_id TEXT NOT NULL,
connection TEXT NOT NULL,
databases TEXT NOT NULL,
category TEXT NOT NULL,
ast_type TEXT,
sql_raw TEXT NOT NULL,
sql_redacted TEXT NOT NULL,
decision TEXT NOT NULL,
confirmed INTEGER NOT NULL DEFAULT 0,
outcome TEXT NOT NULL,
affected_rows INTEGER,
duration_ms INTEGER,
error_msg TEXT,
backup_id INTEGER REFERENCES backup(id) ON DELETE SET NULL,
prev_hash BLOB,
row_hash BLOB
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
CREATE INDEX IF NOT EXISTS idx_audit_connection ON audit_log(connection, ts);
CREATE INDEX IF NOT EXISTS idx_audit_outcome ON audit_log(outcome, ts);
CREATE TABLE IF NOT EXISTS backup (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
connection TEXT NOT NULL,
database TEXT,
table_name TEXT NOT NULL,
backup_kind TEXT NOT NULL,
rows_json TEXT,
schema_sql TEXT,
primary_key TEXT,
row_count INTEGER NOT NULL DEFAULT 0,
truncated INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_backup_ts ON backup(ts);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
";
const SCHEMA_V2_SQL: &str = "
ALTER TABLE audit_log ADD COLUMN approval_scope TEXT;
ALTER TABLE audit_log ADD COLUMN approval_digest BLOB;
ALTER TABLE audit_log ADD COLUMN policy_revision INTEGER;
INSERT INTO meta (key, value) VALUES ('schema_version', '2')
ON CONFLICT(key) DO UPDATE SET value = '2';
INSERT OR IGNORE INTO meta (key, value) VALUES ('chain_epoch', '0');
";
fn apply_pragmas(conn: &Connection) -> rusqlite::Result<()> {
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "temp_store", "MEMORY")?;
conn.pragma_update(None, "mmap_size", 268_435_456i64)?;
conn.pragma_update(None, "cache_size", -20_000i64)?;
conn.pragma_update(None, "wal_autocheckpoint", 1000)?;
conn.pragma_update(None, "journal_size_limit", 67_108_864i64)?;
conn.pragma_update(None, "busy_timeout", 5000)?;
conn.pragma_update(None, "foreign_keys", true)?;
Ok(())
}
fn migrate_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA_V1_SQL)?;
let version: String = conn
.query_row(
"SELECT value FROM meta WHERE key = 'schema_version'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "1".to_string());
if version == "1" {
let have_scope = conn
.prepare("SELECT approval_scope FROM audit_log LIMIT 0")
.is_ok();
if !have_scope {
conn.execute_batch(SCHEMA_V2_SQL)?;
}
}
Ok(())
}
pub struct AuditDb {
conn: Arc<Mutex<Connection>>,
#[allow(dead_code)]
path: PathBuf,
}
static CACHED: OnceLock<Arc<AuditDb>> = OnceLock::new();
fn set_file_mode_0600(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
}
fn open_at(path: &Path, ensure_parent_with_mode: bool) -> rusqlite::Result<Connection> {
if ensure_parent_with_mode && let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700));
}
}
let conn = Connection::open(path)?;
apply_pragmas(&conn)?;
migrate_schema(&conn)?;
set_file_mode_0600(path);
Ok(conn)
}
impl AuditDb {
pub fn shared() -> Arc<AuditDb> {
CACHED
.get_or_init(|| {
let path = paths::audit_db_path();
let conn = open_at(&path, true).expect("audit db opens");
Arc::new(AuditDb {
conn: Arc::new(Mutex::new(conn)),
path,
})
})
.clone()
}
pub fn at_path(path: &Path) -> rusqlite::Result<AuditDb> {
let conn = open_at(path, true)?;
Ok(AuditDb {
conn: Arc::new(Mutex::new(conn)),
path: path.to_path_buf(),
})
}
pub fn with<T>(
&self,
f: impl FnOnce(&Connection) -> rusqlite::Result<T>,
) -> rusqlite::Result<T> {
let guard = self.conn.lock().unwrap();
f(&guard)
}
pub fn with_tx<T>(
&self,
f: impl FnOnce(&Connection) -> rusqlite::Result<T>,
) -> rusqlite::Result<T> {
let guard = self.conn.lock().unwrap();
guard.execute_batch("BEGIN IMMEDIATE")?;
match f(&guard) {
Ok(v) => {
guard.execute_batch("COMMIT")?;
Ok(v)
}
Err(e) => {
let _ = guard.execute_batch("ROLLBACK");
Err(e)
}
}
}
pub fn checkpoint(&self) {
let _ = self.with(|c| c.pragma_update(None, "wal_checkpoint", "TRUNCATE"));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_is_created_and_versioned() {
let dir = tempfile::tempdir().unwrap();
let db = AuditDb::at_path(&dir.path().join("audit.sqlite")).unwrap();
let v: String = db
.with(|c| {
c.query_row(
"SELECT value FROM meta WHERE key='schema_version'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(v, "2");
let n: i64 = db
.with(|c| c.query_row("SELECT COUNT(*) FROM audit_log", [], |r| r.get(0)))
.unwrap();
assert_eq!(n, 0);
let _ = dir;
}
#[test]
fn reopen_upgrades_v1_to_v2() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("audit.sqlite");
{
let raw = Connection::open(&file).unwrap();
raw.execute_batch(SCHEMA_V1_SQL).unwrap();
raw.execute(
"INSERT INTO meta (key, value) VALUES ('schema_version', '1')",
[],
)
.unwrap();
}
let db = AuditDb::at_path(&file).unwrap();
let v: String = db
.with(|c| {
c.query_row(
"SELECT value FROM meta WHERE key='schema_version'",
[],
|r| r.get(0),
)
})
.unwrap();
assert_eq!(v, "2");
}
}