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/// ProtectedGenerationSlot
9///
10/// One physical generation slot that can participate in protected recovery.
11pub trait ProtectedGenerationSlot {
12    /// Generation encoded by this slot.
13    fn generation(&self) -> u64;
14
15    /// Return whether the slot passed its marker/checksum validation.
16    fn validates(&self) -> bool;
17}
18
19///
20/// DualProtectedCommitStore
21///
22/// Physical store with two protected generation slots.
23pub trait DualProtectedCommitStore {
24    /// Protected slot record type.
25    type Slot: ProtectedGenerationSlot;
26
27    /// Borrow the first physical slot.
28    fn slot0(&self) -> Option<&Self::Slot>;
29
30    /// Borrow the second physical slot.
31    fn slot1(&self) -> Option<&Self::Slot>;
32
33    /// Return true when no commit slot has ever been written.
34    fn is_uninitialized(&self) -> bool {
35        self.slot0().is_none() && self.slot1().is_none()
36    }
37
38    /// Return the highest-generation valid physical slot.
39    fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_, Self::Slot>, CommitRecoveryError> {
40        select_authoritative_slot(self.slot0(), self.slot1())
41    }
42
43    /// Return the slot that should receive the next staged generation write.
44    ///
45    /// The result is derived from validated recovery state. It does not trust a
46    /// separate current-pointer/header field.
47    fn inactive_slot_index(&self) -> CommitSlotIndex {
48        match self.authoritative_slot() {
49            Ok(authoritative) => authoritative.index.opposite(),
50            Err(_) => CommitSlotIndex::Slot0,
51        }
52    }
53}
54
55///
56/// CommitSlotIndex
57///
58/// Physical dual-slot index selected by protected recovery.
59#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
60pub enum CommitSlotIndex {
61    /// First physical commit slot.
62    Slot0,
63    /// Second physical commit slot.
64    Slot1,
65}
66
67impl CommitSlotIndex {
68    /// Return the opposite physical slot.
69    #[must_use]
70    pub const fn opposite(self) -> Self {
71        match self {
72            Self::Slot0 => Self::Slot1,
73            Self::Slot1 => Self::Slot0,
74        }
75    }
76}
77
78///
79/// AuthoritativeSlot
80///
81/// Highest-generation valid slot selected by protected recovery.
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83pub struct AuthoritativeSlot<'slot, T> {
84    /// Physical slot index.
85    pub index: CommitSlotIndex,
86    /// Valid committed generation in that slot.
87    pub record: &'slot T,
88}
89
90/// Select the highest-generation valid physical slot.
91pub fn select_authoritative_slot<'slot, T: ProtectedGenerationSlot>(
92    slot0: Option<&'slot T>,
93    slot1: Option<&'slot T>,
94) -> Result<AuthoritativeSlot<'slot, T>, CommitRecoveryError> {
95    let slot0 = slot0
96        .filter(|slot| slot.validates())
97        .map(|record| AuthoritativeSlot {
98            index: CommitSlotIndex::Slot0,
99            record,
100        });
101    let slot1 = slot1
102        .filter(|slot| slot.validates())
103        .map(|record| AuthoritativeSlot {
104            index: CommitSlotIndex::Slot1,
105            record,
106        });
107
108    match (slot0, slot1) {
109        (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
110            Ok(right)
111        }
112        (Some(left), Some(_) | None) => Ok(left),
113        (None, Some(right)) => Ok(right),
114        (None, None) => Err(CommitRecoveryError::NoValidGeneration),
115    }
116}
117
118///
119/// CommittedGenerationBytes
120///
121/// Physically committed ledger generation payload protected by a checksum.
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
123pub struct CommittedGenerationBytes {
124    /// Generation number represented by this payload.
125    pub generation: u64,
126    /// Physical commit marker. Readers reject records with an invalid marker.
127    pub commit_marker: u64,
128    /// Checksum over the generation, marker, and payload bytes.
129    pub checksum: u64,
130    /// Encoded ledger generation payload.
131    pub payload: Vec<u8>,
132}
133
134impl CommittedGenerationBytes {
135    /// Build a committed generation record.
136    #[must_use]
137    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
138        let mut record = Self {
139            generation,
140            commit_marker: COMMIT_MARKER,
141            checksum: 0,
142            payload,
143        };
144        record.checksum = generation_checksum(&record);
145        record
146    }
147
148    /// Return whether the marker and checksum validate.
149    #[must_use]
150    pub fn validates(&self) -> bool {
151        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
152    }
153}
154
155impl ProtectedGenerationSlot for CommittedGenerationBytes {
156    fn generation(&self) -> u64 {
157        self.generation
158    }
159
160    fn validates(&self) -> bool {
161        self.validates()
162    }
163}
164
165///
166/// DualCommitStore
167///
168/// Dual-slot protected commit protocol for encoded ledger generations.
169///
170/// Writers stage a complete generation record into the inactive slot. Readers
171/// recover by selecting the highest-generation valid slot. A torn or partial
172/// write cannot become authoritative unless its marker and checksum validate.
173#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
174pub struct DualCommitStore {
175    /// First physical commit slot.
176    pub slot0: Option<CommittedGenerationBytes>,
177    /// Second physical commit slot.
178    pub slot1: Option<CommittedGenerationBytes>,
179}
180
181impl DualCommitStore {
182    /// Return true when no commit slot has ever been written.
183    #[must_use]
184    pub const fn is_uninitialized(&self) -> bool {
185        self.slot0.is_none() && self.slot1.is_none()
186    }
187
188    /// Return the highest-generation valid committed record.
189    pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
190        self.authoritative_slot()
191            .map(|authoritative| authoritative.record)
192    }
193
194    /// Build a read-only recovery diagnostic for the protected commit slots.
195    #[must_use]
196    pub fn diagnostic(&self) -> CommitStoreDiagnostic {
197        CommitStoreDiagnostic::from_store(self)
198    }
199
200    /// Commit a new payload to the inactive slot.
201    ///
202    /// The returned store models the post-write physical state. If a real
203    /// substrate traps before the inactive slot is fully written, the prior
204    /// valid slot remains authoritative under `authoritative`.
205    pub fn commit_payload(
206        &mut self,
207        payload: Vec<u8>,
208    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
209        let next_generation = self
210            .authoritative()
211            .map_or(0, |record| record.generation.saturating_add(1));
212        let next = CommittedGenerationBytes::new(next_generation, payload);
213
214        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
215            self.slot0 = Some(next);
216        } else {
217            self.slot1 = Some(next);
218        }
219
220        self.authoritative()
221    }
222
223    /// Simulate a torn write into the inactive slot.
224    ///
225    /// This helper is intentionally part of the model because recovery behavior
226    /// is an ABI requirement, not an implementation detail.
227    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
228        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
229        corrupt.checksum = corrupt.checksum.wrapping_add(1);
230
231        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
232            self.slot0 = Some(corrupt);
233        } else {
234            self.slot1 = Some(corrupt);
235        }
236    }
237}
238
239impl DualProtectedCommitStore for DualCommitStore {
240    type Slot = CommittedGenerationBytes;
241
242    fn slot0(&self) -> Option<&Self::Slot> {
243        self.slot0.as_ref()
244    }
245
246    fn slot1(&self) -> Option<&Self::Slot> {
247        self.slot1.as_ref()
248    }
249}
250
251///
252/// CommitStoreDiagnostic
253///
254/// Read-only diagnostic summary of protected commit recovery state.
255#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
256pub struct CommitStoreDiagnostic {
257    /// First physical commit slot diagnostic.
258    pub slot0: CommitSlotDiagnostic,
259    /// Second physical commit slot diagnostic.
260    pub slot1: CommitSlotDiagnostic,
261    /// Highest valid generation selected by recovery.
262    pub authoritative_generation: Option<u64>,
263    /// Recovery error when no authoritative generation can be selected.
264    pub recovery_error: Option<CommitRecoveryError>,
265}
266
267impl CommitStoreDiagnostic {
268    /// Build a read-only recovery diagnostic from a dual protected commit store.
269    #[must_use]
270    pub fn from_store<S: DualProtectedCommitStore>(store: &S) -> Self {
271        let (authoritative_generation, recovery_error) = match store.authoritative_slot() {
272            Ok(slot) => (Some(slot.record.generation()), None),
273            Err(err) => (None, Some(err)),
274        };
275        Self {
276            slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
277            slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
278            authoritative_generation,
279            recovery_error,
280        }
281    }
282}
283
284///
285/// CommitSlotDiagnostic
286///
287/// Read-only diagnostic summary for one protected commit slot.
288#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
289pub struct CommitSlotDiagnostic {
290    /// Whether a physical slot record is present.
291    pub present: bool,
292    /// Generation encoded by the slot, if present.
293    pub generation: Option<u64>,
294    /// Whether marker and checksum validation succeeded.
295    pub valid: bool,
296}
297
298impl CommitSlotDiagnostic {
299    fn from_slot<T: ProtectedGenerationSlot>(slot: Option<&T>) -> Self {
300        match slot {
301            Some(record) => Self {
302                present: true,
303                generation: Some(record.generation()),
304                valid: record.validates(),
305            },
306            None => Self {
307                present: false,
308                generation: None,
309                valid: false,
310            },
311        }
312    }
313}
314
315///
316/// CommitRecoveryError
317///
318/// Protected commit recovery failure.
319#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
320pub enum CommitRecoveryError {
321    /// No committed slot passed marker and checksum validation.
322    #[error("no valid committed ledger generation")]
323    NoValidGeneration,
324}
325
326fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
327    let mut hash = FNV_OFFSET;
328    hash = hash_u64(hash, generation.generation);
329    hash = hash_u64(hash, generation.commit_marker);
330    hash = hash_usize(hash, generation.payload.len());
331    for byte in &generation.payload {
332        hash = hash_byte(hash, *byte);
333    }
334    hash
335}
336
337fn hash_usize(hash: u64, value: usize) -> u64 {
338    hash_u64(hash, value as u64)
339}
340
341fn hash_u64(mut hash: u64, value: u64) -> u64 {
342    for byte in value.to_le_bytes() {
343        hash = hash_byte(hash, byte);
344    }
345    hash
346}
347
348const fn hash_byte(hash: u64, byte: u8) -> u64 {
349    (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn payload(value: u8) -> Vec<u8> {
357        vec![value; 4]
358    }
359
360    #[test]
361    fn committed_generation_validates_marker_and_checksum() {
362        let mut generation = CommittedGenerationBytes::new(7, payload(1));
363        assert!(generation.validates());
364
365        generation.checksum = generation.checksum.wrapping_add(1);
366        assert!(!generation.validates());
367    }
368
369    #[test]
370    fn authoritative_selects_highest_valid_generation() {
371        let mut store = DualCommitStore::default();
372        store.commit_payload(payload(1)).expect("first commit");
373        store.commit_payload(payload(2)).expect("second commit");
374
375        let authoritative = store.authoritative().expect("authoritative");
376        let authoritative_slot =
377            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
378                .expect("authoritative slot");
379
380        assert_eq!(authoritative.generation, 1);
381        assert_eq!(authoritative.payload, payload(2));
382        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
383        assert_eq!(authoritative_slot.record.payload, payload(2));
384    }
385
386    #[test]
387    fn corrupt_newer_slot_leaves_prior_generation_authoritative() {
388        let mut store = DualCommitStore::default();
389        store.commit_payload(payload(1)).expect("first commit");
390        store.write_corrupt_inactive_slot(1, payload(2));
391
392        let authoritative = store.authoritative().expect("authoritative");
393
394        assert_eq!(authoritative.generation, 0);
395        assert_eq!(authoritative.payload, payload(1));
396    }
397
398    #[test]
399    fn no_valid_generation_fails_closed() {
400        let mut store = DualCommitStore::default();
401        store.write_corrupt_inactive_slot(0, payload(1));
402        store.write_corrupt_inactive_slot(1, payload(2));
403
404        let err = store.authoritative().expect_err("no valid slot");
405
406        assert_eq!(err, CommitRecoveryError::NoValidGeneration);
407    }
408
409    #[test]
410    fn diagnostic_reports_authoritative_generation_and_corrupt_slots() {
411        let mut store = DualCommitStore::default();
412        store.commit_payload(payload(1)).expect("first commit");
413        store.write_corrupt_inactive_slot(1, payload(2));
414
415        let diagnostic = store.diagnostic();
416
417        assert_eq!(diagnostic.authoritative_generation, Some(0));
418        assert_eq!(diagnostic.recovery_error, None);
419        assert_eq!(diagnostic.slot0.generation, Some(0));
420        assert!(diagnostic.slot0.valid);
421        assert_eq!(diagnostic.slot1.generation, Some(1));
422        assert!(!diagnostic.slot1.valid);
423    }
424
425    #[test]
426    fn diagnostic_reports_no_valid_generation_for_empty_store() {
427        let diagnostic = DualCommitStore::default().diagnostic();
428
429        assert_eq!(diagnostic.authoritative_generation, None);
430        assert_eq!(
431            diagnostic.recovery_error,
432            Some(CommitRecoveryError::NoValidGeneration)
433        );
434        assert!(!diagnostic.slot0.present);
435        assert!(!diagnostic.slot1.present);
436    }
437
438    #[test]
439    fn diagnostic_builds_from_any_dual_protected_store() {
440        struct TestSlot {
441            generation: u64,
442            valid: bool,
443        }
444
445        impl ProtectedGenerationSlot for TestSlot {
446            fn generation(&self) -> u64 {
447                self.generation
448            }
449
450            fn validates(&self) -> bool {
451                self.valid
452            }
453        }
454
455        struct TestStore {
456            slot0: Option<TestSlot>,
457            slot1: Option<TestSlot>,
458        }
459
460        impl DualProtectedCommitStore for TestStore {
461            type Slot = TestSlot;
462
463            fn slot0(&self) -> Option<&Self::Slot> {
464                self.slot0.as_ref()
465            }
466
467            fn slot1(&self) -> Option<&Self::Slot> {
468                self.slot1.as_ref()
469            }
470        }
471
472        let diagnostic = CommitStoreDiagnostic::from_store(&TestStore {
473            slot0: Some(TestSlot {
474                generation: 8,
475                valid: true,
476            }),
477            slot1: Some(TestSlot {
478                generation: 9,
479                valid: false,
480            }),
481        });
482
483        assert_eq!(diagnostic.authoritative_generation, Some(8));
484        assert!(diagnostic.slot0.valid);
485        assert_eq!(diagnostic.slot1.generation, Some(9));
486        assert!(!diagnostic.slot1.valid);
487    }
488
489    #[test]
490    fn uninitialized_distinguishes_empty_from_corrupt() {
491        let mut store = DualCommitStore::default();
492        assert!(store.is_uninitialized());
493
494        store.write_corrupt_inactive_slot(0, payload(1));
495
496        assert!(!store.is_uninitialized());
497    }
498
499    #[test]
500    fn commit_after_corrupt_slot_advances_from_prior_valid_generation() {
501        let mut store = DualCommitStore::default();
502        store.commit_payload(payload(1)).expect("first commit");
503        store.write_corrupt_inactive_slot(1, payload(2));
504        store.commit_payload(payload(3)).expect("third commit");
505
506        let authoritative = store.authoritative().expect("authoritative");
507
508        assert_eq!(authoritative.generation, 1);
509        assert_eq!(authoritative.payload, payload(3));
510    }
511}