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_row_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)
}
async fn read_latest_row_hash(conn: &mut SqliteConnection) -> Result<[u8; 32]> {
let row = sqlx::query!(
"SELECT row_hash 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 prev_hash read: {e}")))?;
match row.and_then(|r| r.row_hash) {
Some(bytes) => parse_stored_hash(&bytes),
None => Ok(GENESIS_PREV_HASH),
}
}
#[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"
);
}
}