polyc_eventlog/checkpoint.rs
1//! Durable "expected event count" side-channel (`#799` hardening).
2//!
3//! [`crate::EventLog`]'s replay can be silently shortened by the underlying
4//! journal's own crash recovery: on open, `commonware-storage`'s
5//! `variable::Journal` re-derives its still-open (active) section's item
6//! boundaries by sequentially re-parsing it from byte zero, and if that scan
7//! ever fails to decode an item it cannot tell "one item mid-section is
8//! corrupt" apart from "this whole section may be an in-flight torn write" —
9//! so it silently rewinds, which can invalidate the ENTIRE active section,
10//! not just a truncated tail (empirically: even an event that PRECEDES the
11//! corrupted one is lost when everything lives in one still-open section).
12//! That is the right call for what the journal itself can see (a real crash
13//! never durably committed content it cannot cleanly re-derive), but the
14//! journal cannot tell "recovered from a crash" apart from "recovered from a
15//! byte flipped in already-durable data" — both look identical on disk. Left
16//! unchecked, a caller that only consults the journal after this self-heal
17//! sees a shorter-than-expected (or empty) replay with no signal anything is
18//! wrong, which is exactly the false pass a tamper-evidence check must not
19//! produce.
20//!
21//! [`EventCountCheckpoint`] closes that gap by durably recording, in a
22//! store SEPARATE from the partition's own journal files (a
23//! [`commonware_storage::metadata::Metadata`] instance — CRC32-checked,
24//! dual-blob rotation, so a single corrupted blob falls back to the other
25//! still-valid one instead of silently truncating), the event count last
26//! observed after a legitimate, durably-committed mutation. A later replay
27//! whose count is lower than the last recorded checkpoint has lost
28//! already-committed data — the signature of active-section corruption, not
29//! an ordinary crash — and the caller must treat that as a hard failure.
30//!
31//! This is deliberately a MINIMUM bound, not an exact one: a crash between a
32//! successful journal `commit()` and this checkpoint's own `sync()` leaves
33//! the checkpoint stale (lower than reality), which only weakens detection
34//! for that one commit — the checkpoint is only ever raised to a count the
35//! caller has already durably observed, so a stale value can never manufacture
36//! a false failure.
37
38use commonware_storage::metadata::{Config as MetadataConfig, Metadata};
39use commonware_utils::sequence::U64;
40
41use crate::EventLogError;
42
43/// Fixed key under which the expected event count is stored — one value per
44/// checkpoint store, so a well-known key is all that is needed.
45const EXPECTED_COUNT_KEY: U64 = U64::new(0);
46
47/// Suffix distinguishing a partition's checkpoint store from its journal's
48/// own `{partition}_data` / `{partition}_offsets` storage directories. Ends
49/// without a `_data` suffix, so a directory listing keyed on that suffix
50/// (partition discovery) never picks this side-store up as a logical
51/// conversation partition.
52const CHECKPOINT_PARTITION_SUFFIX: &str = "__eventcount_checkpoint";
53
54/// A durable "last known committed event count" for one conversation
55/// partition, stored independently of that partition's own journal files.
56///
57/// See the module docs for why this exists and the guarantee it provides.
58pub struct EventCountCheckpoint<E: commonware_storage::Context> {
59 store: Metadata<E, U64, u64>,
60}
61
62impl<E: commonware_storage::Context> EventCountCheckpoint<E> {
63 /// Open (or create) the checkpoint store tracking `partition`.
64 ///
65 /// # Errors
66 ///
67 /// Returns [`EventLogError::Checkpoint`] if the underlying metadata store
68 /// fails to initialize.
69 pub async fn open(context: E, partition: &str) -> Result<Self, EventLogError> {
70 let store = Metadata::init(
71 context,
72 MetadataConfig {
73 partition: format!("{partition}{CHECKPOINT_PARTITION_SUFFIX}"),
74 codec_config: (),
75 },
76 )
77 .await?;
78 Ok(Self { store })
79 }
80
81 /// The last durably recorded event count, or `0` if none was ever
82 /// recorded — a fresh partition, or one whose checkpoint was cleared by
83 /// [`Self::clear`].
84 #[must_use]
85 pub fn expected_count(&self) -> u64 {
86 self.store.get(&EXPECTED_COUNT_KEY).copied().unwrap_or(0)
87 }
88
89 /// Durably record `count` as the new expected minimum.
90 ///
91 /// Callers must only pass a count already observed durably committed
92 /// (see the module docs) — this store does not itself validate that.
93 ///
94 /// # Errors
95 ///
96 /// Returns [`EventLogError::Checkpoint`] if the durable sync fails.
97 pub async fn record(&mut self, count: u64) -> Result<(), EventLogError> {
98 self.store.put(EXPECTED_COUNT_KEY, count);
99 self.store.sync().await?;
100 Ok(())
101 }
102
103 /// Clear the checkpoint because the partition it tracks was destroyed or
104 /// erased, so a subsequent [`Self::open`] of a reused partition name
105 /// starts fresh at `0` rather than remembering content the partition no
106 /// longer has.
107 ///
108 /// # Errors
109 ///
110 /// Returns [`EventLogError::Checkpoint`] if the durable sync fails.
111 pub async fn clear(&mut self) -> Result<(), EventLogError> {
112 self.store.clear();
113 self.store.sync().await?;
114 Ok(())
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use commonware_runtime::{Runner as _, Supervisor as _, deterministic};
122
123 /// A fresh checkpoint has no recorded expectation — verify must not treat
124 /// "never recorded" as "corrupted".
125 #[test]
126 fn fresh_checkpoint_expects_zero() {
127 let executor = deterministic::Runner::default();
128 executor.start(|context| async move {
129 let checkpoint = EventCountCheckpoint::open(context, "conv-fresh")
130 .await
131 .expect("open");
132 assert_eq!(checkpoint.expected_count(), 0);
133 });
134 }
135
136 /// A recorded count survives a reopen (durability), and `clear` resets it.
137 #[test]
138 fn record_survives_reopen_and_clear_resets_it() {
139 let executor = deterministic::Runner::default();
140 executor.start(|context| async move {
141 {
142 let mut checkpoint =
143 EventCountCheckpoint::open(context.child("first"), "conv-durable")
144 .await
145 .expect("open");
146 checkpoint.record(7).await.expect("record");
147 }
148
149 let mut checkpoint =
150 EventCountCheckpoint::open(context.child("second"), "conv-durable")
151 .await
152 .expect("reopen");
153 assert_eq!(checkpoint.expected_count(), 7, "survives reopen");
154
155 checkpoint.clear().await.expect("clear");
156 assert_eq!(checkpoint.expected_count(), 0, "cleared");
157
158 let checkpoint = EventCountCheckpoint::open(context.child("third"), "conv-durable")
159 .await
160 .expect("reopen after clear");
161 assert_eq!(checkpoint.expected_count(), 0, "clear is durable");
162 });
163 }
164}