Skip to main content

cairn_mod/cli/
audit_verify.rs

1//! `cairn audit verify` (#41 / v1.3; #88 / v1.7 unified-chain
2//! extension) — operator command for verifying the audit hash chain.
3//!
4//! Walks the **unified** chain across `audit_log` AND
5//! `pds_admin_audit` (since #85 / §F23) in chain order, recomputes
6//! each attested row's `row_hash` from the running prev_hash + the
7//! row's stored content, and compares against the stored hash.
8//! Reports the **first** divergence and stops.
9//!
10//! Continuing past first divergence would cascade — every row after
11//! a tampered row would also report mismatch, since the chain link
12//! is broken from there forward. The first divergence is the
13//! actionable signal; later rows are downstream noise.
14//!
15//! # Why both tables, not just `audit_log`
16//!
17//! v1.3's verify walked `audit_log` only. After #87 wired
18//! `pds_admin_audit` rows into the recordAction pipeline, the
19//! single-table walker would report false-positive divergences for
20//! any `audit_log` row whose chain-predecessor is a
21//! `pds_admin_audit` row (the predecessor's `row_hash` would not
22//! be findable in `audit_log`). #88 extends the walker to read
23//! both tables in a single ordered stream so the same chain
24//! integrity property holds across the v1.7+ shape.
25//!
26//! # Chain ordering
27//!
28//! Matches the tie-break convention used by the crate-internal
29//! `read_latest_chain_hash` helper in [`crate::audit::append`]
30//! (#85):
31//! 1. timestamp ascending — `audit_log.created_at` vs
32//!    `pds_admin_audit.call_completed_at`
33//! 2. on tie, `audit_log` comes before `pds_admin_audit` (because
34//!    #85's chain-tip read uses `pds.call_completed_at >= a.created_at`,
35//!    treating `pds_admin_audit` as the more-recent tie-winner;
36//!    inverted, that means `pds_admin_audit` comes AFTER
37//!    `audit_log` in chain order)
38//! 3. within a single table, id ascending (id is
39//!    `INTEGER PRIMARY KEY AUTOINCREMENT` — insertion order)
40//!
41//! # Read-only / streaming
42//!
43//! Read-only. No lease acquired (unlike `cairn audit-rebuild`); safe
44//! to run while `cairn serve` is live. Concurrent writes during
45//! verify are fine — SQLite's WAL gives us a consistent snapshot,
46//! and any new rows arriving mid-walk are either fully included or
47//! not at all.
48//!
49//! Loads both tables fully into memory (matching v1.3's existing
50//! posture for `audit_log`-only walks). Streaming is a v1.x+
51//! concern when production-scale audit logs make full-load
52//! prohibitive; for v1.7's deployment scale (single-binary,
53//! single-SQLite-file, community-tier per §10) full read is fine.
54//! The full-load assumption is documented at the read site.
55//!
56//! # Pre-attestation rows
57//!
58//! Pre-v1.3 rows in `audit_log` (NULL `row_hash`) are **skipped,
59//! not flagged**. They predate the chain attestation; verify counts
60//! them so the operator sees exactly how much of their audit log
61//! is unattested. `pds_admin_audit` has no pre-attestation rows
62//! (the table only exists from v1.7 onward, and migration 0006
63//! makes both hash columns NOT NULL).
64//!
65//! # Tampering detection model
66//!
67//! This catches any modification that changes a row's hash-relevant
68//! content without recomputing the whole forward chain. A "smart"
69//! attacker who tampers a row AND re-derives the entire downstream
70//! chain is **not** caught by this command alone — that requires
71//! external attestation (signed Merkle root, transparency log)
72//! which is v1.x+ scope.
73
74use 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/// Which table a divergent row lives in. Lets operators correlate
87/// the divergence id back to the right SQL table.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum AuditTable {
91    /// §F10 audit_log — the v1.3-original chain.
92    AuditLog,
93    /// §F23 pds_admin_audit — the v1.7-added chain (#85, #87).
94    PdsAdminAudit,
95    /// §F23 inbound xrpc_gateway membership — moderator DIDs
96    /// authorized for proxied `tools.ozone.*` calls (#94).
97    XrpcKnownCallers,
98    /// §F23 inbound xrpc_gateway membership — PDS DIDs
99    /// authorized to forward `createReport` calls (#94).
100    XrpcTrustedPdses,
101}
102
103impl AuditTable {
104    /// SQL-table name. Used by the human formatter and by the
105    /// dispatcher in `main.rs` to populate
106    /// [`crate::cli::error::CliError::AuditDivergence::table`].
107    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/// Outcome of a verify run. Tests + the formatters branch on this
118/// rather than on stdout text.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
120#[serde(tag = "outcome", rename_all = "snake_case")]
121pub enum VerifyOutcome {
122    /// Both tables were empty; nothing to verify.
123    Empty,
124    /// Chain verified successfully.
125    Verified {
126        /// Total rows across both tables (attested + pre-attestation).
127        total_rows: i64,
128        /// Rows whose stored row_hash matched the recomputed hash
129        /// (across both tables).
130        attested_rows: i64,
131        /// Rows with NULL row_hash that were skipped (pre-v1.3
132        /// `audit_log` rows that haven't been backfilled by
133        /// `cairn audit-rebuild`). `pds_admin_audit` rows are
134        /// always attested, so this counts only `audit_log` rows.
135        pre_attestation_rows: i64,
136        /// `Some(N)` when rows 1..N-1 are pre-attestation and the
137        /// attested chain begins at row N — the trust horizon. Refers
138        /// to `audit_log.id` (the only table where pre-attestation
139        /// rows exist).
140        /// `None` when the first attested row's id is 1 (pure
141        /// post-v1.3 deployment OR a fully-rebuilt deployment;
142        /// either way, the chain is rooted at genesis with no
143        /// horizon to call out).
144        #[serde(skip_serializing_if = "Option::is_none")]
145        attestation_starts_at_row: Option<i64>,
146        /// Total `audit_log` rows seen during the walk
147        /// (attested + pre-attestation). Added in #88 so operators
148        /// can see the per-table breakdown when running on a
149        /// post-#87 deployment.
150        audit_log_rows: i64,
151        /// Total `pds_admin_audit` rows seen during the walk.
152        /// `0` for pre-#87 deployments and operators with
153        /// `[pds_admin].enabled = false`.
154        pds_admin_audit_rows: i64,
155        /// Total `xrpc_known_callers` rows seen during the walk.
156        /// Added in #94. `0` when `[xrpc_gateway]` is disabled
157        /// or no callers have been added.
158        xrpc_known_callers_rows: i64,
159        /// Total `xrpc_trusted_pdses` rows seen during the walk
160        /// (#94). Same defaulting as
161        /// [`Self::Verified::xrpc_known_callers_rows`].
162        xrpc_trusted_pdses_rows: i64,
163    },
164    /// First-divergence report. Walking halts once this is detected.
165    Divergence {
166        /// Which table the divergent row lives in. Added in #88.
167        /// Operators correlate `(table, row_id)` to the SQL row.
168        table: AuditTable,
169        /// `id` of the divergent row, scoped to the table named by
170        /// [`Self::Divergence::table`].
171        row_id: i64,
172        /// Hex-encoded SHA-256 the chain says this row's row_hash
173        /// should be.
174        expected_hash: String,
175        /// Hex-encoded SHA-256 actually stored in the row.
176        actual_hash: String,
177        /// Number of rows whose hashes verified before this one
178        /// (across both tables). The truncation point operators
179        /// reconcile from.
180        attested_rows_before_divergence: i64,
181    },
182}
183
184/// Walk the unified audit chain across `audit_log` and
185/// `pds_admin_audit`, verifying every attested row's hash.
186///
187/// Returns `Ok(VerifyOutcome)` regardless of whether the chain is
188/// intact; [`CliError`] is reserved for genuine errors (DB
189/// unreachable, row_hash blob with wrong length, etc.). The
190/// dispatcher in `main.rs` lifts `VerifyOutcome::Divergence` into
191/// [`CliError::AuditDivergence`] for exit-code mapping.
192///
193/// Read-only. No lease, no transaction (a single SELECT is its own
194/// implicit transaction in SQLite WAL mode). Concurrent appends
195/// during the walk are tolerated — they either appear in our
196/// snapshot or don't, but the chain integrity property holds for
197/// whatever subset we read.
198pub async fn verify(pool: &Pool<Sqlite>) -> Result<VerifyOutcome, CliError> {
199    // Load both tables fully. Streaming would be a refactor for
200    // production-scale audit logs; deferred per #88's prompt
201    // ("not in scope").
202    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 row: skip, don't update
253                // running_prev_hash. Only audit_log produces these.
254                pre_attestation_rows += 1;
255                continue;
256            }
257        };
258
259        if !seen_attested {
260            seen_attested = true;
261            // Trust horizon: only set the field when there's
262            // actually a horizon to call out (first attested
263            // audit_log row's id > 1). The horizon refers to
264            // audit_log only — pds_admin_audit has no
265            // pre-attestation rows, so a chain that starts with
266            // a pds_admin_audit row has no horizon.
267            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            // First-divergence: bail with a structured report.
292            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
317// ===========================================================================
318// Unified chain entry + ordering
319// ===========================================================================
320
321/// Owned `audit_log` row data as the verify walker needs it.
322struct 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
334/// Owned `pds_admin_audit` row data as the verify walker needs it.
335struct 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
349/// Owned `xrpc_known_callers` / `xrpc_trusted_pdses` row data.
350/// Same shape between the two tables; the variant tag is what
351/// distinguishes them in [`UnifiedEntry`]. Matches
352/// `recompute_membership_row_hash`'s input shape.
353struct XrpcMembershipRow {
354    /// Per-table id surrogate. Membership tables use `did` as
355    /// the primary key; the verify walker uses a synthesized id
356    /// (the position in the table's ORDER BY scan) so the
357    /// `Divergence::row_id` field carries something stable.
358    /// Since `did` is `String` and the divergence struct's
359    /// `row_id` is `i64`, we use a row index here.
360    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
368/// One entry in the unified audit chain. The variant determines
369/// which row-shape canonicalization applies during recomputation.
370enum 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    /// The chain-ordering timestamp. `audit_log.created_at` for
396    /// AuditLog entries; `pds_admin_audit.call_completed_at` for
397    /// PdsAdmin entries; `xrpc_*.added_at` for the membership
398    /// entries — matching the
399    /// [`crate::audit::append::read_latest_chain_hash`] tie-break
400    /// rules.
401    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    /// `Some(row_hash)` for attested rows; `None` for pre-v1.3
410    /// `audit_log` rows that haven't been backfilled by
411    /// `cairn audit-rebuild`. The other tables' hash columns
412    /// are NOT NULL per their migrations.
413    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    /// Tie-break priority within a single timestamp. Mirrors the
460    /// [`crate::audit::append::read_latest_chain_hash`] order:
461    /// `audit_log` (0) < `pds_admin_audit` (1) <
462    /// `xrpc_known_callers` (2) < `xrpc_trusted_pdses` (3).
463    /// Higher priority = "later in chain" on ties.
464    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
481// ===========================================================================
482// Reads
483// ===========================================================================
484
485async 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
592// ===========================================================================
593// Output formatters
594// ===========================================================================
595
596/// Human-readable summary. Multi-line for `Verified` (one line per
597/// fact the operator wants to see) and `Divergence` (id + hashes).
598/// Trailing newline is left for the dispatcher's `println!`.
599pub 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
659/// JSON one-line summary. The serde discriminator (`outcome`) lets
660/// downstream tools branch on a stable enum tag rather than parsing
661/// the human string.
662pub 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    /// Minimal `subject_actions` row so a `pds_admin_audit` row's
695    /// FK resolves. Returns the inserted id.
696    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    /// Append a `pds_admin_audit` row via the production helper.
716    /// `completed_at` is the chain-ordering timestamp — must be
717    /// chosen so the row falls between its intended chain
718    /// predecessor and successor's timestamps, otherwise the
719    /// production `read_latest_chain_hash` will fork the chain
720    /// (each side picking a different predecessor) and the
721    /// verifier will catch the fork as a divergence at the
722    /// out-of-order row. That fork-on-disordered-timestamps
723    /// behavior is the production invariant; tests just have to
724    /// stay inside it.
725    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    /// Manually insert a pre-v1.3-style row (NULL hashes) for fixtures
746    /// that simulate a v1.2-upgrade DB before `cairn audit-rebuild`
747    /// has run.
748    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    /// Drop both tables' no-update triggers so a test can simulate
767    /// tampering. Production paths don't do this — only operator
768    /// commands like `cairn audit-rebuild` (which restores the
769    /// audit_log trigger before commit). Tests need this to forge
770    /// mismatches.
771    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    // ===== Empty / single-table paths =====
783
784    #[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        // Pre-#87-shape deployment: only audit_log rows exist.
794        // The unified walker should behave identically to the old
795        // single-table walker.
796        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        // Edge case: no audit_log rows, only pds_admin_audit. Can
835        // happen if an operator's deployment never recorded any
836        // audit_log activity. The walker should still verify the
837        // pds_admin_audit chain rooted at GENESIS.
838        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    // ===== Unified chain (the #88 acceptance case) =====
859
860    #[tokio::test]
861    async fn interleaved_chain_verifies_across_table_boundary() {
862        // The #87-introduced shape: audit_log → pds_admin_audit →
863        // audit_log. The middle pds_admin_audit row's prev_hash is
864        // the prior audit_log row's row_hash; the final audit_log
865        // row's prev_hash is the pds_admin_audit row's row_hash.
866        let pool = fresh_pool().await;
867        let action_id = fixture_subject_action(&pool).await;
868
869        // Insert in chain order: audit_log row 1 (oldest), then a
870        // pds_admin_audit row, then a second audit_log row. The
871        // production append paths read the unified chain head, so
872        // the inserted prev_hashes link correctly across tables.
873        append_via_pool(&pool, &sample_audit_row("label_applied", "did:plc:m1", 100))
874            .await
875            .unwrap();
876        // pds_admin_audit row's completed_at must be > audit_log
877        // row 1's created_at (100) and < audit_log row 2's
878        // created_at (300) so production's chain-tip read picks
879        // the pds_admin row as audit_log row 2's predecessor.
880        append_pds_admin_audit(&pool, action_id, "ozone:s:42", 150, 200).await;
881        // audit_log row 2 chains to the pds_admin_audit row.
882        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        // Tamper audit_log row 2's actor_did.
917        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                // 2 rows verified before divergence: audit_log#1 and
934                // pds_admin_audit#1, in chain order.
935                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        // Tamper the pds_admin_audit row's error_code (was None
954        // originally; now writing something).
955        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                // 1 row verified before divergence: audit_log#1.
975                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        // Tampering the pds_admin_audit row's stored prev_hash so it
984        // no longer chains correctly to the prior audit_log row's
985        // row_hash. The recomputed hash (using the actual prior
986        // chain head) won't equal the stored row_hash because the
987        // stored row_hash was computed from the OLD prev_hash.
988        // Verify must catch this at the pds_admin_audit row.
989        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        // Replace the pds_admin row's stored prev_hash with bogus
998        // bytes. The row_hash on disk is unchanged; the recomputed
999        // hash from the unified chain will differ from the stored
1000        // row_hash because the stored hash was computed from the
1001        // ORIGINAL prev_hash.
1002        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        // Note: the verify walker recomputes the row_hash from the
1013        // RUNNING prev_hash (built up from prior verified rows),
1014        // not from the row's stored prev_hash. So tampering the
1015        // stored prev_hash alone doesn't cause divergence here —
1016        // tampering the row_hash would. This test instead tampers
1017        // the row_hash so the divergence-detection mechanism is
1018        // exercised: that's what the user-facing tamper looks like
1019        // when an attacker tries to relink the chain.
1020        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                // 1 row attested before divergence: audit_log#1.
1043                assert_eq!(attested_rows_before_divergence, 1);
1044            }
1045            other => panic!("expected Divergence, got {other:?}"),
1046        }
1047    }
1048
1049    // ===== Pre-attestation handling, preserved from v1.3 =====
1050
1051    #[tokio::test]
1052    async fn mixed_pre_then_attested_audit_log_with_pds_admin_audit_horizon_only_for_audit_log() {
1053        // Three pre-v1.3 NULL audit_log rows, then a v1.3-attested
1054        // audit_log row, then a pds_admin_audit row. The trust
1055        // horizon refers to audit_log row 4; pds_admin_audit has
1056        // no horizon concept.
1057        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        // pds_admin_audit chains after audit_log row 4: completed_at > 4.
1066        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        // Verify is read-only; it must NOT touch server_instance_lease.
1096        // Plant a fresh-heartbeat lease (simulating cairn serve running)
1097        // and confirm verify still works without LeaseConflict.
1098        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    // ===== Formatters =====
1121
1122    #[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}