use crate::{
error::{Error, Result},
storage::sql,
};
use rusqlite::{Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
const DOMAIN: &[u8] = b"rustyschwab.audit.v1";
pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
const BACKFILL_PAGE: i64 = 1000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct AuditChainReport {
pub rows: u64,
pub first_sequence: Option<i64>,
pub last_sequence: Option<i64>,
pub head_hash: String,
pub anchor_sequence: Option<i64>,
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn row_hash(
prev_hash: &str,
sequence: i64,
at: i64,
kind: &str,
subject: &str,
payload: &str,
) -> String {
let mut context = ring::digest::Context::new(&ring::digest::SHA256);
for field in [
DOMAIN,
prev_hash.as_bytes(),
&sequence.to_be_bytes(),
&at.to_be_bytes(),
kind.as_bytes(),
subject.as_bytes(),
payload.as_bytes(),
] {
context.update(&(field.len() as u64).to_be_bytes());
context.update(field);
}
hex(context.finish().as_ref())
}
fn head_hash(conn: &Connection) -> Result<String> {
let newest = conn
.query_row(
"SELECT hash FROM audit ORDER BY sequence DESC LIMIT 1",
[],
|r| r.get(0),
)
.optional()
.map_err(sql)?;
match newest {
Some(hash) => Ok(hash),
None => Ok(anchor(conn)?.map_or_else(|| GENESIS_HASH.to_owned(), |(_, hash)| hash)),
}
}
fn anchor(conn: &Connection) -> Result<Option<(i64, String)>> {
conn.query_row("SELECT sequence,hash FROM audit_anchor", [], |r| {
Ok((r.get(0)?, r.get(1)?))
})
.optional()
.map_err(sql)
}
pub(crate) fn append(
conn: &Connection,
at: i64,
kind: &str,
subject: &str,
payload: &str,
) -> Result<()> {
let prev_hash = head_hash(conn)?;
let sequence: i64 = conn
.query_row(
"SELECT MAX(COALESCE((SELECT seq FROM sqlite_sequence WHERE name='audit'),0), \
COALESCE((SELECT MAX(sequence) FROM audit),0), \
COALESCE((SELECT sequence FROM audit_anchor),0)) + 1",
[],
|r| r.get(0),
)
.map_err(sql)?;
let hash = row_hash(&prev_hash, sequence, at, kind, subject, payload);
conn.execute(
"INSERT INTO audit(sequence,at,kind,subject,payload,prev_hash,hash) \
VALUES(?1,?2,?3,?4,?5,?6,?7)",
rusqlite::params![sequence, at, kind, subject, payload, prev_hash, hash],
)
.map_err(sql)?;
Ok(())
}
pub(crate) fn backfill(conn: &Connection) -> Result<()> {
let mut prev_hash = GENESIS_HASH.to_owned();
let mut after = 0_i64;
loop {
let page = {
let mut statement = conn
.prepare(
"SELECT sequence,at,kind,subject,payload FROM audit \
WHERE sequence>?1 ORDER BY sequence LIMIT ?2",
)
.map_err(sql)?;
statement
.query_map([after, BACKFILL_PAGE], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
))
})
.map_err(sql)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sql)?
};
let Some(&(last, ..)) = page.last() else {
return Ok(());
};
for (sequence, at, kind, subject, payload) in page {
let hash = row_hash(&prev_hash, sequence, at, &kind, &subject, &payload);
conn.execute(
"UPDATE audit SET prev_hash=?1, hash=?2 WHERE sequence=?3",
rusqlite::params![prev_hash, hash, sequence],
)
.map_err(sql)?;
prev_hash = hash;
}
after = last;
}
}
fn broken(sequence: i64, reason: &str) -> Error {
Error::Storage(format!(
"Audit chain broken at sequence {sequence}: {reason}"
))
}
pub(crate) fn verify(conn: &mut Connection) -> Result<AuditChainReport> {
let tx = conn.transaction().map_err(sql)?;
let anchor = anchor(&tx)?;
let mut expected_prev = anchor
.as_ref()
.map_or_else(|| GENESIS_HASH.to_owned(), |(_, h)| h.clone());
let mut previous_sequence = anchor.as_ref().map(|(s, _)| *s);
let mut report = AuditChainReport {
rows: 0,
first_sequence: None,
last_sequence: None,
head_hash: expected_prev.clone(),
anchor_sequence: previous_sequence,
};
let mut statement = tx
.prepare(
"SELECT sequence,at,kind,subject,payload,prev_hash,hash FROM audit \
WHERE sequence>?1 ORDER BY sequence",
)
.map_err(sql)?;
let mut rows = statement
.query([previous_sequence.unwrap_or(0)])
.map_err(sql)?;
while let Some(row) = rows.next().map_err(sql)? {
let sequence: i64 = row.get(0).map_err(sql)?;
let prev_hash: String = row.get(5).map_err(sql)?;
let hash: String = row.get(6).map_err(sql)?;
if prev_hash != expected_prev {
return Err(broken(sequence, "link to the previous row does not match"));
}
if previous_sequence.is_some_and(|p| sequence != p + 1) {
return Err(broken(sequence, "preceding rows are missing"));
}
let computed = row_hash(
&prev_hash,
sequence,
row.get(1).map_err(sql)?,
&row.get::<_, String>(2).map_err(sql)?,
&row.get::<_, String>(3).map_err(sql)?,
&row.get::<_, String>(4).map_err(sql)?,
);
if computed != hash {
return Err(broken(sequence, "row content does not match its hash"));
}
report.rows += 1;
report.first_sequence.get_or_insert(sequence);
report.last_sequence = Some(sequence);
previous_sequence = Some(sequence);
expected_prev = hash;
}
report.head_hash = expected_prev;
Ok(report)
}