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.
11///
12/// This is an advanced low-level API for custom persistence/recovery
13/// integrations. Most callers should use the ledger commit/recovery flow
14/// instead of implementing physical slot recovery directly.
15pub trait ProtectedGenerationSlot: Eq {
16    /// Generation encoded by this slot.
17    fn generation(&self) -> u64;
18
19    /// Return whether the slot passed its marker/checksum validation.
20    fn validates(&self) -> bool;
21}
22
23///
24/// DualProtectedCommitStore
25///
26/// Physical store with two protected generation slots.
27///
28/// This is an advanced low-level API for custom persistence/recovery
29/// integrations. Normal allocation flows recover and commit ledgers through the
30/// higher-level ledger commit APIs.
31pub trait DualProtectedCommitStore {
32    /// Protected slot record type.
33    type Slot: ProtectedGenerationSlot;
34
35    /// Borrow the first physical slot.
36    fn slot0(&self) -> Option<&Self::Slot>;
37
38    /// Borrow the second physical slot.
39    fn slot1(&self) -> Option<&Self::Slot>;
40
41    /// Return true when no commit slot has ever been written.
42    fn is_uninitialized(&self) -> bool {
43        self.slot0().is_none() && self.slot1().is_none()
44    }
45
46    /// Return the highest-generation valid physical slot.
47    fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_, Self::Slot>, CommitRecoveryError> {
48        select_authoritative_slot(self.slot0(), self.slot1())
49    }
50
51    /// Return the slot that should receive the next staged generation write.
52    ///
53    /// The result is derived from validated recovery state. It does not trust a
54    /// separate current-pointer/header field.
55    fn inactive_slot_index(&self) -> CommitSlotIndex {
56        match self.authoritative_slot() {
57            Ok(authoritative) => authoritative.index.opposite(),
58            Err(_) => CommitSlotIndex::Slot0,
59        }
60    }
61}
62
63///
64/// CommitSlotIndex
65///
66/// Physical dual-slot index selected by protected recovery.
67#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
68pub enum CommitSlotIndex {
69    /// First physical commit slot.
70    Slot0,
71    /// Second physical commit slot.
72    Slot1,
73}
74
75impl CommitSlotIndex {
76    /// Return the opposite physical slot.
77    #[must_use]
78    pub const fn opposite(self) -> Self {
79        match self {
80            Self::Slot0 => Self::Slot1,
81            Self::Slot1 => Self::Slot0,
82        }
83    }
84}
85
86///
87/// AuthoritativeSlot
88///
89/// Highest-generation valid slot selected by protected recovery.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub struct AuthoritativeSlot<'slot, T> {
92    /// Physical slot index.
93    pub index: CommitSlotIndex,
94    /// Valid committed generation in that slot.
95    pub record: &'slot T,
96}
97
98/// Select the highest-generation valid physical slot.
99///
100/// This is an advanced recovery helper for custom dual-slot persistence
101/// integrations. It only selects among supplied protected slots; it does not
102/// decode or validate the allocation ledger payload.
103pub fn select_authoritative_slot<'slot, T: ProtectedGenerationSlot>(
104    slot0: Option<&'slot T>,
105    slot1: Option<&'slot T>,
106) -> Result<AuthoritativeSlot<'slot, T>, CommitRecoveryError> {
107    let slot0 = slot0
108        .filter(|slot| slot.validates())
109        .map(|record| AuthoritativeSlot {
110            index: CommitSlotIndex::Slot0,
111            record,
112        });
113    let slot1 = slot1
114        .filter(|slot| slot.validates())
115        .map(|record| AuthoritativeSlot {
116            index: CommitSlotIndex::Slot1,
117            record,
118        });
119
120    match (slot0, slot1) {
121        (Some(left), Some(right))
122            if left.record.generation() == right.record.generation()
123                && left.record != right.record =>
124        {
125            Err(CommitRecoveryError::AmbiguousGeneration {
126                generation: left.record.generation(),
127            })
128        }
129        (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
130            Ok(right)
131        }
132        (Some(left), Some(_) | None) => Ok(left),
133        (None, Some(right)) => Ok(right),
134        (None, None) => Err(CommitRecoveryError::NoValidGeneration),
135    }
136}
137
138///
139/// CommittedGenerationBytes
140///
141/// Physically committed ledger generation payload protected by a checksum.
142///
143/// This is an advanced low-level DTO for custom persistence/recovery
144/// integrations. Its recovered bytes are untrusted until marker/checksum
145/// validation and ledger decoding/integrity validation have both succeeded.
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
147pub struct CommittedGenerationBytes {
148    /// Generation number represented by this payload.
149    pub(crate) generation: u64,
150    /// Physical commit marker. Readers reject records with an invalid marker.
151    pub(crate) commit_marker: u64,
152    /// Checksum over the generation, marker, and payload bytes.
153    pub(crate) checksum: u64,
154    /// Encoded ledger generation payload.
155    pub(crate) payload: Vec<u8>,
156}
157
158impl CommittedGenerationBytes {
159    /// Build a committed generation record.
160    #[must_use]
161    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
162        let mut record = Self {
163            generation,
164            commit_marker: COMMIT_MARKER,
165            checksum: 0,
166            payload,
167        };
168        record.checksum = generation_checksum(&record);
169        record
170    }
171
172    /// Return the generation number represented by this payload.
173    #[must_use]
174    pub const fn generation(&self) -> u64 {
175        self.generation
176    }
177
178    /// Return the physical commit marker.
179    ///
180    /// This is diagnostic data from a recovered record. Callers should use
181    /// [`CommittedGenerationBytes::validates`] before treating the record as
182    /// authoritative.
183    #[must_use]
184    pub const fn commit_marker(&self) -> u64 {
185        self.commit_marker
186    }
187
188    /// Return the checksum over the generation, marker, and payload bytes.
189    ///
190    /// The checksum is non-cryptographic and detects torn writes or accidental
191    /// corruption only.
192    #[must_use]
193    pub const fn checksum(&self) -> u64 {
194        self.checksum
195    }
196
197    /// Borrow the encoded ledger generation payload.
198    #[must_use]
199    pub fn payload(&self) -> &[u8] {
200        &self.payload
201    }
202
203    /// Return whether the marker and checksum validate.
204    #[must_use]
205    pub fn validates(&self) -> bool {
206        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
207    }
208}
209
210impl ProtectedGenerationSlot for CommittedGenerationBytes {
211    fn generation(&self) -> u64 {
212        self.generation
213    }
214
215    fn validates(&self) -> bool {
216        self.validates()
217    }
218}
219
220///
221/// DualCommitStore
222///
223/// Dual-slot protected commit protocol for encoded ledger generations.
224///
225/// This is an advanced low-level API for custom persistence/recovery
226/// integrations. Most applications should recover, validate, and commit through
227/// the allocation ledger flow rather than manipulating encoded physical commit
228/// slots directly.
229///
230/// Writers stage a complete generation record into the inactive slot. Readers
231/// recover by selecting the highest-generation valid slot. A torn or partial
232/// write cannot become authoritative unless its marker and checksum validate.
233///
234/// The checksum is for torn-write and accidental-corruption detection only. It
235/// is not a cryptographic hash and does not provide adversarial tamper
236/// resistance.
237#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
238pub struct DualCommitStore {
239    /// First physical commit slot.
240    pub(crate) slot0: Option<CommittedGenerationBytes>,
241    /// Second physical commit slot.
242    pub(crate) slot1: Option<CommittedGenerationBytes>,
243}
244
245impl DualCommitStore {
246    /// Return true when no commit slot has ever been written.
247    #[must_use]
248    pub const fn is_uninitialized(&self) -> bool {
249        self.slot0.is_none() && self.slot1.is_none()
250    }
251
252    /// Borrow the first physical commit slot.
253    ///
254    /// Slot records are untrusted recovered state until recovery selects an
255    /// authoritative generation.
256    #[must_use]
257    pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
258        self.slot0.as_ref()
259    }
260
261    /// Borrow the second physical commit slot.
262    ///
263    /// Slot records are untrusted recovered state until recovery selects an
264    /// authoritative generation.
265    #[must_use]
266    pub const fn slot1(&self) -> Option<&CommittedGenerationBytes> {
267        self.slot1.as_ref()
268    }
269
270    /// Return the highest-generation valid committed record.
271    pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
272        self.authoritative_slot()
273            .map(|authoritative| authoritative.record)
274    }
275
276    /// Build a read-only recovery diagnostic for the protected commit slots.
277    #[must_use]
278    pub fn diagnostic(&self) -> CommitStoreDiagnostic {
279        CommitStoreDiagnostic::from_store(self)
280    }
281
282    /// Commit a new payload to the inactive slot.
283    ///
284    /// The returned store models the post-write physical state. If a real
285    /// substrate traps before the inactive slot is fully written, the prior
286    /// valid slot remains authoritative under `authoritative`.
287    pub fn commit_payload(
288        &mut self,
289        payload: Vec<u8>,
290    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
291        let next_generation =
292            match self.authoritative() {
293                Ok(record) => record.generation.checked_add(1).ok_or(
294                    CommitRecoveryError::GenerationOverflow {
295                        generation: record.generation,
296                    },
297                )?,
298                Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
299                Err(err) => return Err(err),
300            };
301
302        self.commit_payload_at_generation(next_generation, payload)
303    }
304
305    /// Commit `payload` as an explicitly numbered physical generation.
306    ///
307    /// This is the preferred API for logical ledger commits: the physical slot
308    /// generation is taken from the logical ledger generation and checked
309    /// against the recovered physical predecessor.
310    pub fn commit_payload_at_generation(
311        &mut self,
312        generation: u64,
313        payload: Vec<u8>,
314    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
315        match self.authoritative() {
316            Ok(record) => {
317                let expected = record.generation.checked_add(1).ok_or(
318                    CommitRecoveryError::GenerationOverflow {
319                        generation: record.generation,
320                    },
321                )?;
322                if generation != expected {
323                    return Err(CommitRecoveryError::UnexpectedGeneration {
324                        expected,
325                        actual: generation,
326                    });
327                }
328            }
329            Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
330            Err(err) => return Err(err),
331        }
332
333        let next = CommittedGenerationBytes::new(generation, payload);
334
335        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
336            self.slot0 = Some(next);
337        } else {
338            self.slot1 = Some(next);
339        }
340
341        self.authoritative()
342    }
343
344    /// Simulate a torn write into the inactive slot.
345    ///
346    /// This helper is intentionally part of the model because recovery behavior
347    /// is an ABI requirement, not an implementation detail.
348    #[cfg(test)]
349    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
350        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
351        corrupt.checksum = corrupt.checksum.wrapping_add(1);
352
353        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
354            self.slot0 = Some(corrupt);
355        } else {
356            self.slot1 = Some(corrupt);
357        }
358    }
359}
360
361impl DualProtectedCommitStore for DualCommitStore {
362    type Slot = CommittedGenerationBytes;
363
364    fn slot0(&self) -> Option<&Self::Slot> {
365        self.slot0.as_ref()
366    }
367
368    fn slot1(&self) -> Option<&Self::Slot> {
369        self.slot1.as_ref()
370    }
371}
372
373///
374/// CommitStoreDiagnostic
375///
376/// Read-only diagnostic summary of protected commit recovery state.
377#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
378pub struct CommitStoreDiagnostic {
379    /// First physical commit slot diagnostic.
380    pub slot0: CommitSlotDiagnostic,
381    /// Second physical commit slot diagnostic.
382    pub slot1: CommitSlotDiagnostic,
383    /// Highest valid generation selected by recovery.
384    pub authoritative_generation: Option<u64>,
385    /// Recovery error when no authoritative generation can be selected.
386    pub recovery_error: Option<CommitRecoveryError>,
387}
388
389impl CommitStoreDiagnostic {
390    /// Build a read-only recovery diagnostic from a dual protected commit store.
391    #[must_use]
392    pub fn from_store<S: DualProtectedCommitStore>(store: &S) -> Self {
393        let (authoritative_generation, recovery_error) = match store.authoritative_slot() {
394            Ok(slot) => (Some(slot.record.generation()), None),
395            Err(err) => (None, Some(err)),
396        };
397        Self {
398            slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
399            slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
400            authoritative_generation,
401            recovery_error,
402        }
403    }
404}
405
406///
407/// CommitSlotDiagnostic
408///
409/// Read-only diagnostic summary for one protected commit slot.
410#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
411pub struct CommitSlotDiagnostic {
412    /// Whether a physical slot record is present.
413    pub present: bool,
414    /// Generation encoded by the slot, if present.
415    pub generation: Option<u64>,
416    /// Whether marker and checksum validation succeeded.
417    pub valid: bool,
418}
419
420impl CommitSlotDiagnostic {
421    fn from_slot<T: ProtectedGenerationSlot>(slot: Option<&T>) -> Self {
422        match slot {
423            Some(record) => Self {
424                present: true,
425                generation: Some(record.generation()),
426                valid: record.validates(),
427            },
428            None => Self {
429                present: false,
430                generation: None,
431                valid: false,
432            },
433        }
434    }
435}
436
437///
438/// CommitRecoveryError
439///
440/// Protected commit recovery failure.
441#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
442pub enum CommitRecoveryError {
443    /// No committed slot passed marker and checksum validation.
444    #[error("no valid committed ledger generation")]
445    NoValidGeneration,
446    /// Both physical slots validated at the same generation but contained different bytes.
447    #[error("ambiguous committed ledger generation {generation}")]
448    AmbiguousGeneration {
449        /// Ambiguous physical generation.
450        generation: u64,
451    },
452    /// Physical generation advancement would overflow.
453    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
454    GenerationOverflow {
455        /// Last valid physical generation.
456        generation: u64,
457    },
458    /// Caller attempted to commit a physical generation other than the next generation.
459    #[error("expected committed ledger generation {expected}, got {actual}")]
460    UnexpectedGeneration {
461        /// Expected next physical generation.
462        expected: u64,
463        /// Actual requested physical generation.
464        actual: u64,
465    },
466}
467
468fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
469    let mut hash = FNV_OFFSET;
470    hash = hash_u64(hash, generation.generation);
471    hash = hash_u64(hash, generation.commit_marker);
472    hash = hash_usize(hash, generation.payload.len());
473    for byte in &generation.payload {
474        hash = hash_byte(hash, *byte);
475    }
476    hash
477}
478
479fn hash_usize(hash: u64, value: usize) -> u64 {
480    hash_u64(hash, value as u64)
481}
482
483fn hash_u64(mut hash: u64, value: u64) -> u64 {
484    for byte in value.to_le_bytes() {
485        hash = hash_byte(hash, byte);
486    }
487    hash
488}
489
490const fn hash_byte(hash: u64, byte: u8) -> u64 {
491    (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    fn payload(value: u8) -> Vec<u8> {
499        vec![value; 4]
500    }
501
502    #[test]
503    fn committed_generation_validates_marker_and_checksum() {
504        let mut generation = CommittedGenerationBytes::new(7, payload(1));
505        assert!(generation.validates());
506
507        generation.checksum = generation.checksum.wrapping_add(1);
508        assert!(!generation.validates());
509    }
510
511    #[test]
512    fn physical_commit_accessors_expose_read_only_state() {
513        let mut store = DualCommitStore::default();
514        store.commit_payload(payload(1)).expect("first commit");
515
516        let slot = store.slot0().expect("first slot");
517
518        assert_eq!(slot.generation(), 0);
519        assert_eq!(slot.payload(), payload(1).as_slice());
520        assert_eq!(slot.commit_marker(), COMMIT_MARKER);
521        assert_eq!(slot.checksum(), generation_checksum(slot));
522        assert!(store.slot1().is_none());
523    }
524
525    #[test]
526    fn authoritative_selects_highest_valid_generation() {
527        let mut store = DualCommitStore::default();
528        store.commit_payload(payload(1)).expect("first commit");
529        store.commit_payload(payload(2)).expect("second commit");
530
531        let authoritative = store.authoritative().expect("authoritative");
532        let authoritative_slot =
533            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
534                .expect("authoritative slot");
535
536        assert_eq!(authoritative.generation, 1);
537        assert_eq!(authoritative.payload, payload(2));
538        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
539        assert_eq!(authoritative_slot.record.payload, payload(2));
540    }
541
542    #[test]
543    fn corrupt_newer_slot_leaves_prior_generation_authoritative() {
544        let mut store = DualCommitStore::default();
545        store.commit_payload(payload(1)).expect("first commit");
546        store.write_corrupt_inactive_slot(1, payload(2));
547
548        let authoritative = store.authoritative().expect("authoritative");
549
550        assert_eq!(authoritative.generation, 0);
551        assert_eq!(authoritative.payload, payload(1));
552    }
553
554    #[test]
555    fn no_valid_generation_fails_closed() {
556        let mut store = DualCommitStore::default();
557        store.write_corrupt_inactive_slot(0, payload(1));
558        store.write_corrupt_inactive_slot(1, payload(2));
559
560        let err = store.authoritative().expect_err("no valid slot");
561
562        assert_eq!(err, CommitRecoveryError::NoValidGeneration);
563    }
564
565    #[test]
566    fn same_generation_identical_slots_recover_deterministically() {
567        let committed = CommittedGenerationBytes::new(7, payload(1));
568        let store = DualCommitStore {
569            slot0: Some(committed.clone()),
570            slot1: Some(committed),
571        };
572
573        let authoritative = store.authoritative_slot().expect("authoritative");
574
575        assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
576        assert_eq!(authoritative.record.generation, 7);
577    }
578
579    #[test]
580    fn same_generation_divergent_slots_fail_closed() {
581        let store = DualCommitStore {
582            slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
583            slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
584        };
585
586        let err = store.authoritative().expect_err("ambiguous generation");
587
588        assert_eq!(
589            err,
590            CommitRecoveryError::AmbiguousGeneration { generation: 7 }
591        );
592    }
593
594    #[test]
595    fn physical_generation_overflow_fails_closed() {
596        let mut store = DualCommitStore {
597            slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
598            slot1: None,
599        };
600
601        let err = store
602            .commit_payload(payload(2))
603            .expect_err("overflow must fail");
604
605        assert_eq!(
606            err,
607            CommitRecoveryError::GenerationOverflow {
608                generation: u64::MAX
609            }
610        );
611    }
612
613    #[test]
614    fn diagnostic_reports_authoritative_generation_and_corrupt_slots() {
615        let mut store = DualCommitStore::default();
616        store.commit_payload(payload(1)).expect("first commit");
617        store.write_corrupt_inactive_slot(1, payload(2));
618
619        let diagnostic = store.diagnostic();
620
621        assert_eq!(diagnostic.authoritative_generation, Some(0));
622        assert_eq!(diagnostic.recovery_error, None);
623        assert_eq!(diagnostic.slot0.generation, Some(0));
624        assert!(diagnostic.slot0.valid);
625        assert_eq!(diagnostic.slot1.generation, Some(1));
626        assert!(!diagnostic.slot1.valid);
627    }
628
629    #[test]
630    fn diagnostic_reports_no_valid_generation_for_empty_store() {
631        let diagnostic = DualCommitStore::default().diagnostic();
632
633        assert_eq!(diagnostic.authoritative_generation, None);
634        assert_eq!(
635            diagnostic.recovery_error,
636            Some(CommitRecoveryError::NoValidGeneration)
637        );
638        assert!(!diagnostic.slot0.present);
639        assert!(!diagnostic.slot1.present);
640    }
641
642    #[test]
643    fn diagnostic_builds_from_any_dual_protected_store() {
644        #[derive(Eq, PartialEq)]
645        struct TestSlot {
646            generation: u64,
647            valid: bool,
648        }
649
650        impl ProtectedGenerationSlot for TestSlot {
651            fn generation(&self) -> u64 {
652                self.generation
653            }
654
655            fn validates(&self) -> bool {
656                self.valid
657            }
658        }
659
660        struct TestStore {
661            slot0: Option<TestSlot>,
662            slot1: Option<TestSlot>,
663        }
664
665        impl DualProtectedCommitStore for TestStore {
666            type Slot = TestSlot;
667
668            fn slot0(&self) -> Option<&Self::Slot> {
669                self.slot0.as_ref()
670            }
671
672            fn slot1(&self) -> Option<&Self::Slot> {
673                self.slot1.as_ref()
674            }
675        }
676
677        let diagnostic = CommitStoreDiagnostic::from_store(&TestStore {
678            slot0: Some(TestSlot {
679                generation: 8,
680                valid: true,
681            }),
682            slot1: Some(TestSlot {
683                generation: 9,
684                valid: false,
685            }),
686        });
687
688        assert_eq!(diagnostic.authoritative_generation, Some(8));
689        assert!(diagnostic.slot0.valid);
690        assert_eq!(diagnostic.slot1.generation, Some(9));
691        assert!(!diagnostic.slot1.valid);
692    }
693
694    #[test]
695    fn uninitialized_distinguishes_empty_from_corrupt() {
696        let mut store = DualCommitStore::default();
697        assert!(store.is_uninitialized());
698
699        store.write_corrupt_inactive_slot(0, payload(1));
700
701        assert!(!store.is_uninitialized());
702    }
703
704    #[test]
705    fn commit_after_corrupt_slot_advances_from_prior_valid_generation() {
706        let mut store = DualCommitStore::default();
707        store.commit_payload(payload(1)).expect("first commit");
708        store.write_corrupt_inactive_slot(1, payload(2));
709        store.commit_payload(payload(3)).expect("third commit");
710
711        let authoritative = store.authoritative().expect("authoritative");
712
713        assert_eq!(authoritative.generation, 1);
714        assert_eq!(authoritative.payload, payload(3));
715    }
716}