1use {
9 super::Spool,
10 crate::{
11 chain::{chain_hash, from_hex32},
12 event::AuditEventClass,
13 ids::Seq,
14 sink::{AuditError, AuditRecord, HEADER_PREV_HASH, HEADER_SEQ},
15 },
16 krabka_verified::{ChainStep, chain_step},
17 std::str,
18};
19
20struct ChainPosition {
22 next_seq: Seq,
24 head: [u8; 32],
26}
27
28struct ChainHeaders {
30 seq: u64,
31 previous: [u8; 32],
32}
33
34impl Spool {
35 #[tracing::instrument(level = "debug", skip_all, fields(count = self.count.0), err)]
40 pub fn resume_point(&self) -> Result<Option<(u64, [u8; 32])>, AuditError> {
43 let records = self.read_all()?;
44 resume_from_records(&records)
45 .map(|resume| resume.map(|ChainPosition { next_seq, head }| (next_seq.0, head)))
46 }
47}
48
49fn poisoned(reason: &str) -> AuditError {
50 AuditError::Poisoned(format!("audit spool resume: {reason}"))
51}
52
53fn resume_from_records(records: &[AuditRecord]) -> Result<Option<ChainPosition>, AuditError> {
54 let mut resume: Option<ChainPosition> = None;
55 for record in records {
56 if record.class == AuditEventClass::Checkpoint {
57 continue;
58 }
59 let ChainHeaders { seq, previous } = chain_headers(record)?;
60 let next_seq = match resume {
61 None => seq
62 .checked_add(1)
63 .filter(|next| *next < u64::MAX)
64 .ok_or_else(|| poisoned("chain sequence exhausted"))?,
65 Some(ChainPosition {
66 next_seq: expected,
67 head,
68 }) => match chain_step(expected.0, seq, previous == head) {
69 ChainStep::Continue(next) => next,
70 ChainStep::SequenceMismatch => {
71 return Err(poisoned("noncontiguous chain sequence"));
72 }
73 ChainStep::HeadMismatch => return Err(poisoned("chain head mismatch")),
74 ChainStep::Exhausted => return Err(poisoned("chain sequence exhausted")),
75 },
76 };
77 resume = Some(ChainPosition {
78 next_seq: Seq(next_seq),
79 head: chain_hash(&previous, seq, &record.value),
80 });
81 }
82 Ok(resume)
83}
84
85fn chain_headers(record: &AuditRecord) -> Result<ChainHeaders, AuditError> {
86 let seq = record
87 .headers
88 .iter()
89 .find(|(key, _)| key == HEADER_SEQ)
90 .and_then(|(_, value)| str::from_utf8(value).ok())
91 .and_then(|value| value.parse().ok());
92 let previous = record
93 .headers
94 .iter()
95 .find(|(key, _)| key == HEADER_PREV_HASH)
96 .and_then(|(_, value)| str::from_utf8(value).ok())
97 .and_then(from_hex32);
98 seq.zip(previous)
99 .map(|(seq, previous)| ChainHeaders { seq, previous })
100 .ok_or_else(|| poisoned("missing or invalid chain headers"))
101}
102
103#[cfg(test)]
104mod tests {
105 use {
106 super::*,
107 crate::{
108 chain::{GENESIS_HEAD, to_hex},
109 spool::test_support::{ROOMY_CAP, chained_record},
110 },
111 assert2::check,
112 };
113
114 #[test]
115 fn resume_point_is_from_last_chained_record_skipping_checkpoints() {
116 let dir = tempfile::tempdir().unwrap();
117 let mut s = Spool::open(dir.path(), ROOMY_CAP).unwrap();
118 let prev0 = GENESIS_HEAD;
119 let r0 = chained_record(0, &prev0, b"v0");
120 let head0 = chain_hash(&prev0, 0, b"v0");
121 let r1 = chained_record(1, &head0, b"v1");
122 let head1 = chain_hash(&head0, 1, b"v1");
123 let cp = AuditRecord {
125 class: AuditEventClass::Checkpoint,
126 value: b"{\"type\":\"checkpoint\"}".to_vec(),
127 headers: vec![("event_class".into(), b"checkpoint".to_vec())],
128 };
129 s.append(&r0).unwrap();
130 s.append(&r1).unwrap();
131 s.append(&cp).unwrap();
132 let (next_seq, head) = s.resume_point().unwrap().unwrap();
133 check!((next_seq, head, to_hex(&head)) == (2, head1, to_hex(&head1)));
135 let _ = (HEADER_SEQ, HEADER_PREV_HASH); }
137
138 #[test]
139 fn resume_rejects_a_malformed_or_disconnected_tail() {
140 let dir = tempfile::tempdir().unwrap();
141 let mut spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
142 let record = chained_record(0, &GENESIS_HEAD, b"valid");
143 let mut malformed = chained_record(1, &chain_hash(&GENESIS_HEAD, 0, b"valid"), b"bad");
144 malformed.headers.retain(|(key, _)| key != HEADER_SEQ);
145 spool.append(&record).unwrap();
146 spool.append(&malformed).unwrap();
147 check!(matches!(spool.resume_point(), Err(AuditError::Poisoned(_))));
148
149 let dir = tempfile::tempdir().unwrap();
150 let mut spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
151 spool.append(&record).unwrap();
152 spool
153 .append(&chained_record(2, &GENESIS_HEAD, b"gap"))
154 .unwrap();
155 check!(matches!(spool.resume_point(), Err(AuditError::Poisoned(_))));
156 }
157
158 #[test]
159 fn resume_rejects_sequence_exhaustion() {
160 let dir = tempfile::tempdir().unwrap();
161 let mut spool = Spool::open(dir.path(), ROOMY_CAP).unwrap();
162 spool
163 .append(&chained_record(u64::MAX - 1, &GENESIS_HEAD, b"last"))
164 .unwrap();
165 check!(matches!(spool.resume_point(), Err(AuditError::Poisoned(_))));
166 }
167}