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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8enum CommitSlotIndex {
9    Slot0,
10    Slot1,
11}
12
13impl CommitSlotIndex {
14    const fn opposite(self) -> Self {
15        match self {
16            Self::Slot0 => Self::Slot1,
17            Self::Slot1 => Self::Slot0,
18        }
19    }
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23struct AuthoritativeSlot<'slot> {
24    index: CommitSlotIndex,
25    record: &'slot CommittedGenerationBytes,
26}
27
28fn select_authoritative_slot<'slot>(
29    slot0: Option<&'slot CommittedGenerationBytes>,
30    slot1: Option<&'slot CommittedGenerationBytes>,
31) -> Result<AuthoritativeSlot<'slot>, CommitRecoveryError> {
32    let slot0 = slot0
33        .filter(|slot| slot.validates())
34        .map(|record| AuthoritativeSlot {
35            index: CommitSlotIndex::Slot0,
36            record,
37        });
38    let slot1 = slot1
39        .filter(|slot| slot.validates())
40        .map(|record| AuthoritativeSlot {
41            index: CommitSlotIndex::Slot1,
42            record,
43        });
44
45    match (slot0, slot1) {
46        (Some(left), Some(right))
47            if left.record.generation() == right.record.generation()
48                && left.record != right.record =>
49        {
50            Err(CommitRecoveryError::AmbiguousGeneration {
51                generation: left.record.generation(),
52            })
53        }
54        (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
55            Ok(right)
56        }
57        (Some(left), Some(_) | None) => Ok(left),
58        (None, Some(right)) => Ok(right),
59        (None, None) => Err(CommitRecoveryError::NoValidGeneration),
60    }
61}
62
63///
64/// CommittedGenerationBytes
65///
66/// Committed ledger generation payload protected by a checksum.
67///
68/// This is an advanced low-level DTO for framework or stable-IO owners. Its
69/// recovered bytes are untrusted until marker/checksum validation and ledger
70/// decoding/integrity validation have both succeeded.
71#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
72#[serde(deny_unknown_fields)]
73pub struct CommittedGenerationBytes {
74    /// Generation number represented by this payload.
75    pub(crate) generation: u64,
76    /// Physical commit marker. Readers reject records with an invalid marker.
77    pub(crate) commit_marker: u64,
78    /// Checksum over the generation, marker, and payload bytes.
79    pub(crate) checksum: u64,
80    /// Encoded ledger generation payload.
81    pub(crate) payload: Vec<u8>,
82}
83
84impl CommittedGenerationBytes {
85    /// Build a committed generation record.
86    #[must_use]
87    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
88        let mut record = Self {
89            generation,
90            commit_marker: COMMIT_MARKER,
91            checksum: 0,
92            payload,
93        };
94        record.checksum = generation_checksum(&record);
95        record
96    }
97
98    /// Return the generation number represented by this payload.
99    #[must_use]
100    pub const fn generation(&self) -> u64 {
101        self.generation
102    }
103
104    /// Return the physical commit marker.
105    ///
106    /// This is diagnostic data from a recovered record. Callers should use
107    /// [`CommittedGenerationBytes::validates`] before treating the record as
108    /// authoritative.
109    #[must_use]
110    pub const fn commit_marker(&self) -> u64 {
111        self.commit_marker
112    }
113
114    /// Return the checksum over the generation, marker, and payload bytes.
115    ///
116    /// The checksum is non-cryptographic and detects accidental corruption
117    /// only.
118    #[must_use]
119    pub const fn checksum(&self) -> u64 {
120        self.checksum
121    }
122
123    /// Borrow the encoded ledger generation payload.
124    #[must_use]
125    pub fn payload(&self) -> &[u8] {
126        &self.payload
127    }
128
129    /// Return whether the marker and checksum validate.
130    #[must_use]
131    pub fn validates(&self) -> bool {
132        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
133    }
134}
135
136///
137/// DualCommitStore
138///
139/// Redundant commit store for encoded ledger generations.
140///
141/// This is an advanced low-level API for framework or stable-IO owners. Most
142/// applications should recover, validate, and commit through the allocation
143/// ledger flow rather than manipulating encoded physical commit slots directly.
144///
145/// Writers stage a complete generation record into the inactive slot. Readers
146/// recover by selecting the highest-generation valid slot. If the enclosing
147/// record remains decodable, a corrupt newer slot cannot override an older
148/// valid slot.
149///
150/// In the default runtime both slots are serialized together inside one
151/// `ic-stable-structures::Cell`; they are not independently atomic physical
152/// writes. ICP message execution supplies atomic stable-memory commit and
153/// rollback. The checksum is for accidental-corruption detection only. It is
154/// not a cryptographic hash and does not provide adversarial tamper resistance.
155#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
156#[serde(deny_unknown_fields)]
157pub struct DualCommitStore {
158    /// First commit slot.
159    pub(crate) slot0: Option<CommittedGenerationBytes>,
160    /// Second commit slot.
161    pub(crate) slot1: Option<CommittedGenerationBytes>,
162}
163
164impl DualCommitStore {
165    /// Return true when no commit slot has ever been written.
166    #[must_use]
167    pub const fn is_uninitialized(&self) -> bool {
168        self.slot0.is_none() && self.slot1.is_none()
169    }
170
171    /// Borrow the first commit slot.
172    ///
173    /// Slot records are untrusted recovered state until recovery selects an
174    /// authoritative generation.
175    #[must_use]
176    pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
177        self.slot0.as_ref()
178    }
179
180    /// Borrow the second commit slot.
181    ///
182    /// Slot records are untrusted recovered state until recovery selects an
183    /// authoritative generation.
184    #[must_use]
185    pub const fn slot1(&self) -> Option<&CommittedGenerationBytes> {
186        self.slot1.as_ref()
187    }
188
189    fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_>, CommitRecoveryError> {
190        select_authoritative_slot(self.slot0(), self.slot1())
191    }
192
193    fn inactive_slot_index(&self) -> CommitSlotIndex {
194        match self.authoritative_slot() {
195            Ok(authoritative) => authoritative.index.opposite(),
196            Err(_) => CommitSlotIndex::Slot0,
197        }
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 record is the new authoritative in-memory slot. The owner
215    /// remains responsible for persisting the enclosing store.
216    pub fn commit_payload(
217        &mut self,
218        payload: Vec<u8>,
219    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
220        let next_generation =
221            match self.authoritative() {
222                Ok(record) => record.generation.checked_add(1).ok_or(
223                    CommitRecoveryError::GenerationOverflow {
224                        generation: record.generation,
225                    },
226                )?,
227                Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
228                Err(err) => return Err(err),
229            };
230
231        self.commit_payload_at_generation(next_generation, payload)
232    }
233
234    /// Commit `payload` as an explicitly numbered physical generation.
235    ///
236    /// This is the low-level physical-slot primitive used by
237    /// [`crate::LedgerCommitStore`]. Normal ledger commits should use
238    /// [`crate::LedgerCommitStore::commit`] or [`crate::AllocationBootstrap`] so
239    /// payloads are decoded, current-format checked, and integrity-validated
240    /// before they can become authoritative.
241    ///
242    /// The commit-slot generation is checked against the recovered
243    /// predecessor. This method does not inspect `payload`.
244    pub fn commit_payload_at_generation(
245        &mut self,
246        generation: u64,
247        payload: Vec<u8>,
248    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
249        match self.authoritative() {
250            Ok(record) => {
251                let expected = record.generation.checked_add(1).ok_or(
252                    CommitRecoveryError::GenerationOverflow {
253                        generation: record.generation,
254                    },
255                )?;
256                if generation != expected {
257                    return Err(CommitRecoveryError::UnexpectedGeneration {
258                        expected,
259                        actual: generation,
260                    });
261                }
262            }
263            Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
264            Err(err) => return Err(err),
265        }
266
267        let next = CommittedGenerationBytes::new(generation, payload);
268
269        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
270            self.slot0 = Some(next);
271        } else {
272            self.slot1 = Some(next);
273        }
274
275        self.authoritative()
276    }
277
278    /// Simulate corruption in the inactive slot.
279    ///
280    /// This helper is intentionally part of the model because recovery behavior
281    /// is an ABI requirement, not an implementation detail.
282    #[cfg(test)]
283    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
284        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
285        corrupt.checksum = corrupt.checksum.wrapping_add(1);
286
287        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
288            self.slot0 = Some(corrupt);
289        } else {
290            self.slot1 = Some(corrupt);
291        }
292    }
293}
294
295///
296/// CommitStoreDiagnostic
297///
298/// Read-only diagnostic summary of protected commit recovery state.
299#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
300#[serde(deny_unknown_fields)]
301pub struct CommitStoreDiagnostic {
302    /// First physical commit slot diagnostic.
303    pub slot0: CommitSlotDiagnostic,
304    /// Second physical commit slot diagnostic.
305    pub slot1: CommitSlotDiagnostic,
306    /// Highest valid generation selected by recovery.
307    pub authoritative_generation: Option<u64>,
308    /// Recovery error when no authoritative generation can be selected.
309    pub recovery_error: Option<CommitRecoveryError>,
310}
311
312impl CommitStoreDiagnostic {
313    /// Build a read-only recovery diagnostic from a dual commit store.
314    #[must_use]
315    pub fn from_store(store: &DualCommitStore) -> Self {
316        let (authoritative_generation, recovery_error) = match store.authoritative_slot() {
317            Ok(slot) => (Some(slot.record.generation()), None),
318            Err(err) => (None, Some(err)),
319        };
320        Self {
321            slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
322            slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
323            authoritative_generation,
324            recovery_error,
325        }
326    }
327}
328
329///
330/// CommitSlotDiagnostic
331///
332/// Read-only diagnostic summary for one protected commit slot.
333#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
334#[serde(deny_unknown_fields)]
335pub struct CommitSlotDiagnostic {
336    /// Whether a physical slot record is present.
337    pub present: bool,
338    /// Generation encoded by the slot, if present.
339    pub generation: Option<u64>,
340    /// Whether marker and checksum validation succeeded.
341    pub valid: bool,
342}
343
344impl CommitSlotDiagnostic {
345    fn from_slot(slot: Option<&CommittedGenerationBytes>) -> Self {
346        match slot {
347            Some(record) => Self {
348                present: true,
349                generation: Some(record.generation()),
350                valid: record.validates(),
351            },
352            None => Self {
353                present: false,
354                generation: None,
355                valid: false,
356            },
357        }
358    }
359}
360
361///
362/// CommitRecoveryError
363///
364/// Protected commit recovery failure.
365#[non_exhaustive]
366#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
367pub enum CommitRecoveryError {
368    /// No committed slot passed marker and checksum validation.
369    #[error("no valid committed ledger generation")]
370    NoValidGeneration,
371    /// Both commit slots validated at the same generation but contained different bytes.
372    #[error("ambiguous committed ledger generation {generation}")]
373    AmbiguousGeneration {
374        /// Ambiguous physical generation.
375        generation: u64,
376    },
377    /// Physical generation advancement would overflow.
378    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
379    GenerationOverflow {
380        /// Last valid physical generation.
381        generation: u64,
382    },
383    /// Caller attempted to commit a physical generation other than the next generation.
384    #[error("expected committed ledger generation {expected}, got {actual}")]
385    UnexpectedGeneration {
386        /// Expected next physical generation.
387        expected: u64,
388        /// Actual requested physical generation.
389        actual: u64,
390    },
391}
392
393fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
394    let mut hash = FNV_OFFSET;
395    hash = hash_u64(hash, generation.generation);
396    hash = hash_u64(hash, generation.commit_marker);
397    hash = hash_usize(hash, generation.payload.len());
398    for byte in &generation.payload {
399        hash = hash_byte(hash, *byte);
400    }
401    hash
402}
403
404fn hash_usize(hash: u64, value: usize) -> u64 {
405    hash_u64(hash, value as u64)
406}
407
408fn hash_u64(mut hash: u64, value: u64) -> u64 {
409    for byte in value.to_le_bytes() {
410        hash = hash_byte(hash, byte);
411    }
412    hash
413}
414
415const fn hash_byte(hash: u64, byte: u8) -> u64 {
416    (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    fn payload(value: u8) -> Vec<u8> {
424        vec![value; 4]
425    }
426
427    #[test]
428    fn committed_generation_validates_marker_and_checksum() {
429        let mut generation = CommittedGenerationBytes::new(7, payload(1));
430        assert!(generation.validates());
431
432        generation.checksum = generation.checksum.wrapping_add(1);
433        assert!(!generation.validates());
434    }
435
436    #[test]
437    fn physical_commit_accessors_expose_read_only_state() {
438        let mut store = DualCommitStore::default();
439        store.commit_payload(payload(1)).expect("first commit");
440
441        let slot = store.slot0().expect("first slot");
442
443        assert_eq!(slot.generation(), 0);
444        assert_eq!(slot.payload(), payload(1).as_slice());
445        assert_eq!(slot.commit_marker(), COMMIT_MARKER);
446        assert_eq!(slot.checksum(), generation_checksum(slot));
447        assert!(store.slot1().is_none());
448    }
449
450    #[test]
451    fn authoritative_selects_highest_valid_generation() {
452        let mut store = DualCommitStore::default();
453        store.commit_payload(payload(1)).expect("first commit");
454        store.commit_payload(payload(2)).expect("second commit");
455
456        let authoritative = store.authoritative().expect("authoritative");
457        let authoritative_slot =
458            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
459                .expect("authoritative slot");
460
461        assert_eq!(authoritative.generation, 1);
462        assert_eq!(authoritative.payload, payload(2));
463        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
464        assert_eq!(authoritative_slot.record.payload, payload(2));
465    }
466
467    #[test]
468    fn corrupt_newer_slot_leaves_prior_generation_authoritative() {
469        let mut store = DualCommitStore::default();
470        store.commit_payload(payload(1)).expect("first commit");
471        store.write_corrupt_inactive_slot(1, payload(2));
472
473        let authoritative = store.authoritative().expect("authoritative");
474
475        assert_eq!(authoritative.generation, 0);
476        assert_eq!(authoritative.payload, payload(1));
477    }
478
479    #[test]
480    fn no_valid_generation_fails_closed() {
481        let mut store = DualCommitStore::default();
482        store.write_corrupt_inactive_slot(0, payload(1));
483        store.write_corrupt_inactive_slot(1, payload(2));
484
485        let err = store.authoritative().expect_err("no valid slot");
486
487        assert_eq!(err, CommitRecoveryError::NoValidGeneration);
488    }
489
490    #[test]
491    fn same_generation_identical_slots_recover_deterministically() {
492        let committed = CommittedGenerationBytes::new(7, payload(1));
493        let store = DualCommitStore {
494            slot0: Some(committed.clone()),
495            slot1: Some(committed),
496        };
497
498        let authoritative = store.authoritative_slot().expect("authoritative");
499
500        assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
501        assert_eq!(authoritative.record.generation, 7);
502    }
503
504    #[test]
505    fn same_generation_divergent_slots_fail_closed() {
506        let store = DualCommitStore {
507            slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
508            slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
509        };
510
511        let err = store.authoritative().expect_err("ambiguous generation");
512
513        assert_eq!(
514            err,
515            CommitRecoveryError::AmbiguousGeneration { generation: 7 }
516        );
517    }
518
519    #[test]
520    fn physical_generation_overflow_fails_closed() {
521        let mut store = DualCommitStore {
522            slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
523            slot1: None,
524        };
525
526        let err = store
527            .commit_payload(payload(2))
528            .expect_err("overflow must fail");
529
530        assert_eq!(
531            err,
532            CommitRecoveryError::GenerationOverflow {
533                generation: u64::MAX
534            }
535        );
536    }
537
538    #[test]
539    fn diagnostic_reports_authoritative_generation_and_corrupt_slots() {
540        let mut store = DualCommitStore::default();
541        store.commit_payload(payload(1)).expect("first commit");
542        store.write_corrupt_inactive_slot(1, payload(2));
543
544        let diagnostic = store.diagnostic();
545
546        assert_eq!(diagnostic.authoritative_generation, Some(0));
547        assert_eq!(diagnostic.recovery_error, None);
548        assert_eq!(diagnostic.slot0.generation, Some(0));
549        assert!(diagnostic.slot0.valid);
550        assert_eq!(diagnostic.slot1.generation, Some(1));
551        assert!(!diagnostic.slot1.valid);
552    }
553
554    #[test]
555    fn diagnostic_reports_no_valid_generation_for_empty_store() {
556        let diagnostic = DualCommitStore::default().diagnostic();
557
558        assert_eq!(diagnostic.authoritative_generation, None);
559        assert_eq!(
560            diagnostic.recovery_error,
561            Some(CommitRecoveryError::NoValidGeneration)
562        );
563        assert!(!diagnostic.slot0.present);
564        assert!(!diagnostic.slot1.present);
565    }
566
567    #[test]
568    fn uninitialized_distinguishes_empty_from_corrupt() {
569        let mut store = DualCommitStore::default();
570        assert!(store.is_uninitialized());
571
572        store.write_corrupt_inactive_slot(0, payload(1));
573
574        assert!(!store.is_uninitialized());
575    }
576
577    #[test]
578    fn commit_after_corrupt_slot_advances_from_prior_valid_generation() {
579        let mut store = DualCommitStore::default();
580        store.commit_payload(payload(1)).expect("first commit");
581        store.write_corrupt_inactive_slot(1, payload(2));
582        store.commit_payload(payload(3)).expect("third commit");
583
584        let authoritative = store.authoritative().expect("authoritative");
585
586        assert_eq!(authoritative.generation, 1);
587        assert_eq!(authoritative.payload, payload(3));
588    }
589}