use sqlx::sqlite::SqliteConnection;
use sqlx::{Pool, Sqlite, Transaction};
use super::hash::{
AuditRowForHashing, GENESIS_PREV_HASH, compute_audit_row_hash, parse_stored_hash,
};
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct AuditRowForAppend {
pub created_at: i64,
pub action: String,
pub actor_did: String,
pub target: Option<String>,
pub target_cid: Option<String>,
pub outcome: String,
pub reason: Option<String>,
}
impl AuditRowForAppend {
fn as_hashing(&self) -> AuditRowForHashing<'_> {
AuditRowForHashing {
created_at: self.created_at,
action: &self.action,
actor_did: &self.actor_did,
target: self.target.as_deref(),
target_cid: self.target_cid.as_deref(),
outcome: &self.outcome,
reason: self.reason.as_deref(),
}
}
}
pub async fn append_in_tx(
tx: &mut Transaction<'_, Sqlite>,
row: &AuditRowForAppend,
) -> Result<i64> {
perform_append(tx, row).await
}
pub async fn append_via_pool(pool: &Pool<Sqlite>, row: &AuditRowForAppend) -> Result<i64> {
let mut conn = pool
.acquire()
.await
.map_err(|e| Error::Signing(format!("audit acquire: {e}")))?;
sqlx::query("BEGIN IMMEDIATE")
.execute(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("audit begin: {e}")))?;
match perform_append(&mut conn, row).await {
Ok(id) => {
sqlx::query("COMMIT")
.execute(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("audit commit: {e}")))?;
Ok(id)
}
Err(e) => {
let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
Err(e)
}
}
}
async fn perform_append(conn: &mut SqliteConnection, row: &AuditRowForAppend) -> Result<i64> {
let prev_hash = read_latest_chain_hash(&mut *conn).await?;
let row_hash = compute_audit_row_hash(&prev_hash, &row.as_hashing())?;
let prev_hash_slice: &[u8] = &prev_hash;
let row_hash_slice: &[u8] = &row_hash;
let id = sqlx::query_scalar!(
"INSERT INTO audit_log
(created_at, action, actor_did, target, target_cid, outcome, reason,
prev_hash, row_hash)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
RETURNING id",
row.created_at,
row.action,
row.actor_did,
row.target,
row.target_cid,
row.outcome,
row.reason,
prev_hash_slice,
row_hash_slice,
)
.fetch_one(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("audit append: {e}")))?;
Ok(id)
}
pub(crate) async fn read_latest_chain_hash(conn: &mut SqliteConnection) -> Result<[u8; 32]> {
let candidates = read_chain_tip_candidates(&mut *conn).await?;
Ok(select_chain_tip(&candidates).unwrap_or(GENESIS_PREV_HASH))
}
#[derive(Debug, Clone)]
struct ChainTipCandidate {
timestamp: i64,
priority: u8,
row_hash: Option<Vec<u8>>,
}
async fn read_chain_tip_candidates(conn: &mut SqliteConnection) -> Result<Vec<ChainTipCandidate>> {
let mut out = Vec::with_capacity(4);
let audit_log = sqlx::query!(
"SELECT row_hash, created_at FROM audit_log
WHERE row_hash IS NOT NULL
ORDER BY id DESC LIMIT 1"
)
.fetch_optional(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("audit_log prev_hash read: {e}")))?;
if let Some(r) = audit_log {
out.push(ChainTipCandidate {
timestamp: r.created_at,
priority: 0,
row_hash: r.row_hash,
});
}
let pds_admin = sqlx::query!(
"SELECT row_hash, call_completed_at FROM pds_admin_audit
ORDER BY id DESC LIMIT 1"
)
.fetch_optional(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("pds_admin_audit prev_hash read: {e}")))?;
if let Some(r) = pds_admin {
out.push(ChainTipCandidate {
timestamp: r.call_completed_at,
priority: 1,
row_hash: Some(r.row_hash),
});
}
let known_callers = sqlx::query!(
"SELECT row_hash, added_at FROM xrpc_known_callers
ORDER BY added_at DESC LIMIT 1"
)
.fetch_optional(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("xrpc_known_callers prev_hash read: {e}")))?;
if let Some(r) = known_callers {
out.push(ChainTipCandidate {
timestamp: r.added_at,
priority: 2,
row_hash: Some(r.row_hash),
});
}
let trusted_pdses = sqlx::query!(
"SELECT row_hash, added_at FROM xrpc_trusted_pdses
ORDER BY added_at DESC LIMIT 1"
)
.fetch_optional(&mut *conn)
.await
.map_err(|e| Error::Signing(format!("xrpc_trusted_pdses prev_hash read: {e}")))?;
if let Some(r) = trusted_pdses {
out.push(ChainTipCandidate {
timestamp: r.added_at,
priority: 3,
row_hash: Some(r.row_hash),
});
}
Ok(out)
}
pub(crate) async fn read_latest_chain_timestamp_ms(
conn: &mut SqliteConnection,
) -> Result<Option<i64>> {
let candidates = read_chain_tip_candidates(&mut *conn).await?;
Ok(candidates.iter().map(|c| c.timestamp).max())
}
fn select_chain_tip(candidates: &[ChainTipCandidate]) -> Option<[u8; 32]> {
let mut best: Option<&ChainTipCandidate> = None;
for c in candidates {
if c.row_hash.is_none() {
continue;
}
match best {
None => best = Some(c),
Some(b) => {
if c.timestamp > b.timestamp
|| (c.timestamp == b.timestamp && c.priority > b.priority)
{
best = Some(c);
}
}
}
}
let bytes = best?.row_hash.as_ref()?;
parse_stored_hash(bytes).ok()
}
#[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-test.db");
let pool = storage::open(&path).await.unwrap();
Box::leak(Box::new(dir));
pool
}
fn sample_row(action: &str, actor_did: &str) -> AuditRowForAppend {
AuditRowForAppend {
created_at: 1_776_902_400_000,
action: action.into(),
actor_did: actor_did.into(),
target: Some("at://did:plc:target/col/r".into()),
target_cid: None,
outcome: "success".into(),
reason: None,
}
}
#[tokio::test]
async fn first_append_uses_genesis_prev_hash() {
let pool = fresh_pool().await;
let id = append_via_pool(&pool, &sample_row("label_applied", "did:plc:m1"))
.await
.unwrap();
let stored = sqlx::query!(
"SELECT prev_hash, row_hash FROM audit_log WHERE id = ?1",
id
)
.fetch_one(&pool)
.await
.unwrap();
let prev: &[u8] = stored.prev_hash.as_deref().expect("prev_hash present");
assert_eq!(prev, GENESIS_PREV_HASH);
assert!(stored.row_hash.is_some());
}
#[tokio::test]
async fn second_append_chains_to_first_row_hash() {
let pool = fresh_pool().await;
let id1 = append_via_pool(&pool, &sample_row("label_applied", "did:plc:m1"))
.await
.unwrap();
let id2 = append_via_pool(&pool, &sample_row("label_negated", "did:plc:m1"))
.await
.unwrap();
let row1_hash: Vec<u8> = sqlx::query_scalar!(
r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
id1
)
.fetch_one(&pool)
.await
.unwrap();
let row2_prev: Vec<u8> = sqlx::query_scalar!(
r#"SELECT prev_hash AS "prev_hash!" FROM audit_log WHERE id = ?1"#,
id2
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
row1_hash, row2_prev,
"row 2's prev_hash must match row 1's row_hash"
);
}
#[tokio::test]
async fn pre_v13_null_rows_are_skipped_for_prev_hash_lookup() {
let pool = fresh_pool().await;
sqlx::query!(
"INSERT INTO audit_log (created_at, action, actor_did, outcome)
VALUES (?1, ?2, ?3, ?4)",
1_i64,
"label_applied",
"did:plc:m1",
"success"
)
.execute(&pool)
.await
.unwrap();
let v13_id = append_via_pool(&pool, &sample_row("label_negated", "did:plc:m1"))
.await
.unwrap();
let prev: Option<Vec<u8>> =
sqlx::query_scalar!("SELECT prev_hash FROM audit_log WHERE id = ?1", v13_id)
.fetch_one(&pool)
.await
.unwrap();
let prev_bytes = prev.expect("prev_hash present on v1.3 row");
assert_eq!(
prev_bytes.as_slice(),
GENESIS_PREV_HASH,
"v1.3 row must use GENESIS_PREV_HASH when latest row has NULL row_hash"
);
}
#[tokio::test]
async fn append_in_tx_and_append_via_pool_produce_same_row_hash() {
let pool_a = fresh_pool().await;
let pool_b = fresh_pool().await;
let row = sample_row("label_applied", "did:plc:m1");
let id_a = append_via_pool(&pool_a, &row).await.unwrap();
let hash_a: Vec<u8> = sqlx::query_scalar!(
r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
id_a
)
.fetch_one(&pool_a)
.await
.unwrap();
let mut tx = pool_b.begin().await.unwrap();
let id_b = append_in_tx(&mut tx, &row).await.unwrap();
tx.commit().await.unwrap();
let hash_b: Vec<u8> = sqlx::query_scalar!(
r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
id_b
)
.fetch_one(&pool_b)
.await
.unwrap();
assert_eq!(
hash_a, hash_b,
"append_via_pool and append_in_tx must agree on row_hash for identical input"
);
}
}