1use anyhow::{bail, Result};
24use rusqlite::{Connection, TransactionBehavior};
25
26use super::Store;
27use crate::audit::merkle::{self, Hash, Tree};
28
29pub(super) const SCHEMA: &str = "
48 CREATE TABLE IF NOT EXISTS audit_log (
49 seq INTEGER PRIMARY KEY,
50 leaf BLOB NOT NULL,
51 leaf_hash BLOB NOT NULL
52 );
53 CREATE TRIGGER IF NOT EXISTS audit_log_no_update
54 BEFORE UPDATE ON audit_log
55 BEGIN
56 SELECT RAISE(ABORT, 'audit_log is append-only');
57 END;
58 CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
59 BEFORE DELETE ON audit_log
60 BEGIN
61 SELECT RAISE(ABORT, 'audit_log is append-only');
62 END;
63 CREATE TRIGGER IF NOT EXISTS audit_log_next_seq
64 BEFORE INSERT ON audit_log
65 WHEN NEW.seq IS NOT (SELECT COALESCE(MAX(seq), -1) + 1 FROM audit_log)
66 BEGIN
67 SELECT RAISE(ABORT, 'audit_log is append-only: a leaf takes the next seq');
68 END;
69";
70
71pub(super) struct Loaded {
73 pub(super) tree: Tree,
75 pub(super) last_at: String,
77}
78
79pub(super) fn load(conn: &Connection) -> Result<Loaded> {
94 let mut tree = Tree::new();
95 read_from(conn, 0, |hash| tree.append(hash))?;
96 let last_at = last_at(conn, tree.size())?;
97 Ok(Loaded { tree, last_at })
98}
99
100fn read_from(conn: &Connection, from: u64, each: impl FnMut(Hash)) -> Result<u64> {
104 read_range(conn, from, i64::MAX as u64, each)
105}
106
107fn read_range(conn: &Connection, from: u64, most: u64, mut each: impl FnMut(Hash)) -> Result<u64> {
109 let mut stmt = conn.prepare(
110 "SELECT seq, leaf, leaf_hash FROM audit_log WHERE seq >= ?1 ORDER BY seq LIMIT ?2",
111 )?;
112 let mut rows = stmt.query((from as i64, most.min(i64::MAX as u64) as i64))?;
113 let mut want = from;
114 while let Some(row) = rows.next()? {
115 let seq: i64 = row.get(0)?;
116 if seq != want as i64 {
117 bail!(
118 "the audit log is damaged: leaf {want} is missing (the next one stored is {seq}); \
119 restore the database from a backup"
120 );
121 }
122 let leaf = row.get_ref(1)?.as_blob()?;
123 let hash = merkle::hash_leaf(leaf);
124 if row.get_ref(2)?.as_blob()? != hash.as_slice() {
125 bail!(
126 "the audit log is damaged: leaf {seq} no longer hashes to its stored leaf_hash; \
127 restore the database from a backup"
128 );
129 }
130 each(hash);
131 want += 1;
132 }
133 Ok(want - from)
134}
135
136fn last_at(conn: &Connection, size: u64) -> Result<String> {
138 if size == 0 {
139 return Ok(String::new());
140 }
141 let leaf: Vec<u8> = conn.query_row(
142 "SELECT leaf FROM audit_log WHERE seq = ?1",
143 (size as i64 - 1,),
144 |r| r.get(0),
145 )?;
146 Ok(serde_json::from_slice::<serde_json::Value>(&leaf)
149 .ok()
150 .and_then(|v| v.get("at")?.as_str().map(str::to_string))
151 .unwrap_or_default())
152}
153
154fn catch_up(conn: &Connection, held: u64) -> Result<(Vec<Hash>, Option<String>)> {
173 let stored: i64 =
174 conn.query_row("SELECT COALESCE(MAX(seq) + 1, 0) FROM audit_log", [], |r| {
175 r.get(0)
176 })?;
177 if stored < held as i64 {
178 bail!(
179 "the audit log holds {stored} leaves, fewer than the {held} this server appended: \
180 the database was replaced under a running server; restart it"
181 );
182 }
183 let mut hashes = Vec::new();
184 if stored == held as i64 {
185 return Ok((hashes, None));
186 }
187 let read = read_from(conn, held, |hash| hashes.push(hash))?;
188 Ok((hashes, Some(last_at(conn, held + read)?)))
189}
190
191pub enum Outcome<T> {
193 Commit(T),
195 Refuse(T),
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct AuditEntry {
207 pub seq: u64,
209 pub leaf: Vec<u8>,
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
215pub enum ConsistencyError {
216 #[error("first must be at least 1 and at most second")]
218 BadRange,
219 #[error("second is past the end of the log")]
221 SecondBeyondTreeSize,
222}
223
224fn next_at(one: &str, other: &str) -> String {
230 let newest = one.max(other);
231 let now = crate::now();
232 if now.as_str() < newest {
233 newest.to_string()
234 } else {
235 now
236 }
237}
238
239impl Store {
240 pub fn audited<T>(
256 &self,
257 write: impl FnOnce(&rusqlite::Transaction, &str) -> Result<Outcome<T>>,
258 build_leaf: impl FnOnce(u64, &str, &T) -> Vec<u8>,
259 ) -> Result<T> {
260 self.audited_each(write, |seq, at, value| vec![build_leaf(seq, at, value)])
261 }
262
263 pub fn audited_each<T>(
269 &self,
270 write: impl FnOnce(&rusqlite::Transaction, &str) -> Result<Outcome<T>>,
271 build_leaves: impl FnOnce(u64, &str, &T) -> Vec<Vec<u8>>,
272 ) -> Result<T> {
273 self.audited_each_as(
274 anyhow::Error::from,
275 anyhow::Error::from,
276 write,
277 build_leaves,
278 )
279 }
280
281 pub(crate) fn audited_each_as<T>(
287 &self,
288 begin_failed: impl FnOnce(rusqlite::Error) -> anyhow::Error,
289 commit_failed: impl FnOnce(rusqlite::Error) -> anyhow::Error,
290 write: impl FnOnce(&rusqlite::Transaction, &str) -> Result<Outcome<T>>,
291 build_leaves: impl FnOnce(u64, &str, &T) -> Vec<Vec<u8>>,
292 ) -> Result<T> {
293 let mut state = self.lock();
294 if let Some(opened) = state.file {
297 if super::file_id(&state.conn) != Some(opened) {
298 let why = format!(
299 "the database file {} was moved or replaced under this running server, \
300 which would otherwise go on writing into the file it had opened. Nothing \
301 was written. Restart the server, so it opens the file that is there now",
302 state.conn.path().unwrap_or("")
303 );
304 if !std::mem::replace(&mut state.moved_said, true) && state.log_moved {
307 eprintln!("{why}");
308 }
309 bail!(why);
310 }
311 }
312 let (held, held_at) = (state.audit.size(), state.audit_at.clone());
313 let tx = state
319 .conn
320 .transaction_with_behavior(TransactionBehavior::Immediate)
321 .map_err(begin_failed)?;
322 let (caught, caught_at) = catch_up(&tx, held)?;
323 let seq = held + caught.len() as u64;
328 let at = next_at(caught_at.as_deref().unwrap_or(""), &held_at);
329 let value = match write(&tx, &at)? {
330 Outcome::Commit(value) => value,
331 Outcome::Refuse(value) => return Ok(value), };
333 let leaves = build_leaves(seq, &at, &value);
334 let mut hashes = Vec::with_capacity(leaves.len());
335 for (i, leaf) in leaves.iter().enumerate() {
336 let leaf_hash = merkle::hash_leaf(leaf);
337 tx.execute(
338 "INSERT INTO audit_log (seq, leaf, leaf_hash) VALUES (?1, ?2, ?3)",
339 (seq as i64 + i as i64, leaf, leaf_hash.as_slice()),
340 )?;
341 hashes.push(leaf_hash);
342 }
343 tx.commit().map_err(commit_failed)?;
344 for leaf_hash in caught.into_iter().chain(hashes) {
345 state.audit.append(leaf_hash);
346 }
347 if !leaves.is_empty() {
348 state.audit_at = at;
349 } else if let Some(caught_at) = caught_at.filter(|c| *c > state.audit_at) {
350 state.audit_at = caught_at;
351 }
352 Ok(value)
353 }
354
355 pub(crate) fn audit_read_ahead(&self) -> Result<()> {
369 self.audit_read_ahead_by(4096)
370 }
371
372 fn audit_read_ahead_by(&self, chunk: u64) -> Result<()> {
374 let mut state = self.lock();
375 loop {
376 let held = state.audit.size();
377 let mut hashes = Vec::new();
378 let read = read_range(&state.conn, held, chunk, |hash| hashes.push(hash))?;
379 if read == 0 {
380 return Ok(());
381 }
382 let at = last_at(&state.conn, held + read)?;
383 for hash in hashes {
384 state.audit.append(hash);
385 }
386 if at > state.audit_at {
387 state.audit_at = at;
388 }
389 }
390 }
391
392 pub fn audit_append(&self, build_leaf: impl FnOnce(u64, &str) -> Vec<u8>) -> Result<u64> {
401 let assigned = std::cell::Cell::new(0u64);
402 self.audited(
403 |_tx, _at| Ok(Outcome::Commit(())),
404 |seq, at, ()| {
405 assigned.set(seq);
406 build_leaf(seq, at)
407 },
408 )?;
409 Ok(assigned.get())
410 }
411
412 pub fn audit_checkpoint(&self) -> (u64, Hash) {
415 let state = self.lock();
416 (state.audit.size(), state.audit.root())
417 }
418
419 pub fn audit_entries(&self, start: u64, end: u64, max_bytes: usize) -> Result<Vec<AuditEntry>> {
425 let state = self.lock();
426 let mut stmt = state
427 .conn
428 .prepare("SELECT seq, leaf FROM audit_log WHERE seq >= ?1 AND seq < ?2 ORDER BY seq")?;
429 let mut rows = stmt.query((start as i64, end as i64))?;
430 let (mut out, mut bytes) = (Vec::new(), 0usize);
431 while let Some(row) = rows.next()? {
432 let leaf: Vec<u8> = row.get(1)?;
433 bytes += leaf.len();
434 if bytes > max_bytes && !out.is_empty() {
435 break;
436 }
437 out.push(AuditEntry {
438 seq: row.get::<_, i64>(0)? as u64,
439 leaf,
440 });
441 }
442 Ok(out)
443 }
444
445 pub fn audit_consistency(
449 &self,
450 first: u64,
451 second: u64,
452 ) -> Result<Result<Vec<Hash>, ConsistencyError>> {
453 let state = self.lock();
454 if first == 0 || first > second {
455 return Ok(Err(ConsistencyError::BadRange));
456 }
457 if second > state.audit.size() {
458 return Ok(Err(ConsistencyError::SecondBeyondTreeSize));
459 }
460 Ok(Ok(state.audit.consistency(first, second)))
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467 use crate::audit::leaf;
468
469 fn store() -> Store {
470 Store::open_in_memory().unwrap()
471 }
472
473 fn push_leaf(seq: u64) -> Vec<u8> {
474 leaf::encode(
475 seq,
476 "2026-01-01T00:00:00.000Z",
477 leaf::action::PUSH,
478 &leaf::Actor::Operator,
479 leaf::subject_file(&leaf::FileChange {
480 project_key: "acme/app",
481 file_path: "a.md",
482 deleted: false,
483 stored_sha256: "abc",
484 base_sha256: None,
485 merged: false,
486 merge_job: None,
487 }),
488 None,
489 )
490 }
491
492 fn append(st: &Store) {
493 st.audited(
494 |_tx, _| Ok(Outcome::Commit(())),
495 |seq, _, ()| push_leaf(seq),
496 )
497 .unwrap();
498 }
499
500 const INSERT_FILE: &str = "INSERT INTO memory_files \
501 (project_key, file_path, content, source_env, updated_at) VALUES ('a','b','c','d','e')";
502
503 #[test]
504 fn a_committed_write_appends_exactly_one_leaf() {
505 let st = store();
506 st.audited(
507 |tx, _| {
508 tx.execute(INSERT_FILE, [])?;
509 Ok(Outcome::Commit(()))
510 },
511 |seq, _, ()| push_leaf(seq),
512 )
513 .unwrap();
514 let (size, _) = st.audit_checkpoint();
515 assert_eq!(size, 1);
516 assert_eq!(st.audit_entries(0, 1, usize::MAX).unwrap().len(), 1);
517 }
518
519 #[test]
523 fn a_failing_write_appends_no_leaf_and_keeps_no_change() {
524 let st = store();
525 let result = st.audited(
526 |tx, _| {
527 tx.execute(INSERT_FILE, [])?;
528 Err(anyhow::Error::from(rusqlite::Error::ExecuteReturnedResults))
529 },
530 |seq, _, ()| push_leaf(seq),
531 );
532 assert!(result.is_err());
533 assert_eq!(
534 st.audit_checkpoint().0,
535 0,
536 "no leaf from a rolled-back write"
537 );
538 assert!(st.get("a", "b").unwrap().is_none(), "no row either");
539 }
540
541 #[test]
546 fn a_leaf_that_cannot_be_written_undoes_the_change() {
547 let st = store();
548 st.with_raw(|c| {
549 c.execute_batch(
550 "CREATE TEMP TRIGGER no_leaves BEFORE INSERT ON audit_log
551 BEGIN SELECT RAISE(ABORT, 'no leaves today'); END;",
552 )
553 })
554 .unwrap();
555 let result = st.audited(
556 |tx, _| {
557 tx.execute(INSERT_FILE, [])?;
558 Ok(Outcome::Commit(()))
559 },
560 |seq, _, ()| push_leaf(seq),
561 );
562 assert!(result.is_err(), "the leaf's insert failed");
563 assert!(
564 st.get("a", "b").unwrap().is_none(),
565 "so the row is not there"
566 );
567 assert_eq!(st.audit_checkpoint().0, 0);
568 }
569
570 #[test]
573 fn a_refused_write_appends_no_leaf() {
574 let st = store();
575 let refusal: &str = st
576 .audited(
577 |_tx, _| Ok(Outcome::Refuse("no such code")),
578 |seq, _, _| push_leaf(seq),
579 )
580 .unwrap();
581 assert_eq!(refusal, "no such code");
582 assert_eq!(st.audit_checkpoint().0, 0);
583 }
584
585 #[test]
588 fn one_write_can_append_several_leaves_in_order() {
589 let st = store();
590 append(&st);
591 st.audited_each(
592 |_tx, _| Ok(Outcome::Commit(3u64)),
593 |seq, _, n| (seq..seq + n).map(push_leaf).collect(),
594 )
595 .unwrap();
596 let seqs: Vec<u64> = st
597 .audit_entries(0, 4, usize::MAX)
598 .unwrap()
599 .iter()
600 .map(|e| e.seq)
601 .collect();
602 assert_eq!(seqs, vec![0, 1, 2, 3]);
603 assert_eq!(
604 st.audit_entries(1, 2, usize::MAX).unwrap()[0].leaf,
605 push_leaf(1)
606 );
607 }
608
609 #[test]
613 fn at_never_decreases_along_seq() {
614 let st = store();
615 let mut ats = Vec::new();
616 for _ in 0..3 {
617 st.audit_append(|seq, at| {
618 ats.push(at.to_string());
619 push_leaf(seq)
620 })
621 .unwrap();
622 }
623 assert!(ats.windows(2).all(|w| w[0] <= w[1]), "{ats:?}");
624 assert_eq!(ats[0].len(), 24, "the API's timestamp format");
625
626 st.lock().audit_at = "2999-01-01T00:00:00.000Z".into();
628 let mut got = String::new();
629 st.audit_append(|seq, at| {
630 got = at.to_string();
631 push_leaf(seq)
632 })
633 .unwrap();
634 assert_eq!(got, "2999-01-01T00:00:00.000Z");
635 }
636
637 #[test]
638 fn checkpoint_matches_the_merkle_root_of_every_leaf() {
639 let st = store();
640 for _ in 0..5 {
641 append(&st);
642 }
643 let (size, root) = st.audit_checkpoint();
644 assert_eq!(size, 5);
645 let leaves: Vec<Hash> = (0..5)
646 .map(push_leaf)
647 .map(|l| merkle::hash_leaf(&l))
648 .collect();
649 assert_eq!(root, merkle::root(&leaves));
650 }
651
652 #[test]
653 fn entries_pages_by_seq_and_by_bytes() {
654 let st = store();
655 for _ in 0..3 {
656 append(&st);
657 }
658 let got = st.audit_entries(1, 3, usize::MAX).unwrap();
659 assert_eq!(got.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2]);
660 assert_eq!(got[0].leaf, push_leaf(1));
661
662 let one = push_leaf(0).len();
663 let seqs = |max| -> Vec<u64> {
664 st.audit_entries(0, 3, max)
665 .unwrap()
666 .iter()
667 .map(|e| e.seq)
668 .collect()
669 };
670 assert_eq!(seqs(2 * one), vec![0, 1], "two fit exactly");
671 assert_eq!(seqs(2 * one - 1), vec![0], "the second would overflow");
672 assert_eq!(seqs(1), vec![0], "never fewer than one");
673 }
674
675 #[test]
676 fn consistency_matches_merkle_and_rejects_bad_ranges() {
677 let st = store();
678 for _ in 0..8 {
679 append(&st);
680 }
681 let proof = st.audit_consistency(3, 8).unwrap().unwrap();
682 let leaves: Vec<Hash> = (0..8)
683 .map(push_leaf)
684 .map(|l| merkle::hash_leaf(&l))
685 .collect();
686 assert_eq!(proof, merkle::consistency(3, 8, &leaves));
687
688 assert_eq!(
689 st.audit_consistency(0, 8).unwrap(),
690 Err(ConsistencyError::BadRange)
691 );
692 assert_eq!(
693 st.audit_consistency(5, 3).unwrap(),
694 Err(ConsistencyError::BadRange)
695 );
696 assert_eq!(
697 st.audit_consistency(1, 100).unwrap(),
698 Err(ConsistencyError::SecondBeyondTreeSize)
699 );
700 }
701
702 #[test]
706 fn a_proof_does_not_read_the_table() {
707 let st = store();
708 for _ in 0..40 {
709 append(&st);
710 }
711 let want = st.audit_consistency(7, 40).unwrap().unwrap();
712 st.with_raw(|c| c.execute_batch("ALTER TABLE audit_log RENAME TO audit_log_away"))
713 .unwrap();
714 assert_eq!(st.audit_consistency(7, 40).unwrap().unwrap(), want);
715 }
716
717 #[test]
721 fn nothing_but_the_next_leaf_can_be_written() {
722 let st = store();
723 append(&st);
724 append(&st);
725
726 let refused = |sql: &str| {
727 assert!(st.with_raw(|c| c.execute(sql, [])).is_err(), "{sql}");
728 };
729 refused("UPDATE audit_log SET seq = 99 WHERE seq = 0");
730 refused("DELETE FROM audit_log WHERE seq = 0");
731 refused(
732 "INSERT OR REPLACE INTO audit_log (seq, leaf, leaf_hash) \
733 VALUES (0, CAST('forged' AS BLOB), zeroblob(32))",
734 );
735 refused(
736 "INSERT INTO audit_log (seq, leaf, leaf_hash) VALUES (0, x'01', zeroblob(32)) \
737 ON CONFLICT(seq) DO UPDATE SET leaf = excluded.leaf",
738 );
739 refused("INSERT INTO audit_log (seq, leaf, leaf_hash) VALUES (100, x'02', zeroblob(32))");
740 refused("INSERT INTO audit_log (leaf, leaf_hash) VALUES (x'02', zeroblob(32))");
741 assert_eq!(
742 st.audit_entries(0, 2, usize::MAX).unwrap()[0].leaf,
743 push_leaf(0),
744 "leaf 0 is what was written"
745 );
746 assert_eq!(st.audit_checkpoint().0, 2);
747
748 st.with_raw(|c| {
750 c.execute(
751 "INSERT INTO audit_log (seq, leaf, leaf_hash) VALUES (2, x'03', zeroblob(32))",
752 [],
753 )
754 })
755 .unwrap();
756 }
757
758 #[test]
761 fn reopening_rebuilds_the_same_tree() {
762 let dir = tempfile::tempdir().unwrap();
763 let path = dir.path().join("r.db");
764 let before = {
765 let st = Store::open(&path).unwrap();
766 for _ in 0..5 {
767 append(&st);
768 }
769 st.audit_checkpoint()
770 };
771 let st = Store::open(&path).unwrap();
772 assert_eq!(st.audit_checkpoint(), before);
773 assert_eq!(st.audit_append(|seq, _| push_leaf(seq)).unwrap(), 5);
774 }
775
776 #[test]
782 fn a_leaf_another_process_appended_is_read_in_before_the_next() {
783 let dir = tempfile::tempdir().unwrap();
784 let path = dir.path().join("r.db");
785 let server = Store::open(&path).unwrap();
786 append(&server);
787 append(&server);
788 let host = Store::open(&path).unwrap();
789 assert_eq!(host.audit_append(|seq, _| push_leaf(seq)).unwrap(), 2);
790 assert_eq!(server.audit_checkpoint().0, 2, "not read until it appends");
791 assert_eq!(server.audit_append(|seq, _| push_leaf(seq)).unwrap(), 3);
792 assert_eq!(
793 server.audit_checkpoint(),
794 Store::open(&path).unwrap().audit_checkpoint()
795 );
796
797 let blank = Store::with_connection(Connection::open(&path).unwrap()).unwrap();
799 blank.lock().audit = Tree::new();
800 assert_eq!(blank.audit_append(|seq, _| push_leaf(seq)).unwrap(), 4);
801
802 Connection::open(&path)
808 .unwrap()
809 .execute_batch("DROP TRIGGER audit_log_no_delete; DELETE FROM audit_log WHERE seq >= 1")
810 .unwrap();
811 let err = server.audit_append(|seq, _| push_leaf(seq)).unwrap_err();
812 assert!(format!("{err:#}").contains("restart it"), "{err:#}");
813 }
814
815 #[test]
820 fn reading_ahead_builds_the_tree_catching_up_would() {
821 let dir = tempfile::tempdir().unwrap();
822 let path = dir.path().join("r.db");
823 let server = Store::open(&path).unwrap();
824 for _ in 0..5 {
825 append(&server);
826 }
827 let host = Store::with_connection(Connection::open(&path).unwrap()).unwrap();
828 host.lock().audit = Tree::new();
829 host.lock().audit_at = String::new();
830 host.audit_read_ahead_by(2).unwrap();
831 assert_eq!(host.audit_checkpoint(), server.audit_checkpoint());
832 assert_eq!(host.lock().audit_at, "2026-01-01T00:00:00.000Z");
833 append(&server);
834 host.audit_read_ahead_by(2).unwrap();
835 assert_eq!(host.audit_checkpoint(), server.audit_checkpoint());
836 assert_eq!(host.audit_append(|seq, _| push_leaf(seq)).unwrap(), 6);
837 assert_eq!(server.audit_append(|seq, _| push_leaf(seq)).unwrap(), 7);
838 assert_eq!(host.audit_append(|seq, _| push_leaf(seq)).unwrap(), 8);
839
840 let conn = Connection::open(&path).unwrap();
841 conn.execute_batch(
842 "DROP TRIGGER audit_log_no_update;
843 UPDATE audit_log SET leaf = CAST('forged' AS BLOB) WHERE seq = 3",
844 )
845 .unwrap();
846 let blank =
847 Store::with_connection(Connection::open(dir.path().join("x.db")).unwrap()).unwrap();
848 blank.lock().conn = Connection::open(&path).unwrap();
849 let err = blank.audit_read_ahead_by(2).unwrap_err();
850 assert!(
851 format!("{err:#}").contains("leaf 3 no longer hashes"),
852 "{err:#}"
853 );
854 }
855
856 #[test]
860 fn opening_refuses_a_damaged_log() {
861 let damaged = |how: &str| -> String {
862 let dir = tempfile::tempdir().unwrap();
863 let path = dir.path().join("r.db");
864 {
865 let st = Store::open(&path).unwrap();
866 for _ in 0..4 {
867 append(&st);
868 }
869 }
870 let conn = Connection::open(&path).unwrap();
871 conn.execute_batch(&format!(
872 "DROP TRIGGER audit_log_no_update; DROP TRIGGER audit_log_no_delete; {how}"
873 ))
874 .unwrap();
875 drop(conn);
876 match Store::open(&path) {
877 Ok(_) => panic!("opened a log damaged by {how}"),
878 Err(e) => format!("{e:#}"),
879 }
880 };
881 let e = damaged("UPDATE audit_log SET leaf = CAST('forged' AS BLOB) WHERE seq = 1");
882 assert!(e.contains("leaf 1 no longer hashes"), "{e}");
883 let e = damaged("DELETE FROM audit_log WHERE seq = 2");
884 assert!(e.contains("leaf 2 is missing"), "{e}");
885 let e = damaged("UPDATE audit_log SET leaf_hash = zeroblob(32) WHERE seq = 3");
886 assert!(e.contains("leaf 3 no longer hashes"), "{e}");
887 }
888}