Skip to main content

ic_memory/
physical.rs

1use serde::{Deserialize, Serialize};
2
3const COMMIT_MARKER: u64 = 0x4943_4D45_4D43_4F4D;
4const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
5const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
6
7///
8/// CommittedGenerationBytes
9///
10/// Physically committed ledger generation payload protected by a checksum.
11#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
12pub struct CommittedGenerationBytes {
13    /// Generation number represented by this payload.
14    pub generation: u64,
15    /// Physical commit marker. Readers reject records with an invalid marker.
16    pub commit_marker: u64,
17    /// Checksum over the generation, marker, and payload bytes.
18    pub checksum: u64,
19    /// Encoded ledger generation payload.
20    pub payload: Vec<u8>,
21}
22
23impl CommittedGenerationBytes {
24    /// Build a committed generation record.
25    #[must_use]
26    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
27        let mut record = Self {
28            generation,
29            commit_marker: COMMIT_MARKER,
30            checksum: 0,
31            payload,
32        };
33        record.checksum = generation_checksum(&record);
34        record
35    }
36
37    /// Return whether the marker and checksum validate.
38    #[must_use]
39    pub fn validates(&self) -> bool {
40        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
41    }
42}
43
44///
45/// DualCommitStore
46///
47/// Dual-slot protected commit protocol for encoded ledger generations.
48///
49/// Writers stage a complete generation record into the inactive slot. Readers
50/// recover by selecting the highest-generation valid slot. A torn or partial
51/// write cannot become authoritative unless its marker and checksum validate.
52#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
53pub struct DualCommitStore {
54    /// First physical commit slot.
55    pub slot0: Option<CommittedGenerationBytes>,
56    /// Second physical commit slot.
57    pub slot1: Option<CommittedGenerationBytes>,
58}
59
60impl DualCommitStore {
61    /// Return true when no commit slot has ever been written.
62    #[must_use]
63    pub const fn is_uninitialized(&self) -> bool {
64        self.slot0.is_none() && self.slot1.is_none()
65    }
66
67    /// Return the highest-generation valid committed record.
68    pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
69        let slot0 = self.slot0.as_ref().filter(|slot| slot.validates());
70        let slot1 = self.slot1.as_ref().filter(|slot| slot.validates());
71
72        match (slot0, slot1) {
73            (Some(left), Some(right)) if right.generation > left.generation => Ok(right),
74            (Some(left), Some(_) | None) => Ok(left),
75            (None, Some(right)) => Ok(right),
76            (None, None) => Err(CommitRecoveryError::NoValidGeneration),
77        }
78    }
79
80    /// Build a read-only recovery diagnostic for the protected commit slots.
81    #[must_use]
82    pub fn diagnostic(&self) -> CommitStoreDiagnostic {
83        let authoritative = self.authoritative();
84        CommitStoreDiagnostic {
85            slot0: CommitSlotDiagnostic::from_slot(self.slot0.as_ref()),
86            slot1: CommitSlotDiagnostic::from_slot(self.slot1.as_ref()),
87            authoritative_generation: authoritative.ok().map(|record| record.generation),
88            recovery_error: authoritative.err(),
89        }
90    }
91
92    /// Commit a new payload to the inactive slot.
93    ///
94    /// The returned store models the post-write physical state. If a real
95    /// substrate traps before the inactive slot is fully written, the prior
96    /// valid slot remains authoritative under `authoritative`.
97    pub fn commit_payload(
98        &mut self,
99        payload: Vec<u8>,
100    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
101        let next_generation = self
102            .authoritative()
103            .map_or(0, |record| record.generation.saturating_add(1));
104        let next = CommittedGenerationBytes::new(next_generation, payload);
105
106        if self.inactive_slot_index() == 0 {
107            self.slot0 = Some(next);
108        } else {
109            self.slot1 = Some(next);
110        }
111
112        self.authoritative()
113    }
114
115    /// Simulate a torn write into the inactive slot.
116    ///
117    /// This helper is intentionally part of the model because recovery behavior
118    /// is an ABI requirement, not an implementation detail.
119    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
120        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
121        corrupt.checksum = corrupt.checksum.wrapping_add(1);
122
123        if self.inactive_slot_index() == 0 {
124            self.slot0 = Some(corrupt);
125        } else {
126            self.slot1 = Some(corrupt);
127        }
128    }
129
130    fn inactive_slot_index(&self) -> u8 {
131        match self.authoritative() {
132            Ok(record) if self.slot0.as_ref() == Some(record) => 1,
133            Ok(_) | Err(_) => 0,
134        }
135    }
136}
137
138///
139/// CommitStoreDiagnostic
140///
141/// Read-only diagnostic summary of protected commit recovery state.
142#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
143pub struct CommitStoreDiagnostic {
144    /// First physical commit slot diagnostic.
145    pub slot0: CommitSlotDiagnostic,
146    /// Second physical commit slot diagnostic.
147    pub slot1: CommitSlotDiagnostic,
148    /// Highest valid generation selected by recovery.
149    pub authoritative_generation: Option<u64>,
150    /// Recovery error when no authoritative generation can be selected.
151    pub recovery_error: Option<CommitRecoveryError>,
152}
153
154///
155/// CommitSlotDiagnostic
156///
157/// Read-only diagnostic summary for one protected commit slot.
158#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
159pub struct CommitSlotDiagnostic {
160    /// Whether a physical slot record is present.
161    pub present: bool,
162    /// Generation encoded by the slot, if present.
163    pub generation: Option<u64>,
164    /// Whether marker and checksum validation succeeded.
165    pub valid: bool,
166}
167
168impl CommitSlotDiagnostic {
169    fn from_slot(slot: Option<&CommittedGenerationBytes>) -> Self {
170        match slot {
171            Some(record) => Self {
172                present: true,
173                generation: Some(record.generation),
174                valid: record.validates(),
175            },
176            None => Self {
177                present: false,
178                generation: None,
179                valid: false,
180            },
181        }
182    }
183}
184
185///
186/// CommitRecoveryError
187///
188/// Protected commit recovery failure.
189#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
190pub enum CommitRecoveryError {
191    /// No committed slot passed marker and checksum validation.
192    #[error("no valid committed ledger generation")]
193    NoValidGeneration,
194}
195
196fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
197    let mut hash = FNV_OFFSET;
198    hash = hash_u64(hash, generation.generation);
199    hash = hash_u64(hash, generation.commit_marker);
200    hash = hash_usize(hash, generation.payload.len());
201    for byte in &generation.payload {
202        hash = hash_byte(hash, *byte);
203    }
204    hash
205}
206
207fn hash_usize(hash: u64, value: usize) -> u64 {
208    hash_u64(hash, value as u64)
209}
210
211fn hash_u64(mut hash: u64, value: u64) -> u64 {
212    for byte in value.to_le_bytes() {
213        hash = hash_byte(hash, byte);
214    }
215    hash
216}
217
218const fn hash_byte(hash: u64, byte: u8) -> u64 {
219    (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    fn payload(value: u8) -> Vec<u8> {
227        vec![value; 4]
228    }
229
230    #[test]
231    fn committed_generation_validates_marker_and_checksum() {
232        let mut generation = CommittedGenerationBytes::new(7, payload(1));
233        assert!(generation.validates());
234
235        generation.checksum = generation.checksum.wrapping_add(1);
236        assert!(!generation.validates());
237    }
238
239    #[test]
240    fn authoritative_selects_highest_valid_generation() {
241        let mut store = DualCommitStore::default();
242        store.commit_payload(payload(1)).expect("first commit");
243        store.commit_payload(payload(2)).expect("second commit");
244
245        let authoritative = store.authoritative().expect("authoritative");
246
247        assert_eq!(authoritative.generation, 1);
248        assert_eq!(authoritative.payload, payload(2));
249    }
250
251    #[test]
252    fn corrupt_newer_slot_leaves_prior_generation_authoritative() {
253        let mut store = DualCommitStore::default();
254        store.commit_payload(payload(1)).expect("first commit");
255        store.write_corrupt_inactive_slot(1, payload(2));
256
257        let authoritative = store.authoritative().expect("authoritative");
258
259        assert_eq!(authoritative.generation, 0);
260        assert_eq!(authoritative.payload, payload(1));
261    }
262
263    #[test]
264    fn no_valid_generation_fails_closed() {
265        let mut store = DualCommitStore::default();
266        store.write_corrupt_inactive_slot(0, payload(1));
267        store.write_corrupt_inactive_slot(1, payload(2));
268
269        let err = store.authoritative().expect_err("no valid slot");
270
271        assert_eq!(err, CommitRecoveryError::NoValidGeneration);
272    }
273
274    #[test]
275    fn diagnostic_reports_authoritative_generation_and_corrupt_slots() {
276        let mut store = DualCommitStore::default();
277        store.commit_payload(payload(1)).expect("first commit");
278        store.write_corrupt_inactive_slot(1, payload(2));
279
280        let diagnostic = store.diagnostic();
281
282        assert_eq!(diagnostic.authoritative_generation, Some(0));
283        assert_eq!(diagnostic.recovery_error, None);
284        assert_eq!(diagnostic.slot0.generation, Some(0));
285        assert!(diagnostic.slot0.valid);
286        assert_eq!(diagnostic.slot1.generation, Some(1));
287        assert!(!diagnostic.slot1.valid);
288    }
289
290    #[test]
291    fn diagnostic_reports_no_valid_generation_for_empty_store() {
292        let diagnostic = DualCommitStore::default().diagnostic();
293
294        assert_eq!(diagnostic.authoritative_generation, None);
295        assert_eq!(
296            diagnostic.recovery_error,
297            Some(CommitRecoveryError::NoValidGeneration)
298        );
299        assert!(!diagnostic.slot0.present);
300        assert!(!diagnostic.slot1.present);
301    }
302
303    #[test]
304    fn uninitialized_distinguishes_empty_from_corrupt() {
305        let mut store = DualCommitStore::default();
306        assert!(store.is_uninitialized());
307
308        store.write_corrupt_inactive_slot(0, payload(1));
309
310        assert!(!store.is_uninitialized());
311    }
312
313    #[test]
314    fn commit_after_corrupt_slot_advances_from_prior_valid_generation() {
315        let mut store = DualCommitStore::default();
316        store.commit_payload(payload(1)).expect("first commit");
317        store.write_corrupt_inactive_slot(1, payload(2));
318        store.commit_payload(payload(3)).expect("third commit");
319
320        let authoritative = store.authoritative().expect("authoritative");
321
322        assert_eq!(authoritative.generation, 1);
323        assert_eq!(authoritative.payload, payload(3));
324    }
325}