1use std::cmp::Ordering;
75
76use serde::Serialize;
77use sqlx::{Pool, Sqlite};
78
79use super::error::CliError;
80use crate::audit::hash::{
81 AuditRowForHashing, GENESIS_PREV_HASH, compute_audit_row_hash, parse_stored_hash,
82};
83use crate::pds_admin::audit::{PdsAdminAuditRowForHashing, compute_pds_admin_audit_row_hash};
84use crate::xrpc_gateway::membership::recompute_membership_row_hash;
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum AuditTable {
91 AuditLog,
93 PdsAdminAudit,
95 XrpcKnownCallers,
98 XrpcTrustedPdses,
101}
102
103impl AuditTable {
104 pub fn as_str(self) -> &'static str {
108 match self {
109 Self::AuditLog => "audit_log",
110 Self::PdsAdminAudit => "pds_admin_audit",
111 Self::XrpcKnownCallers => "xrpc_known_callers",
112 Self::XrpcTrustedPdses => "xrpc_trusted_pdses",
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120#[serde(tag = "outcome", rename_all = "snake_case")]
121pub enum VerifyOutcome {
122 Empty,
124 Verified {
126 total_rows: i64,
128 attested_rows: i64,
131 pre_attestation_rows: i64,
136 #[serde(skip_serializing_if = "Option::is_none")]
145 attestation_starts_at_row: Option<i64>,
146 audit_log_rows: i64,
151 pds_admin_audit_rows: i64,
155 xrpc_known_callers_rows: i64,
159 xrpc_trusted_pdses_rows: i64,
163 },
164 Divergence {
166 table: AuditTable,
169 row_id: i64,
172 expected_hash: String,
175 actual_hash: String,
177 attested_rows_before_divergence: i64,
181 },
182}
183
184pub async fn verify(pool: &Pool<Sqlite>) -> Result<VerifyOutcome, CliError> {
199 let audit_log_rows = read_audit_log_rows(pool).await?;
203 let pds_admin_rows = read_pds_admin_audit_rows(pool).await?;
204 let xrpc_known_callers_rows = read_xrpc_known_callers_rows(pool).await?;
205 let xrpc_trusted_pdses_rows = read_xrpc_trusted_pdses_rows(pool).await?;
206
207 if audit_log_rows.is_empty()
208 && pds_admin_rows.is_empty()
209 && xrpc_known_callers_rows.is_empty()
210 && xrpc_trusted_pdses_rows.is_empty()
211 {
212 return Ok(VerifyOutcome::Empty);
213 }
214
215 let audit_log_count = audit_log_rows.len() as i64;
216 let pds_admin_count = pds_admin_rows.len() as i64;
217 let xrpc_known_callers_count = xrpc_known_callers_rows.len() as i64;
218 let xrpc_trusted_pdses_count = xrpc_trusted_pdses_rows.len() as i64;
219
220 let mut entries: Vec<UnifiedEntry> = Vec::with_capacity(
221 audit_log_rows.len()
222 + pds_admin_rows.len()
223 + xrpc_known_callers_rows.len()
224 + xrpc_trusted_pdses_rows.len(),
225 );
226 entries.extend(audit_log_rows.into_iter().map(UnifiedEntry::AuditLog));
227 entries.extend(pds_admin_rows.into_iter().map(UnifiedEntry::PdsAdmin));
228 entries.extend(
229 xrpc_known_callers_rows
230 .into_iter()
231 .map(UnifiedEntry::XrpcKnownCaller),
232 );
233 entries.extend(
234 xrpc_trusted_pdses_rows
235 .into_iter()
236 .map(UnifiedEntry::XrpcTrustedPds),
237 );
238 entries.sort_by(unified_chain_cmp);
239
240 let total_rows =
241 audit_log_count + pds_admin_count + xrpc_known_callers_count + xrpc_trusted_pdses_count;
242 let mut running_prev_hash: [u8; 32] = GENESIS_PREV_HASH;
243 let mut attested_rows: i64 = 0;
244 let mut pre_attestation_rows: i64 = 0;
245 let mut attestation_starts_at_row: Option<i64> = None;
246 let mut seen_attested = false;
247
248 for entry in &entries {
249 let stored_row_hash_blob = match entry.row_hash() {
250 Some(b) => b,
251 None => {
252 pre_attestation_rows += 1;
255 continue;
256 }
257 };
258
259 if !seen_attested {
260 seen_attested = true;
261 if let UnifiedEntry::AuditLog(row) = entry
268 && row.id != 1
269 {
270 attestation_starts_at_row = Some(row.id);
271 }
272 }
273
274 let stored_row_hash = parse_stored_hash(stored_row_hash_blob).map_err(|e| {
275 CliError::Startup(format!(
276 "audit verify: {}:{} stored row_hash malformed: {e}",
277 entry.table().as_str(),
278 entry.id()
279 ))
280 })?;
281
282 let recomputed = entry.recompute_row_hash(&running_prev_hash).map_err(|e| {
283 CliError::Startup(format!(
284 "audit verify: {}:{} hash compute: {e}",
285 entry.table().as_str(),
286 entry.id()
287 ))
288 })?;
289
290 if recomputed != stored_row_hash {
291 return Ok(VerifyOutcome::Divergence {
293 table: entry.table(),
294 row_id: entry.id(),
295 expected_hash: hex::encode(recomputed),
296 actual_hash: hex::encode(stored_row_hash),
297 attested_rows_before_divergence: attested_rows,
298 });
299 }
300
301 attested_rows += 1;
302 running_prev_hash = stored_row_hash;
303 }
304
305 Ok(VerifyOutcome::Verified {
306 total_rows,
307 attested_rows,
308 pre_attestation_rows,
309 attestation_starts_at_row,
310 audit_log_rows: audit_log_count,
311 pds_admin_audit_rows: pds_admin_count,
312 xrpc_known_callers_rows: xrpc_known_callers_count,
313 xrpc_trusted_pdses_rows: xrpc_trusted_pdses_count,
314 })
315}
316
317struct AuditLogRow {
323 id: i64,
324 created_at: i64,
325 action: String,
326 actor_did: String,
327 target: Option<String>,
328 target_cid: Option<String>,
329 outcome: String,
330 reason: Option<String>,
331 row_hash: Option<Vec<u8>>,
332}
333
334struct PdsAdminAuditRow {
336 id: i64,
337 precipitating_action_id: i64,
338 backend_method: String,
339 backend_action_id: Option<String>,
340 outcome: String,
341 error_code: Option<String>,
342 error_message: Option<String>,
343 retry_after_seconds: Option<i64>,
344 call_started_at: i64,
345 call_completed_at: i64,
346 row_hash: Vec<u8>,
347}
348
349struct XrpcMembershipRow {
354 rowid: i64,
361 did: String,
362 note: Option<String>,
363 added_by_moderator: String,
364 added_at: i64,
365 row_hash: Vec<u8>,
366}
367
368enum UnifiedEntry {
371 AuditLog(AuditLogRow),
372 PdsAdmin(PdsAdminAuditRow),
373 XrpcKnownCaller(XrpcMembershipRow),
374 XrpcTrustedPds(XrpcMembershipRow),
375}
376
377impl UnifiedEntry {
378 fn table(&self) -> AuditTable {
379 match self {
380 Self::AuditLog(_) => AuditTable::AuditLog,
381 Self::PdsAdmin(_) => AuditTable::PdsAdminAudit,
382 Self::XrpcKnownCaller(_) => AuditTable::XrpcKnownCallers,
383 Self::XrpcTrustedPds(_) => AuditTable::XrpcTrustedPdses,
384 }
385 }
386
387 fn id(&self) -> i64 {
388 match self {
389 Self::AuditLog(r) => r.id,
390 Self::PdsAdmin(r) => r.id,
391 Self::XrpcKnownCaller(r) | Self::XrpcTrustedPds(r) => r.rowid,
392 }
393 }
394
395 fn timestamp(&self) -> i64 {
402 match self {
403 Self::AuditLog(r) => r.created_at,
404 Self::PdsAdmin(r) => r.call_completed_at,
405 Self::XrpcKnownCaller(r) | Self::XrpcTrustedPds(r) => r.added_at,
406 }
407 }
408
409 fn row_hash(&self) -> Option<&[u8]> {
414 match self {
415 Self::AuditLog(r) => r.row_hash.as_deref(),
416 Self::PdsAdmin(r) => Some(&r.row_hash),
417 Self::XrpcKnownCaller(r) | Self::XrpcTrustedPds(r) => Some(&r.row_hash),
418 }
419 }
420
421 fn recompute_row_hash(&self, prev_hash: &[u8; 32]) -> Result<[u8; 32], crate::error::Error> {
422 match self {
423 Self::AuditLog(r) => compute_audit_row_hash(
424 prev_hash,
425 &AuditRowForHashing {
426 created_at: r.created_at,
427 action: &r.action,
428 actor_did: &r.actor_did,
429 target: r.target.as_deref(),
430 target_cid: r.target_cid.as_deref(),
431 outcome: &r.outcome,
432 reason: r.reason.as_deref(),
433 },
434 ),
435 Self::PdsAdmin(r) => compute_pds_admin_audit_row_hash(
436 prev_hash,
437 &PdsAdminAuditRowForHashing {
438 precipitating_action_id: r.precipitating_action_id,
439 backend_method: &r.backend_method,
440 backend_action_id: r.backend_action_id.as_deref(),
441 outcome: &r.outcome,
442 error_code: r.error_code.as_deref(),
443 error_message: r.error_message.as_deref(),
444 retry_after_seconds: r.retry_after_seconds,
445 call_started_at: r.call_started_at,
446 call_completed_at: r.call_completed_at,
447 },
448 ),
449 Self::XrpcKnownCaller(r) | Self::XrpcTrustedPds(r) => recompute_membership_row_hash(
450 prev_hash,
451 &r.did,
452 r.note.as_deref(),
453 &r.added_by_moderator,
454 r.added_at,
455 ),
456 }
457 }
458
459 fn table_priority(&self) -> u8 {
465 match self {
466 Self::AuditLog(_) => 0,
467 Self::PdsAdmin(_) => 1,
468 Self::XrpcKnownCaller(_) => 2,
469 Self::XrpcTrustedPds(_) => 3,
470 }
471 }
472}
473
474fn unified_chain_cmp(a: &UnifiedEntry, b: &UnifiedEntry) -> Ordering {
475 a.timestamp()
476 .cmp(&b.timestamp())
477 .then_with(|| a.table_priority().cmp(&b.table_priority()))
478 .then_with(|| a.id().cmp(&b.id()))
479}
480
481async fn read_audit_log_rows(pool: &Pool<Sqlite>) -> Result<Vec<AuditLogRow>, CliError> {
486 let rows = sqlx::query!(
487 "SELECT id, created_at, action, actor_did, target, target_cid, outcome, reason,
488 prev_hash, row_hash
489 FROM audit_log
490 ORDER BY id ASC"
491 )
492 .fetch_all(pool)
493 .await
494 .map_err(|e| CliError::Startup(format!("audit verify scan audit_log: {e}")))?;
495
496 Ok(rows
497 .into_iter()
498 .map(|r| AuditLogRow {
499 id: r.id,
500 created_at: r.created_at,
501 action: r.action,
502 actor_did: r.actor_did,
503 target: r.target,
504 target_cid: r.target_cid,
505 outcome: r.outcome,
506 reason: r.reason,
507 row_hash: r.row_hash,
508 })
509 .collect())
510}
511
512async fn read_xrpc_known_callers_rows(
513 pool: &Pool<Sqlite>,
514) -> Result<Vec<XrpcMembershipRow>, CliError> {
515 let rows = sqlx::query!(
516 "SELECT did, note, added_by_moderator, added_at, row_hash
517 FROM xrpc_known_callers
518 ORDER BY added_at ASC, did ASC"
519 )
520 .fetch_all(pool)
521 .await
522 .map_err(|e| CliError::Startup(format!("audit verify scan xrpc_known_callers: {e}")))?;
523 Ok(rows
524 .into_iter()
525 .enumerate()
526 .map(|(i, r)| XrpcMembershipRow {
527 rowid: i as i64,
528 did: r.did,
529 note: r.note,
530 added_by_moderator: r.added_by_moderator,
531 added_at: r.added_at,
532 row_hash: r.row_hash,
533 })
534 .collect())
535}
536
537async fn read_xrpc_trusted_pdses_rows(
538 pool: &Pool<Sqlite>,
539) -> Result<Vec<XrpcMembershipRow>, CliError> {
540 let rows = sqlx::query!(
541 "SELECT did, note, added_by_moderator, added_at, row_hash
542 FROM xrpc_trusted_pdses
543 ORDER BY added_at ASC, did ASC"
544 )
545 .fetch_all(pool)
546 .await
547 .map_err(|e| CliError::Startup(format!("audit verify scan xrpc_trusted_pdses: {e}")))?;
548 Ok(rows
549 .into_iter()
550 .enumerate()
551 .map(|(i, r)| XrpcMembershipRow {
552 rowid: i as i64,
553 did: r.did,
554 note: r.note,
555 added_by_moderator: r.added_by_moderator,
556 added_at: r.added_at,
557 row_hash: r.row_hash,
558 })
559 .collect())
560}
561
562async fn read_pds_admin_audit_rows(pool: &Pool<Sqlite>) -> Result<Vec<PdsAdminAuditRow>, CliError> {
563 let rows = sqlx::query!(
564 r#"SELECT id AS "id!", precipitating_action_id, backend_method,
565 backend_action_id, outcome, error_code, error_message,
566 retry_after_seconds, row_hash, call_started_at, call_completed_at
567 FROM pds_admin_audit
568 ORDER BY id ASC"#
569 )
570 .fetch_all(pool)
571 .await
572 .map_err(|e| CliError::Startup(format!("audit verify scan pds_admin_audit: {e}")))?;
573
574 Ok(rows
575 .into_iter()
576 .map(|r| PdsAdminAuditRow {
577 id: r.id,
578 precipitating_action_id: r.precipitating_action_id,
579 backend_method: r.backend_method,
580 backend_action_id: r.backend_action_id,
581 outcome: r.outcome,
582 error_code: r.error_code,
583 error_message: r.error_message,
584 retry_after_seconds: r.retry_after_seconds,
585 call_started_at: r.call_started_at,
586 call_completed_at: r.call_completed_at,
587 row_hash: r.row_hash,
588 })
589 .collect())
590}
591
592pub fn format_human(outcome: &VerifyOutcome) -> String {
600 use std::fmt::Write;
601 match outcome {
602 VerifyOutcome::Empty => "audit chain is empty; nothing to verify".to_string(),
603 VerifyOutcome::Verified {
604 total_rows,
605 attested_rows,
606 pre_attestation_rows,
607 attestation_starts_at_row,
608 audit_log_rows,
609 pds_admin_audit_rows,
610 xrpc_known_callers_rows,
611 xrpc_trusted_pdses_rows,
612 } => {
613 let mut s = String::new();
614 let _ = writeln!(
615 s,
616 "audit chain verified: {attested_rows} attested row(s) of {total_rows} total"
617 );
618 let _ = writeln!(
619 s,
620 " audit_log: {audit_log_rows} row(s); pds_admin_audit: {pds_admin_audit_rows} row(s); \
621 xrpc_known_callers: {xrpc_known_callers_rows} row(s); xrpc_trusted_pdses: {xrpc_trusted_pdses_rows} row(s)"
622 );
623 if *pre_attestation_rows > 0 {
624 let _ = writeln!(
625 s,
626 " skipped {pre_attestation_rows} row(s) pre-dating audit chain attestation"
627 );
628 }
629 if let Some(n) = attestation_starts_at_row {
630 let _ = write!(
631 s,
632 " attestation starts at audit_log row {n} (trust horizon)"
633 );
634 } else if s.ends_with('\n') {
635 s.pop();
636 }
637 s
638 }
639 VerifyOutcome::Divergence {
640 table,
641 row_id,
642 expected_hash,
643 actual_hash,
644 attested_rows_before_divergence,
645 } => {
646 let mut s = String::new();
647 let _ = writeln!(s, "audit chain divergence at {}:{row_id}", table.as_str());
648 let _ = writeln!(s, " expected: {expected_hash}");
649 let _ = writeln!(s, " actual: {actual_hash}");
650 let _ = write!(
651 s,
652 " {attested_rows_before_divergence} row(s) verified before divergence"
653 );
654 s
655 }
656 }
657}
658
659pub fn format_json(outcome: &VerifyOutcome) -> String {
663 serde_json::to_string(outcome).expect("VerifyOutcome serializes")
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use crate::audit::append::{AuditRowForAppend, append_via_pool};
670 use crate::pds_admin::{BackendActionId, BackendMethod, record_pds_admin_call};
671 use crate::storage;
672 use tempfile::tempdir;
673
674 async fn fresh_pool() -> Pool<Sqlite> {
675 let dir = tempdir().unwrap();
676 let path = dir.path().join("audit-verify-test.db");
677 let pool = storage::open(&path).await.unwrap();
678 Box::leak(Box::new(dir));
679 pool
680 }
681
682 fn sample_audit_row(action: &str, actor_did: &str, created_at: i64) -> AuditRowForAppend {
683 AuditRowForAppend {
684 created_at,
685 action: action.into(),
686 actor_did: actor_did.into(),
687 target: None,
688 target_cid: None,
689 outcome: "success".into(),
690 reason: None,
691 }
692 }
693
694 async fn fixture_subject_action(pool: &Pool<Sqlite>) -> i64 {
697 sqlx::query_scalar!(
698 r#"INSERT INTO subject_actions (
699 subject_did, subject_uri, actor_did, action_type, reason_codes,
700 duration, effective_at, expires_at, notes, report_ids,
701 strike_value_base, strike_value_applied, was_dampened,
702 strikes_at_time_of_action, audit_log_id, created_at,
703 actor_kind, triggered_by_policy_rule
704 ) VALUES ('did:plc:s', NULL, 'did:plc:m', 'takedown', '["spam"]',
705 NULL, ?1, NULL, NULL, NULL, 1, 1, 0, 1, NULL, ?1,
706 'moderator', NULL)
707 RETURNING id AS "id!""#,
708 1_700_000_000_000_i64
709 )
710 .fetch_one(pool)
711 .await
712 .unwrap()
713 }
714
715 async fn append_pds_admin_audit(
726 pool: &Pool<Sqlite>,
727 precipitating_action_id: i64,
728 synthetic_id: &str,
729 started_at: i64,
730 completed_at: i64,
731 ) -> i64 {
732 record_pds_admin_call(
733 pool,
734 precipitating_action_id,
735 BackendMethod::TakedownAccount,
736 Ok(Some(BackendActionId::new(synthetic_id))),
737 started_at,
738 completed_at,
739 )
740 .await
741 .unwrap()
742 .id
743 }
744
745 async fn insert_pre_v13_row(
749 pool: &Pool<Sqlite>,
750 action: &str,
751 actor_did: &str,
752 created_at: i64,
753 ) {
754 sqlx::query!(
755 "INSERT INTO audit_log (created_at, action, actor_did, outcome) VALUES (?1, ?2, ?3, ?4)",
756 created_at,
757 action,
758 actor_did,
759 "success",
760 )
761 .execute(pool)
762 .await
763 .unwrap();
764 }
765
766 async fn drop_no_update_triggers(pool: &Pool<Sqlite>) {
772 sqlx::query("DROP TRIGGER IF EXISTS audit_log_no_update")
773 .execute(pool)
774 .await
775 .unwrap();
776 sqlx::query("DROP TRIGGER IF EXISTS pds_admin_audit_no_update")
777 .execute(pool)
778 .await
779 .unwrap();
780 }
781
782 #[tokio::test]
785 async fn empty_database_returns_empty() {
786 let pool = fresh_pool().await;
787 let outcome = verify(&pool).await.unwrap();
788 assert_eq!(outcome, VerifyOutcome::Empty);
789 }
790
791 #[tokio::test]
792 async fn audit_log_only_chain_verifies_with_zero_pds_admin_rows() {
793 let pool = fresh_pool().await;
797 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 1))
798 .await
799 .unwrap();
800 append_via_pool(&pool, &sample_audit_row("label_negated", "did:plc:m1", 2))
801 .await
802 .unwrap();
803
804 let outcome = verify(&pool).await.unwrap();
805 match outcome {
806 VerifyOutcome::Verified {
807 total_rows,
808 attested_rows,
809 pre_attestation_rows,
810 attestation_starts_at_row,
811 audit_log_rows,
812 pds_admin_audit_rows,
813 xrpc_known_callers_rows,
814 xrpc_trusted_pdses_rows,
815 } => {
816 assert_eq!(total_rows, 2);
817 assert_eq!(attested_rows, 2);
818 assert_eq!(pre_attestation_rows, 0);
819 assert_eq!(attestation_starts_at_row, None);
820 assert_eq!(audit_log_rows, 2);
821 assert_eq!(
822 pds_admin_audit_rows, 0,
823 "pre-#87 deployment has no pds_admin_audit rows"
824 );
825 assert_eq!(xrpc_known_callers_rows, 0);
826 assert_eq!(xrpc_trusted_pdses_rows, 0);
827 }
828 other => panic!("expected Verified, got {other:?}"),
829 }
830 }
831
832 #[tokio::test]
833 async fn pds_admin_audit_only_chain_verifies() {
834 let pool = fresh_pool().await;
839 let action_id = fixture_subject_action(&pool).await;
840 append_pds_admin_audit(&pool, action_id, "ozone:test:1", 100, 110).await;
841
842 let outcome = verify(&pool).await.unwrap();
843 match outcome {
844 VerifyOutcome::Verified {
845 total_rows,
846 audit_log_rows,
847 pds_admin_audit_rows,
848 ..
849 } => {
850 assert_eq!(total_rows, 1);
851 assert_eq!(audit_log_rows, 0);
852 assert_eq!(pds_admin_audit_rows, 1);
853 }
854 other => panic!("expected Verified, got {other:?}"),
855 }
856 }
857
858 #[tokio::test]
861 async fn interleaved_chain_verifies_across_table_boundary() {
862 let pool = fresh_pool().await;
867 let action_id = fixture_subject_action(&pool).await;
868
869 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 100))
874 .await
875 .unwrap();
876 append_pds_admin_audit(&pool, action_id, "ozone:s:42", 150, 200).await;
881 append_via_pool(&pool, &sample_audit_row("label_negated", "did:plc:m1", 300))
883 .await
884 .unwrap();
885
886 let outcome = verify(&pool).await.unwrap();
887 match outcome {
888 VerifyOutcome::Verified {
889 total_rows,
890 attested_rows,
891 audit_log_rows,
892 pds_admin_audit_rows,
893 ..
894 } => {
895 assert_eq!(total_rows, 3);
896 assert_eq!(attested_rows, 3);
897 assert_eq!(audit_log_rows, 2);
898 assert_eq!(pds_admin_audit_rows, 1);
899 }
900 other => panic!("expected Verified, got {other:?}"),
901 }
902 }
903
904 #[tokio::test]
905 async fn tampered_audit_log_row_in_unified_chain_reports_audit_log_table() {
906 let pool = fresh_pool().await;
907 let action_id = fixture_subject_action(&pool).await;
908 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 100))
909 .await
910 .unwrap();
911 append_pds_admin_audit(&pool, action_id, "ozone:s:1", 150, 200).await;
912 append_via_pool(&pool, &sample_audit_row("label_negated", "did:plc:m1", 300))
913 .await
914 .unwrap();
915
916 drop_no_update_triggers(&pool).await;
918 sqlx::query!("UPDATE audit_log SET actor_did = 'did:plc:attacker' WHERE id = 2")
919 .execute(&pool)
920 .await
921 .unwrap();
922
923 let outcome = verify(&pool).await.unwrap();
924 match outcome {
925 VerifyOutcome::Divergence {
926 table,
927 row_id,
928 attested_rows_before_divergence,
929 ..
930 } => {
931 assert_eq!(table, AuditTable::AuditLog);
932 assert_eq!(row_id, 2);
933 assert_eq!(attested_rows_before_divergence, 2);
936 }
937 other => panic!("expected Divergence, got {other:?}"),
938 }
939 }
940
941 #[tokio::test]
942 async fn tampered_pds_admin_audit_row_reports_pds_admin_audit_table() {
943 let pool = fresh_pool().await;
944 let action_id = fixture_subject_action(&pool).await;
945 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 100))
946 .await
947 .unwrap();
948 let pds_id = append_pds_admin_audit(&pool, action_id, "ozone:s:1", 150, 200).await;
949 append_via_pool(&pool, &sample_audit_row("label_negated", "did:plc:m1", 300))
950 .await
951 .unwrap();
952
953 drop_no_update_triggers(&pool).await;
956 sqlx::query!(
957 "UPDATE pds_admin_audit SET error_code = 'tampered' WHERE id = ?1",
958 pds_id
959 )
960 .execute(&pool)
961 .await
962 .unwrap();
963
964 let outcome = verify(&pool).await.unwrap();
965 match outcome {
966 VerifyOutcome::Divergence {
967 table,
968 row_id,
969 attested_rows_before_divergence,
970 ..
971 } => {
972 assert_eq!(table, AuditTable::PdsAdminAudit);
973 assert_eq!(row_id, pds_id);
974 assert_eq!(attested_rows_before_divergence, 1);
976 }
977 other => panic!("expected Divergence, got {other:?}"),
978 }
979 }
980
981 #[tokio::test]
982 async fn cross_table_link_tampering_caught_at_pds_admin_audit_row() {
983 let pool = fresh_pool().await;
990 let action_id = fixture_subject_action(&pool).await;
991 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 100))
992 .await
993 .unwrap();
994 let pds_id = append_pds_admin_audit(&pool, action_id, "ozone:s:1", 150, 200).await;
995
996 drop_no_update_triggers(&pool).await;
997 let bogus_prev: Vec<u8> = vec![0xCC; 32];
1003 sqlx::query!(
1004 "UPDATE pds_admin_audit SET prev_hash = ?1 WHERE id = ?2",
1005 bogus_prev,
1006 pds_id
1007 )
1008 .execute(&pool)
1009 .await
1010 .unwrap();
1011
1012 let bogus_row_hash: Vec<u8> = vec![0xEE; 32];
1021 sqlx::query!(
1022 "UPDATE pds_admin_audit SET row_hash = ?1 WHERE id = ?2",
1023 bogus_row_hash,
1024 pds_id
1025 )
1026 .execute(&pool)
1027 .await
1028 .unwrap();
1029
1030 let outcome = verify(&pool).await.unwrap();
1031 match outcome {
1032 VerifyOutcome::Divergence {
1033 table,
1034 row_id,
1035 actual_hash,
1036 attested_rows_before_divergence,
1037 ..
1038 } => {
1039 assert_eq!(table, AuditTable::PdsAdminAudit);
1040 assert_eq!(row_id, pds_id);
1041 assert_eq!(actual_hash, hex::encode([0xEEu8; 32]));
1042 assert_eq!(attested_rows_before_divergence, 1);
1044 }
1045 other => panic!("expected Divergence, got {other:?}"),
1046 }
1047 }
1048
1049 #[tokio::test]
1052 async fn mixed_pre_then_attested_audit_log_with_pds_admin_audit_horizon_only_for_audit_log() {
1053 let pool = fresh_pool().await;
1058 let action_id = fixture_subject_action(&pool).await;
1059 insert_pre_v13_row(&pool, "label_applied", "did:plc:m1", 1).await;
1060 insert_pre_v13_row(&pool, "label_negated", "did:plc:m1", 2).await;
1061 insert_pre_v13_row(&pool, "report_resolved", "did:plc:m2", 3).await;
1062 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m3", 4))
1063 .await
1064 .unwrap();
1065 append_pds_admin_audit(&pool, action_id, "ozone:s:1", 5, 6).await;
1067
1068 let outcome = verify(&pool).await.unwrap();
1069 match outcome {
1070 VerifyOutcome::Verified {
1071 total_rows,
1072 attested_rows,
1073 pre_attestation_rows,
1074 attestation_starts_at_row,
1075 audit_log_rows,
1076 pds_admin_audit_rows,
1077 xrpc_known_callers_rows,
1078 xrpc_trusted_pdses_rows,
1079 } => {
1080 assert_eq!(total_rows, 5);
1081 assert_eq!(attested_rows, 2);
1082 assert_eq!(pre_attestation_rows, 3);
1083 assert_eq!(attestation_starts_at_row, Some(4));
1084 assert_eq!(audit_log_rows, 4);
1085 assert_eq!(pds_admin_audit_rows, 1);
1086 assert_eq!(xrpc_known_callers_rows, 0);
1087 assert_eq!(xrpc_trusted_pdses_rows, 0);
1088 }
1089 other => panic!("expected Verified, got {other:?}"),
1090 }
1091 }
1092
1093 #[tokio::test]
1094 async fn verify_does_not_acquire_lease_safe_during_serve() {
1095 let pool = fresh_pool().await;
1099 let now_ms = crate::writer::epoch_ms_now();
1100 sqlx::query!(
1101 "INSERT INTO server_instance_lease (id, instance_id, acquired_at, last_heartbeat)
1102 VALUES (1, ?1, ?2, ?2)",
1103 "rival-writer",
1104 now_ms,
1105 )
1106 .execute(&pool)
1107 .await
1108 .unwrap();
1109 append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 1))
1110 .await
1111 .unwrap();
1112
1113 let outcome = verify(&pool).await.unwrap();
1114 assert!(
1115 matches!(outcome, VerifyOutcome::Verified { .. }),
1116 "verify must run while a lease is held; got {outcome:?}"
1117 );
1118 }
1119
1120 #[test]
1123 fn format_human_renders_each_outcome_shape() {
1124 assert!(format_human(&VerifyOutcome::Empty).contains("empty"));
1125
1126 let verified = format_human(&VerifyOutcome::Verified {
1127 total_rows: 10,
1128 attested_rows: 7,
1129 pre_attestation_rows: 3,
1130 attestation_starts_at_row: Some(4),
1131 audit_log_rows: 8,
1132 pds_admin_audit_rows: 2,
1133 xrpc_known_callers_rows: 0,
1134 xrpc_trusted_pdses_rows: 0,
1135 });
1136 assert!(verified.contains("7 attested"));
1137 assert!(verified.contains("of 10"));
1138 assert!(verified.contains("audit_log: 8"));
1139 assert!(verified.contains("pds_admin_audit: 2"));
1140 assert!(verified.contains("skipped 3"));
1141 assert!(verified.contains("trust horizon"));
1142 assert!(verified.contains("audit_log row 4"));
1143
1144 let no_horizon = format_human(&VerifyOutcome::Verified {
1145 total_rows: 5,
1146 attested_rows: 5,
1147 pre_attestation_rows: 0,
1148 attestation_starts_at_row: None,
1149 audit_log_rows: 5,
1150 pds_admin_audit_rows: 0,
1151 xrpc_known_callers_rows: 0,
1152 xrpc_trusted_pdses_rows: 0,
1153 });
1154 assert!(!no_horizon.contains("horizon"), "no horizon line when None");
1155 assert!(!no_horizon.contains("skipped"), "no skipped line when 0");
1156
1157 let div_audit = format_human(&VerifyOutcome::Divergence {
1158 table: AuditTable::AuditLog,
1159 row_id: 42,
1160 expected_hash: "abc123".into(),
1161 actual_hash: "def456".into(),
1162 attested_rows_before_divergence: 41,
1163 });
1164 assert!(div_audit.contains("audit_log:42"));
1165 assert!(div_audit.contains("expected: abc123"));
1166 assert!(div_audit.contains("actual: def456"));
1167 assert!(div_audit.contains("41 row"));
1168
1169 let div_pds = format_human(&VerifyOutcome::Divergence {
1170 table: AuditTable::PdsAdminAudit,
1171 row_id: 7,
1172 expected_hash: "abc".into(),
1173 actual_hash: "def".into(),
1174 attested_rows_before_divergence: 5,
1175 });
1176 assert!(div_pds.contains("pds_admin_audit:7"));
1177 }
1178
1179 #[test]
1180 fn format_json_uses_outcome_discriminator() {
1181 let s = format_json(&VerifyOutcome::Verified {
1182 total_rows: 5,
1183 attested_rows: 5,
1184 pre_attestation_rows: 0,
1185 attestation_starts_at_row: None,
1186 audit_log_rows: 3,
1187 pds_admin_audit_rows: 2,
1188 xrpc_known_callers_rows: 0,
1189 xrpc_trusted_pdses_rows: 0,
1190 });
1191 assert!(s.contains(r#""outcome":"verified""#), "got: {s}");
1192 assert!(
1193 !s.contains("attestation_starts_at_row"),
1194 "None should be skipped"
1195 );
1196 assert!(s.contains(r#""audit_log_rows":3"#));
1197 assert!(s.contains(r#""pds_admin_audit_rows":2"#));
1198
1199 let div = format_json(&VerifyOutcome::Divergence {
1200 table: AuditTable::PdsAdminAudit,
1201 row_id: 5,
1202 expected_hash: "aa".into(),
1203 actual_hash: "bb".into(),
1204 attested_rows_before_divergence: 4,
1205 });
1206 assert!(div.contains(r#""outcome":"divergence""#));
1207 assert!(div.contains(r#""table":"pds_admin_audit""#));
1208 assert!(div.contains(r#""row_id":5"#));
1209 }
1210}