Skip to main content

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}