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