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: Eq {
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))
110            if left.record.generation() == right.record.generation()
111                && left.record != right.record =>
112        {
113            Err(CommitRecoveryError::AmbiguousGeneration {
114                generation: left.record.generation(),
115            })
116        }
117        (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
118            Ok(right)
119        }
120        (Some(left), Some(_) | None) => Ok(left),
121        (None, Some(right)) => Ok(right),
122        (None, None) => Err(CommitRecoveryError::NoValidGeneration),
123    }
124}
125
126///
127/// CommittedGenerationBytes
128///
129/// Physically committed ledger generation payload protected by a checksum.
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
131pub struct CommittedGenerationBytes {
132    /// Generation number represented by this payload.
133    pub generation: u64,
134    /// Physical commit marker. Readers reject records with an invalid marker.
135    pub commit_marker: u64,
136    /// Checksum over the generation, marker, and payload bytes.
137    pub checksum: u64,
138    /// Encoded ledger generation payload.
139    pub payload: Vec<u8>,
140}
141
142impl CommittedGenerationBytes {
143    /// Build a committed generation record.
144    #[must_use]
145    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
146        let mut record = Self {
147            generation,
148            commit_marker: COMMIT_MARKER,
149            checksum: 0,
150            payload,
151        };
152        record.checksum = generation_checksum(&record);
153        record
154    }
155
156    /// Return whether the marker and checksum validate.
157    #[must_use]
158    pub fn validates(&self) -> bool {
159        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
160    }
161}
162
163impl ProtectedGenerationSlot for CommittedGenerationBytes {
164    fn generation(&self) -> u64 {
165        self.generation
166    }
167
168    fn validates(&self) -> bool {
169        self.validates()
170    }
171}
172
173///
174/// DualCommitStore
175///
176/// Dual-slot protected commit protocol for encoded ledger generations.
177///
178/// Writers stage a complete generation record into the inactive slot. Readers
179/// recover by selecting the highest-generation valid slot. A torn or partial
180/// write cannot become authoritative unless its marker and checksum validate.
181///
182/// The checksum is for torn-write and accidental-corruption detection only. It
183/// is not a cryptographic hash and does not provide adversarial tamper
184/// resistance.
185#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
186pub struct DualCommitStore {
187    /// First physical commit slot.
188    pub slot0: Option<CommittedGenerationBytes>,
189    /// Second physical commit slot.
190    pub slot1: Option<CommittedGenerationBytes>,
191}
192
193impl DualCommitStore {
194    /// Return true when no commit slot has ever been written.
195    #[must_use]
196    pub const fn is_uninitialized(&self) -> bool {
197        self.slot0.is_none() && self.slot1.is_none()
198    }
199
200    /// Return the highest-generation valid committed record.
201    pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
202        self.authoritative_slot()
203            .map(|authoritative| authoritative.record)
204    }
205
206    /// Build a read-only recovery diagnostic for the protected commit slots.
207    #[must_use]
208    pub fn diagnostic(&self) -> CommitStoreDiagnostic {
209        CommitStoreDiagnostic::from_store(self)
210    }
211
212    /// Commit a new payload to the inactive slot.
213    ///
214    /// The returned store models the post-write physical state. If a real
215    /// substrate traps before the inactive slot is fully written, the prior
216    /// valid slot remains authoritative under `authoritative`.
217    pub fn commit_payload(
218        &mut self,
219        payload: Vec<u8>,
220    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
221        let next_generation =
222            match self.authoritative() {
223                Ok(record) => record.generation.checked_add(1).ok_or(
224                    CommitRecoveryError::GenerationOverflow {
225                        generation: record.generation,
226                    },
227                )?,
228                Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
229                Err(err) => return Err(err),
230            };
231
232        self.commit_payload_at_generation(next_generation, payload)
233    }
234
235    /// Commit `payload` as an explicitly numbered physical generation.
236    ///
237    /// This is the preferred API for logical ledger commits: the physical slot
238    /// generation is taken from the logical ledger generation and checked
239    /// against the recovered physical predecessor.
240    pub fn commit_payload_at_generation(
241        &mut self,
242        generation: u64,
243        payload: Vec<u8>,
244    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
245        match self.authoritative() {
246            Ok(record) => {
247                let expected = record.generation.checked_add(1).ok_or(
248                    CommitRecoveryError::GenerationOverflow {
249                        generation: record.generation,
250                    },
251                )?;
252                if generation != expected {
253                    return Err(CommitRecoveryError::UnexpectedGeneration {
254                        expected,
255                        actual: generation,
256                    });
257                }
258            }
259            Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
260            Err(err) => return Err(err),
261        }
262
263        let next = CommittedGenerationBytes::new(generation, payload);
264
265        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
266            self.slot0 = Some(next);
267        } else {
268            self.slot1 = Some(next);
269        }
270
271        self.authoritative()
272    }
273
274    /// Simulate a torn write into the inactive slot.
275    ///
276    /// This helper is intentionally part of the model because recovery behavior
277    /// is an ABI requirement, not an implementation detail.
278    #[cfg(test)]
279    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
280        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
281        corrupt.checksum = corrupt.checksum.wrapping_add(1);
282
283        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
284            self.slot0 = Some(corrupt);
285        } else {
286            self.slot1 = Some(corrupt);
287        }
288    }
289}
290
291impl DualProtectedCommitStore for DualCommitStore {
292    type Slot = CommittedGenerationBytes;
293
294    fn slot0(&self) -> Option<&Self::Slot> {
295        self.slot0.as_ref()
296    }
297
298    fn slot1(&self) -> Option<&Self::Slot> {
299        self.slot1.as_ref()
300    }
301}
302
303///
304/// CommitStoreDiagnostic
305///
306/// Read-only diagnostic summary of protected commit recovery state.
307#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
308pub struct CommitStoreDiagnostic {
309    /// First physical commit slot diagnostic.
310    pub slot0: CommitSlotDiagnostic,
311    /// Second physical commit slot diagnostic.
312    pub slot1: CommitSlotDiagnostic,
313    /// Highest valid generation selected by recovery.
314    pub authoritative_generation: Option<u64>,
315    /// Recovery error when no authoritative generation can be selected.
316    pub recovery_error: Option<CommitRecoveryError>,
317}
318
319impl CommitStoreDiagnostic {
320    /// Build a read-only recovery diagnostic from a dual protected commit store.
321    #[must_use]
322    pub fn from_store<S: DualProtectedCommitStore>(store: &S) -> Self {
323        let (authoritative_generation, recovery_error) = match store.authoritative_slot() {
324            Ok(slot) => (Some(slot.record.generation()), None),
325            Err(err) => (None, Some(err)),
326        };
327        Self {
328            slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
329            slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
330            authoritative_generation,
331            recovery_error,
332        }
333    }
334}
335
336///
337/// CommitSlotDiagnostic
338///
339/// Read-only diagnostic summary for one protected commit slot.
340#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
341pub struct CommitSlotDiagnostic {
342    /// Whether a physical slot record is present.
343    pub present: bool,
344    /// Generation encoded by the slot, if present.
345    pub generation: Option<u64>,
346    /// Whether marker and checksum validation succeeded.
347    pub valid: bool,
348}
349
350impl CommitSlotDiagnostic {
351    fn from_slot<T: ProtectedGenerationSlot>(slot: Option<&T>) -> Self {
352        match slot {
353            Some(record) => Self {
354                present: true,
355                generation: Some(record.generation()),
356                valid: record.validates(),
357            },
358            None => Self {
359                present: false,
360                generation: None,
361                valid: false,
362            },
363        }
364    }
365}
366
367///
368/// CommitRecoveryError
369///
370/// Protected commit recovery failure.
371#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
372pub enum CommitRecoveryError {
373    /// No committed slot passed marker and checksum validation.
374    #[error("no valid committed ledger generation")]
375    NoValidGeneration,
376    /// Both physical slots validated at the same generation but contained different bytes.
377    #[error("ambiguous committed ledger generation {generation}")]
378    AmbiguousGeneration {
379        /// Ambiguous physical generation.
380        generation: u64,
381    },
382    /// Physical generation advancement would overflow.
383    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
384    GenerationOverflow {
385        /// Last valid physical generation.
386        generation: u64,
387    },
388    /// Caller attempted to commit a physical generation other than the next generation.
389    #[error("expected committed ledger generation {expected}, got {actual}")]
390    UnexpectedGeneration {
391        /// Expected next physical generation.
392        expected: u64,
393        /// Actual requested physical generation.
394        actual: u64,
395    },
396}
397
398fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
399    let mut hash = FNV_OFFSET;
400    hash = hash_u64(hash, generation.generation);
401    hash = hash_u64(hash, generation.commit_marker);
402    hash = hash_usize(hash, generation.payload.len());
403    for byte in &generation.payload {
404        hash = hash_byte(hash, *byte);
405    }
406    hash
407}
408
409fn hash_usize(hash: u64, value: usize) -> u64 {
410    hash_u64(hash, value as u64)
411}
412
413fn hash_u64(mut hash: u64, value: u64) -> u64 {
414    for byte in value.to_le_bytes() {
415        hash = hash_byte(hash, byte);
416    }
417    hash
418}
419
420const fn hash_byte(hash: u64, byte: u8) -> u64 {
421    (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn payload(value: u8) -> Vec<u8> {
429        vec![value; 4]
430    }
431
432    #[test]
433    fn committed_generation_validates_marker_and_checksum() {
434        let mut generation = CommittedGenerationBytes::new(7, payload(1));
435        assert!(generation.validates());
436
437        generation.checksum = generation.checksum.wrapping_add(1);
438        assert!(!generation.validates());
439    }
440
441    #[test]
442    fn authoritative_selects_highest_valid_generation() {
443        let mut store = DualCommitStore::default();
444        store.commit_payload(payload(1)).expect("first commit");
445        store.commit_payload(payload(2)).expect("second commit");
446
447        let authoritative = store.authoritative().expect("authoritative");
448        let authoritative_slot =
449            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
450                .expect("authoritative slot");
451
452        assert_eq!(authoritative.generation, 1);
453        assert_eq!(authoritative.payload, payload(2));
454        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
455        assert_eq!(authoritative_slot.record.payload, payload(2));
456    }
457
458    #[test]
459    fn corrupt_newer_slot_leaves_prior_generation_authoritative() {
460        let mut store = DualCommitStore::default();
461        store.commit_payload(payload(1)).expect("first commit");
462        store.write_corrupt_inactive_slot(1, payload(2));
463
464        let authoritative = store.authoritative().expect("authoritative");
465
466        assert_eq!(authoritative.generation, 0);
467        assert_eq!(authoritative.payload, payload(1));
468    }
469
470    #[test]
471    fn no_valid_generation_fails_closed() {
472        let mut store = DualCommitStore::default();
473        store.write_corrupt_inactive_slot(0, payload(1));
474        store.write_corrupt_inactive_slot(1, payload(2));
475
476        let err = store.authoritative().expect_err("no valid slot");
477
478        assert_eq!(err, CommitRecoveryError::NoValidGeneration);
479    }
480
481    #[test]
482    fn same_generation_identical_slots_recover_deterministically() {
483        let committed = CommittedGenerationBytes::new(7, payload(1));
484        let store = DualCommitStore {
485            slot0: Some(committed.clone()),
486            slot1: Some(committed),
487        };
488
489        let authoritative = store.authoritative_slot().expect("authoritative");
490
491        assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
492        assert_eq!(authoritative.record.generation, 7);
493    }
494
495    #[test]
496    fn same_generation_divergent_slots_fail_closed() {
497        let store = DualCommitStore {
498            slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
499            slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
500        };
501
502        let err = store.authoritative().expect_err("ambiguous generation");
503
504        assert_eq!(
505            err,
506            CommitRecoveryError::AmbiguousGeneration { generation: 7 }
507        );
508    }
509
510    #[test]
511    fn physical_generation_overflow_fails_closed() {
512        let mut store = DualCommitStore {
513            slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
514            slot1: None,
515        };
516
517        let err = store
518            .commit_payload(payload(2))
519            .expect_err("overflow must fail");
520
521        assert_eq!(
522            err,
523            CommitRecoveryError::GenerationOverflow {
524                generation: u64::MAX
525            }
526        );
527    }
528
529    #[test]
530    fn diagnostic_reports_authoritative_generation_and_corrupt_slots() {
531        let mut store = DualCommitStore::default();
532        store.commit_payload(payload(1)).expect("first commit");
533        store.write_corrupt_inactive_slot(1, payload(2));
534
535        let diagnostic = store.diagnostic();
536
537        assert_eq!(diagnostic.authoritative_generation, Some(0));
538        assert_eq!(diagnostic.recovery_error, None);
539        assert_eq!(diagnostic.slot0.generation, Some(0));
540        assert!(diagnostic.slot0.valid);
541        assert_eq!(diagnostic.slot1.generation, Some(1));
542        assert!(!diagnostic.slot1.valid);
543    }
544
545    #[test]
546    fn diagnostic_reports_no_valid_generation_for_empty_store() {
547        let diagnostic = DualCommitStore::default().diagnostic();
548
549        assert_eq!(diagnostic.authoritative_generation, None);
550        assert_eq!(
551            diagnostic.recovery_error,
552            Some(CommitRecoveryError::NoValidGeneration)
553        );
554        assert!(!diagnostic.slot0.present);
555        assert!(!diagnostic.slot1.present);
556    }
557
558    #[test]
559    fn diagnostic_builds_from_any_dual_protected_store() {
560        #[derive(Eq, PartialEq)]
561        struct TestSlot {
562            generation: u64,
563            valid: bool,
564        }
565
566        impl ProtectedGenerationSlot for TestSlot {
567            fn generation(&self) -> u64 {
568                self.generation
569            }
570
571            fn validates(&self) -> bool {
572                self.valid
573            }
574        }
575
576        struct TestStore {
577            slot0: Option<TestSlot>,
578            slot1: Option<TestSlot>,
579        }
580
581        impl DualProtectedCommitStore for TestStore {
582            type Slot = TestSlot;
583
584            fn slot0(&self) -> Option<&Self::Slot> {
585                self.slot0.as_ref()
586            }
587
588            fn slot1(&self) -> Option<&Self::Slot> {
589                self.slot1.as_ref()
590            }
591        }
592
593        let diagnostic = CommitStoreDiagnostic::from_store(&TestStore {
594            slot0: Some(TestSlot {
595                generation: 8,
596                valid: true,
597            }),
598            slot1: Some(TestSlot {
599                generation: 9,
600                valid: false,
601            }),
602        });
603
604        assert_eq!(diagnostic.authoritative_generation, Some(8));
605        assert!(diagnostic.slot0.valid);
606        assert_eq!(diagnostic.slot1.generation, Some(9));
607        assert!(!diagnostic.slot1.valid);
608    }
609
610    #[test]
611    fn uninitialized_distinguishes_empty_from_corrupt() {
612        let mut store = DualCommitStore::default();
613        assert!(store.is_uninitialized());
614
615        store.write_corrupt_inactive_slot(0, payload(1));
616
617        assert!(!store.is_uninitialized());
618    }
619
620    #[test]
621    fn commit_after_corrupt_slot_advances_from_prior_valid_generation() {
622        let mut store = DualCommitStore::default();
623        store.commit_payload(payload(1)).expect("first commit");
624        store.write_corrupt_inactive_slot(1, payload(2));
625        store.commit_payload(payload(3)).expect("third commit");
626
627        let authoritative = store.authoritative().expect("authoritative");
628
629        assert_eq!(authoritative.generation, 1);
630        assert_eq!(authoritative.payload, payload(3));
631    }
632}