1use sqlx::sqlite::SqliteConnection;
28use sqlx::{Pool, Sqlite, Transaction};
29
30use super::hash::{
31 AuditRowForHashing, GENESIS_PREV_HASH, compute_audit_row_hash, parse_stored_hash,
32};
33use crate::error::{Error, Result};
34
35#[derive(Debug, Clone)]
39pub struct AuditRowForAppend {
40 pub created_at: i64,
42 pub action: String,
44 pub actor_did: String,
46 pub target: Option<String>,
48 pub target_cid: Option<String>,
50 pub outcome: String,
52 pub reason: Option<String>,
54}
55
56impl AuditRowForAppend {
57 fn as_hashing(&self) -> AuditRowForHashing<'_> {
59 AuditRowForHashing {
60 created_at: self.created_at,
61 action: &self.action,
62 actor_did: &self.actor_did,
63 target: self.target.as_deref(),
64 target_cid: self.target_cid.as_deref(),
65 outcome: &self.outcome,
66 reason: self.reason.as_deref(),
67 }
68 }
69}
70
71pub async fn append_in_tx(
83 tx: &mut Transaction<'_, Sqlite>,
84 row: &AuditRowForAppend,
85) -> Result<i64> {
86 perform_append(tx, row).await
87}
88
89pub async fn append_via_pool(pool: &Pool<Sqlite>, row: &AuditRowForAppend) -> Result<i64> {
103 let mut conn = pool
104 .acquire()
105 .await
106 .map_err(|e| Error::Signing(format!("audit acquire: {e}")))?;
107 sqlx::query("BEGIN IMMEDIATE")
108 .execute(&mut *conn)
109 .await
110 .map_err(|e| Error::Signing(format!("audit begin: {e}")))?;
111
112 match perform_append(&mut conn, row).await {
113 Ok(id) => {
114 sqlx::query("COMMIT")
115 .execute(&mut *conn)
116 .await
117 .map_err(|e| Error::Signing(format!("audit commit: {e}")))?;
118 Ok(id)
119 }
120 Err(e) => {
121 let _ = sqlx::query("ROLLBACK").execute(&mut *conn).await;
125 Err(e)
126 }
127 }
128}
129
130async fn perform_append(conn: &mut SqliteConnection, row: &AuditRowForAppend) -> Result<i64> {
135 let prev_hash = read_latest_row_hash(&mut *conn).await?;
136 let row_hash = compute_audit_row_hash(&prev_hash, &row.as_hashing())?;
137
138 let prev_hash_slice: &[u8] = &prev_hash;
139 let row_hash_slice: &[u8] = &row_hash;
140 let id = sqlx::query_scalar!(
141 "INSERT INTO audit_log
142 (created_at, action, actor_did, target, target_cid, outcome, reason,
143 prev_hash, row_hash)
144 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
145 RETURNING id",
146 row.created_at,
147 row.action,
148 row.actor_did,
149 row.target,
150 row.target_cid,
151 row.outcome,
152 row.reason,
153 prev_hash_slice,
154 row_hash_slice,
155 )
156 .fetch_one(&mut *conn)
157 .await
158 .map_err(|e| Error::Signing(format!("audit append: {e}")))?;
159 Ok(id)
160}
161
162async fn read_latest_row_hash(conn: &mut SqliteConnection) -> Result<[u8; 32]> {
169 let row = sqlx::query!(
170 "SELECT row_hash FROM audit_log
171 WHERE row_hash IS NOT NULL
172 ORDER BY id DESC LIMIT 1"
173 )
174 .fetch_optional(&mut *conn)
175 .await
176 .map_err(|e| Error::Signing(format!("audit prev_hash read: {e}")))?;
177 match row.and_then(|r| r.row_hash) {
178 Some(bytes) => parse_stored_hash(&bytes),
179 None => Ok(GENESIS_PREV_HASH),
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use crate::storage;
187 use tempfile::tempdir;
188
189 async fn fresh_pool() -> Pool<Sqlite> {
190 let dir = tempdir().unwrap();
191 let path = dir.path().join("audit-test.db");
192 let pool = storage::open(&path).await.unwrap();
193 Box::leak(Box::new(dir));
197 pool
198 }
199
200 fn sample_row(action: &str, actor_did: &str) -> AuditRowForAppend {
201 AuditRowForAppend {
202 created_at: 1_776_902_400_000,
203 action: action.into(),
204 actor_did: actor_did.into(),
205 target: Some("at://did:plc:target/col/r".into()),
206 target_cid: None,
207 outcome: "success".into(),
208 reason: None,
209 }
210 }
211
212 #[tokio::test]
213 async fn first_append_uses_genesis_prev_hash() {
214 let pool = fresh_pool().await;
215 let id = append_via_pool(&pool, &sample_row("label_applied", "did:plc:m1"))
216 .await
217 .unwrap();
218
219 let stored = sqlx::query!(
220 "SELECT prev_hash, row_hash FROM audit_log WHERE id = ?1",
221 id
222 )
223 .fetch_one(&pool)
224 .await
225 .unwrap();
226
227 let prev: &[u8] = stored.prev_hash.as_deref().expect("prev_hash present");
228 assert_eq!(prev, GENESIS_PREV_HASH);
229 assert!(stored.row_hash.is_some());
230 }
231
232 #[tokio::test]
233 async fn second_append_chains_to_first_row_hash() {
234 let pool = fresh_pool().await;
235 let id1 = append_via_pool(&pool, &sample_row("label_applied", "did:plc:m1"))
236 .await
237 .unwrap();
238 let id2 = append_via_pool(&pool, &sample_row("label_negated", "did:plc:m1"))
239 .await
240 .unwrap();
241
242 let row1_hash: Vec<u8> = sqlx::query_scalar!(
243 r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
244 id1
245 )
246 .fetch_one(&pool)
247 .await
248 .unwrap();
249 let row2_prev: Vec<u8> = sqlx::query_scalar!(
250 r#"SELECT prev_hash AS "prev_hash!" FROM audit_log WHERE id = ?1"#,
251 id2
252 )
253 .fetch_one(&pool)
254 .await
255 .unwrap();
256 assert_eq!(
257 row1_hash, row2_prev,
258 "row 2's prev_hash must match row 1's row_hash"
259 );
260 }
261
262 #[tokio::test]
263 async fn pre_v13_null_rows_are_skipped_for_prev_hash_lookup() {
264 let pool = fresh_pool().await;
265 sqlx::query!(
269 "INSERT INTO audit_log (created_at, action, actor_did, outcome)
270 VALUES (?1, ?2, ?3, ?4)",
271 1_i64,
272 "label_applied",
273 "did:plc:m1",
274 "success"
275 )
276 .execute(&pool)
277 .await
278 .unwrap();
279
280 let v13_id = append_via_pool(&pool, &sample_row("label_negated", "did:plc:m1"))
281 .await
282 .unwrap();
283
284 let prev: Option<Vec<u8>> =
285 sqlx::query_scalar!("SELECT prev_hash FROM audit_log WHERE id = ?1", v13_id)
286 .fetch_one(&pool)
287 .await
288 .unwrap();
289 let prev_bytes = prev.expect("prev_hash present on v1.3 row");
290 assert_eq!(
291 prev_bytes.as_slice(),
292 GENESIS_PREV_HASH,
293 "v1.3 row must use GENESIS_PREV_HASH when latest row has NULL row_hash"
294 );
295 }
296
297 #[tokio::test]
298 async fn append_in_tx_and_append_via_pool_produce_same_row_hash() {
299 let pool_a = fresh_pool().await;
304 let pool_b = fresh_pool().await;
305 let row = sample_row("label_applied", "did:plc:m1");
306
307 let id_a = append_via_pool(&pool_a, &row).await.unwrap();
309 let hash_a: Vec<u8> = sqlx::query_scalar!(
310 r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
311 id_a
312 )
313 .fetch_one(&pool_a)
314 .await
315 .unwrap();
316
317 let mut tx = pool_b.begin().await.unwrap();
321 let id_b = append_in_tx(&mut tx, &row).await.unwrap();
322 tx.commit().await.unwrap();
323 let hash_b: Vec<u8> = sqlx::query_scalar!(
324 r#"SELECT row_hash AS "row_hash!" FROM audit_log WHERE id = ?1"#,
325 id_b
326 )
327 .fetch_one(&pool_b)
328 .await
329 .unwrap();
330
331 assert_eq!(
332 hash_a, hash_b,
333 "append_via_pool and append_in_tx must agree on row_hash for identical input"
334 );
335 }
336}