use serde::Serialize;
use sqlx::{Pool, Sqlite, sqlite::SqliteConnection};
use super::error::CliError;
use crate::audit::hash::{
AuditRowForHashing, GENESIS_PREV_HASH, compute_audit_row_hash, parse_stored_hash,
};
use crate::error::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum RebuildOutcome {
Empty,
AlreadyRebuilt {
total_rows: i64,
},
Rebuilt {
rows_filled: i64,
total_rows: i64,
},
}
const RECREATE_NO_UPDATE_TRIGGER: &str =
"CREATE TRIGGER audit_log_no_update BEFORE UPDATE ON audit_log
BEGIN
SELECT RAISE(ABORT, 'audit_log is append-only');
END";
pub async fn rebuild(pool: &Pool<Sqlite>) -> Result<RebuildOutcome, CliError> {
let instance_id = crate::writer::acquire_lease(pool)
.await
.map_err(map_lease_error)?;
let result = run_with_lease_held(pool).await;
if let Err(e) = crate::writer::release_lease_by_id(pool, &instance_id).await {
eprintln!("warning: audit-rebuild lease release failed (instance_id={instance_id}): {e}");
}
result
}
fn map_lease_error(e: Error) -> CliError {
match e {
Error::LeaseHeld {
instance_id,
age_secs,
} => CliError::LeaseConflict {
instance_id,
age_secs,
},
other => CliError::Startup(format!("audit-rebuild lease: {other}")),
}
}
async fn run_with_lease_held(pool: &Pool<Sqlite>) -> Result<RebuildOutcome, CliError> {
let mut conn = pool
.acquire()
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild acquire: {e}")))?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild begin: {e}")))?;
match perform_rebuild(&mut conn).await {
Ok(outcome) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild commit: {e}")))?;
Ok(outcome)
}
Err(e) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
Err(e)
}
}
}
async fn perform_rebuild(conn: &mut SqliteConnection) -> Result<RebuildOutcome, CliError> {
let total_rows: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM audit_log")
.fetch_one(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild count: {e}")))?;
if total_rows == 0 {
return Ok(RebuildOutcome::Empty);
}
let null_count: i64 =
sqlx::query_scalar!("SELECT COUNT(*) FROM audit_log WHERE row_hash IS NULL")
.fetch_one(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild null-count: {e}")))?;
if null_count == 0 {
return Ok(RebuildOutcome::AlreadyRebuilt { total_rows });
}
sqlx::query("DROP TRIGGER IF EXISTS audit_log_no_update")
.execute(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild drop trigger: {e}")))?;
let rows_filled = walk_and_fill(conn).await?;
sqlx::query(RECREATE_NO_UPDATE_TRIGGER)
.execute(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild restore trigger: {e}")))?;
Ok(RebuildOutcome::Rebuilt {
rows_filled,
total_rows,
})
}
async fn walk_and_fill(conn: &mut SqliteConnection) -> Result<i64, CliError> {
let rows = sqlx::query!(
"SELECT id, created_at, action, actor_did, target, target_cid, outcome, reason, row_hash
FROM audit_log
ORDER BY id ASC"
)
.fetch_all(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild scan: {e}")))?;
let mut prev_hash = GENESIS_PREV_HASH;
let mut rows_filled: i64 = 0;
for row in rows {
if let Some(stored) = &row.row_hash {
prev_hash = parse_stored_hash(stored)
.map_err(|e| CliError::Startup(format!("audit-rebuild parse stored hash: {e}")))?;
continue;
}
let new_hash = compute_audit_row_hash(
&prev_hash,
&AuditRowForHashing {
created_at: row.created_at,
action: &row.action,
actor_did: &row.actor_did,
target: row.target.as_deref(),
target_cid: row.target_cid.as_deref(),
outcome: &row.outcome,
reason: row.reason.as_deref(),
},
)
.map_err(|e| CliError::Startup(format!("audit-rebuild hash: {e}")))?;
let prev_slice: &[u8] = &prev_hash;
let new_slice: &[u8] = &new_hash;
sqlx::query!(
"UPDATE audit_log SET prev_hash = ?1, row_hash = ?2 WHERE id = ?3",
prev_slice,
new_slice,
row.id,
)
.execute(&mut *conn)
.await
.map_err(|e| CliError::Startup(format!("audit-rebuild update id={}: {e}", row.id)))?;
rows_filled += 1;
prev_hash = new_hash;
}
Ok(rows_filled)
}
pub fn format_human(outcome: &RebuildOutcome) -> String {
match outcome {
RebuildOutcome::Empty => "audit_log is empty; nothing to rebuild".to_string(),
RebuildOutcome::AlreadyRebuilt { total_rows } => {
format!("audit_log already rebuilt; {total_rows} row(s) already have hashes")
}
RebuildOutcome::Rebuilt {
rows_filled,
total_rows,
} => format!("audit-rebuild complete: filled {rows_filled} row(s) of {total_rows} total"),
}
}
pub fn format_json(outcome: &RebuildOutcome) -> String {
serde_json::to_string(outcome).expect("RebuildOutcome serializes")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage;
use tempfile::tempdir;
async fn fresh_pool() -> Pool<Sqlite> {
let dir = tempdir().unwrap();
let path = dir.path().join("audit-rebuild-test.db");
let pool = storage::open(&path).await.unwrap();
Box::leak(Box::new(dir));
pool
}
async fn insert_pre_v13_row(
pool: &Pool<Sqlite>,
action: &str,
actor_did: &str,
created_at: i64,
) {
sqlx::query!(
"INSERT INTO audit_log (created_at, action, actor_did, outcome) VALUES (?1, ?2, ?3, ?4)",
created_at,
action,
actor_did,
"success",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn empty_audit_log_returns_empty() {
let pool = fresh_pool().await;
let outcome = rebuild(&pool).await.unwrap();
assert_eq!(outcome, RebuildOutcome::Empty);
}
#[tokio::test]
async fn already_rebuilt_no_op_is_fast_path() {
let pool = fresh_pool().await;
crate::audit::append::append_via_pool(
&pool,
&crate::audit::append::AuditRowForAppend {
created_at: 1,
action: "label_applied".into(),
actor_did: "did:plc:m1".into(),
target: None,
target_cid: None,
outcome: "success".into(),
reason: None,
},
)
.await
.unwrap();
crate::audit::append::append_via_pool(
&pool,
&crate::audit::append::AuditRowForAppend {
created_at: 2,
action: "label_negated".into(),
actor_did: "did:plc:m1".into(),
target: None,
target_cid: None,
outcome: "success".into(),
reason: None,
},
)
.await
.unwrap();
let outcome = rebuild(&pool).await.unwrap();
assert_eq!(outcome, RebuildOutcome::AlreadyRebuilt { total_rows: 2 });
}
#[tokio::test]
async fn fills_all_null_rows_chained_from_genesis() {
let pool = fresh_pool().await;
insert_pre_v13_row(&pool, "label_applied", "did:plc:m1", 100).await;
insert_pre_v13_row(&pool, "label_negated", "did:plc:m1", 200).await;
insert_pre_v13_row(&pool, "report_resolved", "did:plc:m2", 300).await;
let outcome = rebuild(&pool).await.unwrap();
assert_eq!(
outcome,
RebuildOutcome::Rebuilt {
rows_filled: 3,
total_rows: 3,
}
);
let rows = sqlx::query!(
r#"SELECT id, prev_hash AS "prev_hash!", row_hash AS "row_hash!"
FROM audit_log ORDER BY id ASC"#
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].prev_hash, GENESIS_PREV_HASH);
assert_eq!(rows[1].prev_hash, rows[0].row_hash);
assert_eq!(rows[2].prev_hash, rows[1].row_hash);
}
#[tokio::test]
async fn mixed_state_fills_only_null_rows() {
let pool = fresh_pool().await;
crate::audit::append::append_via_pool(
&pool,
&crate::audit::append::AuditRowForAppend {
created_at: 1,
action: "label_applied".into(),
actor_did: "did:plc:m1".into(),
target: None,
target_cid: None,
outcome: "success".into(),
reason: None,
},
)
.await
.unwrap();
crate::audit::append::append_via_pool(
&pool,
&crate::audit::append::AuditRowForAppend {
created_at: 2,
action: "label_negated".into(),
actor_did: "did:plc:m1".into(),
target: None,
target_cid: None,
outcome: "success".into(),
reason: None,
},
)
.await
.unwrap();
insert_pre_v13_row(&pool, "report_resolved", "did:plc:m2", 300).await;
insert_pre_v13_row(&pool, "reporter_flagged", "did:plc:m2", 400).await;
let outcome = rebuild(&pool).await.unwrap();
assert_eq!(
outcome,
RebuildOutcome::Rebuilt {
rows_filled: 2,
total_rows: 4,
}
);
let rows = sqlx::query!(
r#"SELECT id, prev_hash AS "prev_hash!", row_hash AS "row_hash!"
FROM audit_log ORDER BY id ASC"#
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(rows[2].prev_hash, rows[1].row_hash);
assert_eq!(rows[3].prev_hash, rows[2].row_hash);
}
#[tokio::test]
async fn rebuild_restores_no_update_trigger_after_run() {
let pool = fresh_pool().await;
insert_pre_v13_row(&pool, "label_applied", "did:plc:m1", 100).await;
rebuild(&pool).await.unwrap();
let err = sqlx::query!("UPDATE audit_log SET action = 'tampered' WHERE id = 1")
.execute(&pool)
.await
.expect_err("audit_log_no_update trigger must abort UPDATE");
let msg = err.to_string();
assert!(
msg.contains("audit_log is append-only"),
"expected trigger message, got: {msg}"
);
}
#[tokio::test]
async fn rebuild_rejects_when_lease_held_by_other_writer() {
let pool = fresh_pool().await;
let now_ms = crate::writer::epoch_ms_now();
sqlx::query!(
"INSERT INTO server_instance_lease (id, instance_id, acquired_at, last_heartbeat)
VALUES (1, ?1, ?2, ?2)",
"rival-writer",
now_ms,
)
.execute(&pool)
.await
.unwrap();
insert_pre_v13_row(&pool, "label_applied", "did:plc:m1", 100).await;
let err = rebuild(&pool).await.expect_err("lease must block rebuild");
match err {
CliError::LeaseConflict { instance_id, .. } => {
assert_eq!(instance_id, "rival-writer");
}
other => panic!("expected LeaseConflict, got: {other:?}"),
}
let row_hash: Option<Vec<u8>> =
sqlx::query_scalar!("SELECT row_hash FROM audit_log WHERE id = 1")
.fetch_one(&pool)
.await
.unwrap();
assert!(
row_hash.is_none(),
"rebuild must not write rows when lease is held"
);
}
#[tokio::test]
async fn rebuild_releases_lease_on_success_so_next_run_works() {
let pool = fresh_pool().await;
insert_pre_v13_row(&pool, "label_applied", "did:plc:m1", 100).await;
let first = rebuild(&pool).await.unwrap();
assert!(matches!(first, RebuildOutcome::Rebuilt { .. }));
let second = rebuild(&pool).await.unwrap();
assert!(matches!(second, RebuildOutcome::AlreadyRebuilt { .. }));
}
#[tokio::test]
async fn rebuilt_hashes_recompute_correctly() {
let pool = fresh_pool().await;
insert_pre_v13_row(&pool, "label_applied", "did:plc:m1", 100).await;
insert_pre_v13_row(&pool, "label_negated", "did:plc:m1", 200).await;
rebuild(&pool).await.unwrap();
let rows = sqlx::query!(
r#"SELECT id, created_at, action, actor_did, target, target_cid, outcome, reason,
prev_hash AS "prev_hash!", row_hash AS "row_hash!"
FROM audit_log ORDER BY id ASC"#
)
.fetch_all(&pool)
.await
.unwrap();
for row in &rows {
let prev = parse_stored_hash(&row.prev_hash).unwrap();
let recomputed = compute_audit_row_hash(
&prev,
&AuditRowForHashing {
created_at: row.created_at,
action: &row.action,
actor_did: &row.actor_did,
target: row.target.as_deref(),
target_cid: row.target_cid.as_deref(),
outcome: &row.outcome,
reason: row.reason.as_deref(),
},
)
.unwrap();
assert_eq!(
recomputed.to_vec(),
row.row_hash,
"row id={} hash recomputation mismatch",
row.id
);
}
}
#[test]
fn recreate_trigger_ddl_matches_initial_migration() {
let init_sql = include_str!("../../migrations/0001_init.sql");
let needle = "CREATE TRIGGER audit_log_no_update";
let start = init_sql
.find(needle)
.expect("0001_init.sql contains audit_log_no_update trigger");
let end = init_sql[start..].find("END;").expect("trigger has END;") + start + "END".len(); let init_trigger = &init_sql[start..end];
assert_eq!(
init_trigger, RECREATE_NO_UPDATE_TRIGGER,
"RECREATE_NO_UPDATE_TRIGGER must match the body in 0001_init.sql"
);
}
#[test]
fn format_human_renders_each_outcome() {
assert!(format_human(&RebuildOutcome::Empty).contains("empty"));
assert!(
format_human(&RebuildOutcome::AlreadyRebuilt { total_rows: 5 })
.contains("already rebuilt")
);
let s = format_human(&RebuildOutcome::Rebuilt {
rows_filled: 3,
total_rows: 5,
});
assert!(s.contains("filled 3"));
assert!(s.contains("of 5"));
}
#[test]
fn format_json_uses_outcome_discriminator() {
let s = format_json(&RebuildOutcome::Rebuilt {
rows_filled: 3,
total_rows: 5,
});
assert!(s.contains(r#""outcome":"rebuilt""#), "got: {s}");
assert!(s.contains(r#""rows_filled":3"#), "got: {s}");
assert!(s.contains(r#""total_rows":5"#), "got: {s}");
}
}