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/// Binary serde formats encode the payload as a bounded byte string; human-readable
77/// formats retain an array of bytes. Direct serde decoding is a DTO operation:
78/// maintained durable readers additionally preflight CBOR before allocation.
79///
80
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(deny_unknown_fields)]
83pub struct CommittedGenerationBytes {
84    /// Generation number represented by this payload.
85    pub(crate) generation: u64,
86    /// Physical commit marker. Readers reject records with an invalid marker.
87    pub(crate) commit_marker: u64,
88    /// Checksum over the generation, marker, and payload bytes.
89    pub(crate) checksum: u64,
90    /// Encoded ledger generation payload.
91    #[serde(with = "opaque_payload")]
92    pub(crate) payload: Vec<u8>,
93}
94
95// The binary representation belongs to the persisted codec. Human-readable DTOs
96// continue to round-trip byte arrays without accepting arrays in durable CBOR.
97mod opaque_payload {
98    use crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
99    use serde::{Deserializer, Serialize, Serializer, de::Visitor};
100
101    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
102        if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
103            return Err(serde::ser::Error::custom(
104                "ledger byte string exceeds payload bound",
105            ));
106        }
107        if serializer.is_human_readable() {
108            bytes.serialize(serializer)
109        } else {
110            serializer.serialize_bytes(bytes)
111        }
112    }
113
114    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
115        struct Bytes;
116        impl Visitor<'_> for Bytes {
117            type Value = Vec<u8>;
118
119            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120                f.write_str("a bounded opaque ledger byte string")
121            }
122
123            fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
124                if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
125                    return Err(E::custom("ledger byte string exceeds payload bound"));
126                }
127                Ok(bytes.to_vec())
128            }
129
130            fn visit_byte_buf<E: serde::de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> {
131                if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
132                    return Err(E::custom("ledger byte string exceeds payload bound"));
133                }
134                Ok(bytes)
135            }
136        }
137        if deserializer.is_human_readable() {
138            return crate::cbor::deserialize_bounded_vec::<D, u8, MAX_COMMITTED_PAYLOAD_BYTES>(
139                deserializer,
140            );
141        }
142        deserializer.deserialize_byte_buf(Bytes)
143    }
144}
145
146impl CommittedGenerationBytes {
147    /// Build a committed generation record.
148    #[must_use]
149    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
150        let mut record = Self {
151            generation,
152            commit_marker: COMMIT_MARKER,
153            checksum: 0,
154            payload,
155        };
156        record.checksum = generation_checksum(&record);
157        record
158    }
159
160    /// Return the generation number represented by this payload.
161    #[must_use]
162    pub const fn generation(&self) -> u64 {
163        self.generation
164    }
165
166    /// Return the physical commit marker.
167    ///
168    /// This is diagnostic data from a recovered record. Callers should use
169    /// [`CommittedGenerationBytes::validates`] before treating the record as
170    /// authoritative.
171    #[must_use]
172    pub const fn commit_marker(&self) -> u64 {
173        self.commit_marker
174    }
175
176    /// Return the checksum over the generation, marker, and payload bytes.
177    ///
178    /// The checksum is non-cryptographic and detects accidental corruption
179    /// only.
180    #[must_use]
181    pub const fn checksum(&self) -> u64 {
182        self.checksum
183    }
184
185    /// Borrow the encoded ledger generation payload.
186    #[must_use]
187    pub fn payload(&self) -> &[u8] {
188        &self.payload
189    }
190
191    /// Return whether the marker and checksum validate.
192    #[must_use]
193    pub fn validates(&self) -> bool {
194        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
195    }
196}
197
198///
199/// DualCommitStore
200///
201/// Redundant commit store for encoded ledger generations.
202///
203/// This is an advanced low-level API for framework or stable-IO owners. Most
204/// applications should recover, validate, and commit through the allocation
205/// ledger flow rather than manipulating encoded physical commit slots directly.
206///
207/// Writers stage a complete generation record into the inactive slot. Readers
208/// recover by selecting the highest-generation slot after every present slot
209/// passes marker and checksum validation. Any present invalid slot fails
210/// closed; recovery never rolls durable allocation history back to an older
211/// generation.
212///
213/// In the default runtime both slots are serialized together inside one
214/// `ic-stable-structures::Cell`; they are not independently atomic physical
215/// writes. ICP message execution supplies atomic stable-memory commit and
216/// rollback. The checksum is for accidental-corruption detection only. It is
217/// not a cryptographic hash and does not provide adversarial tamper resistance.
218///
219
220#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
221#[serde(deny_unknown_fields)]
222pub struct DualCommitStore {
223    /// First commit slot.
224    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
225    pub(crate) slot0: Option<CommittedGenerationBytes>,
226    /// Second commit slot.
227    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
228    pub(crate) slot1: Option<CommittedGenerationBytes>,
229}
230
231impl DualCommitStore {
232    /// Return true when no commit slot has ever been written.
233    #[must_use]
234    pub const fn is_uninitialized(&self) -> bool {
235        self.slot0.is_none() && self.slot1.is_none()
236    }
237
238    /// Borrow the first commit slot.
239    ///
240    /// Slot records are untrusted recovered state until recovery selects an
241    /// authoritative generation.
242    #[must_use]
243    pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
244        self.slot0.as_ref()
245    }
246
247    /// Borrow the second commit slot.
248    ///
249    /// Slot records are untrusted recovered state until recovery selects an
250    /// authoritative generation.
251    #[must_use]
252    pub const fn slot1(&self) -> Option<&CommittedGenerationBytes> {
253        self.slot1.as_ref()
254    }
255
256    fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_>, CommitRecoveryError> {
257        select_authoritative_slot(self.slot0(), self.slot1())
258    }
259
260    fn inactive_slot_index(&self) -> CommitSlotIndex {
261        match self.authoritative_slot() {
262            Ok(authoritative) => authoritative.index.opposite(),
263            Err(_) if self.slot0.is_none() => CommitSlotIndex::Slot0,
264            Err(_) => CommitSlotIndex::Slot1,
265        }
266    }
267
268    /// Return the authoritative committed record after validating present slots.
269    pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
270        self.authoritative_slot()
271            .map(|authoritative| authoritative.record)
272    }
273
274    /// Build a read-only recovery diagnostic for the protected commit slots.
275    #[must_use]
276    pub fn diagnostic(&self) -> CommitStoreDiagnostic {
277        CommitStoreDiagnostic::from_store(self)
278    }
279
280    /// Commit a new payload to the inactive slot.
281    ///
282    /// The returned record is the new authoritative in-memory slot. The owner
283    /// remains responsible for persisting the enclosing store.
284    pub fn commit_payload(
285        &mut self,
286        payload: Vec<u8>,
287    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
288        let next_generation =
289            match self.authoritative() {
290                Ok(record) => record.generation.checked_add(1).ok_or(
291                    CommitRecoveryError::GenerationOverflow {
292                        generation: record.generation,
293                    },
294                )?,
295                Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
296                Err(err) => return Err(err),
297            };
298
299        self.commit_payload_at_generation(next_generation, payload)
300    }
301
302    /// Commit `payload` as an explicitly numbered physical generation.
303    ///
304    /// This is the low-level physical-slot primitive used by
305    /// [`crate::LedgerCommitStore`]. Normal ledger commits should use
306    /// [`crate::LedgerCommitStore::commit`] or [`crate::AllocationBootstrap`] so
307    /// payloads are decoded, current-format checked, and integrity-validated
308    /// before they can become authoritative.
309    ///
310    /// The commit-slot generation is checked against the recovered
311    /// predecessor. This method does not inspect `payload`.
312    pub fn commit_payload_at_generation(
313        &mut self,
314        generation: u64,
315        payload: Vec<u8>,
316    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
317        match self.authoritative() {
318            Ok(record) => {
319                let expected = record.generation.checked_add(1).ok_or(
320                    CommitRecoveryError::GenerationOverflow {
321                        generation: record.generation,
322                    },
323                )?;
324                if generation != expected {
325                    return Err(CommitRecoveryError::UnexpectedGeneration {
326                        expected,
327                        actual: generation,
328                    });
329                }
330            }
331            Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
332            Err(err) => return Err(err),
333        }
334
335        let next = CommittedGenerationBytes::new(generation, payload);
336
337        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
338            self.slot0 = Some(next);
339        } else {
340            self.slot1 = Some(next);
341        }
342
343        self.authoritative()
344    }
345
346    /// Simulate corruption in the inactive slot.
347    ///
348    /// This helper is intentionally part of the model because recovery behavior
349    /// is an ABI requirement, not an implementation detail.
350    #[cfg(test)]
351    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
352        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
353        corrupt.checksum = corrupt.checksum.wrapping_add(1);
354
355        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
356            self.slot0 = Some(corrupt);
357        } else {
358            self.slot1 = Some(corrupt);
359        }
360    }
361}
362
363///
364/// CommitStoreDiagnostic
365///
366/// Read-only diagnostic summary of protected commit recovery state.
367///
368
369#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
370#[serde(deny_unknown_fields)]
371pub struct CommitStoreDiagnostic {
372    /// First physical commit slot diagnostic.
373    pub slot0: CommitSlotDiagnostic,
374    /// Second physical commit slot diagnostic.
375    pub slot1: CommitSlotDiagnostic,
376    /// Authoritative generation or the recovery error that prevented selection.
377    pub recovery: Result<u64, CommitRecoveryError>,
378}
379
380impl CommitStoreDiagnostic {
381    /// Build a read-only recovery diagnostic from a dual commit store.
382    #[must_use]
383    pub fn from_store(store: &DualCommitStore) -> Self {
384        Self {
385            slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
386            slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
387            recovery: store
388                .authoritative_slot()
389                .map(|slot| slot.record.generation()),
390        }
391    }
392}
393
394///
395/// CommitSlotDiagnostic
396///
397/// Read-only diagnostic summary for one protected commit slot.
398///
399
400#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
401#[serde(deny_unknown_fields)]
402pub enum CommitSlotDiagnostic {
403    /// No physical slot record is present.
404    Empty,
405    /// A present slot passed marker and checksum validation.
406    Valid {
407        /// Generation encoded by the valid slot.
408        generation: u64,
409    },
410    /// A present slot failed marker or checksum validation.
411    Invalid {
412        /// Generation encoded by the invalid slot.
413        generation: u64,
414    },
415}
416
417impl CommitSlotDiagnostic {
418    fn from_slot(slot: Option<&CommittedGenerationBytes>) -> Self {
419        match slot {
420            Some(record) if record.validates() => Self::Valid {
421                generation: record.generation(),
422            },
423            Some(record) => Self::Invalid {
424                generation: record.generation(),
425            },
426            None => Self::Empty,
427        }
428    }
429}
430
431///
432/// CommitRecoveryError
433///
434/// Protected commit recovery failure.
435///
436
437#[non_exhaustive]
438#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
439pub enum CommitRecoveryError {
440    /// No committed slot is present.
441    #[error("no committed ledger generation is present")]
442    NoValidGeneration,
443    /// At least one present commit slot failed marker/checksum validation.
444    #[error(
445        "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
446    )]
447    InvalidCommitSlots {
448        /// Whether the first present slot failed validation.
449        slot0_invalid: bool,
450        /// Whether the second present slot failed validation.
451        slot1_invalid: bool,
452    },
453    /// Both commit slots validated at the same generation but contained different bytes.
454    #[error("ambiguous committed ledger generation {generation}")]
455    AmbiguousGeneration {
456        /// Ambiguous physical generation.
457        generation: u64,
458    },
459    /// Physical generation advancement would overflow.
460    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
461    GenerationOverflow {
462        /// Last valid physical generation.
463        generation: u64,
464    },
465    /// Caller attempted to commit a physical generation other than the next generation.
466    #[error("expected committed ledger generation {expected}, got {actual}")]
467    UnexpectedGeneration {
468        /// Expected next physical generation.
469        expected: u64,
470        /// Actual requested physical generation.
471        actual: u64,
472    },
473}
474
475fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
476    let mut hash = FNV_OFFSET;
477    hash = hash_u64(hash, generation.generation);
478    hash = hash_u64(hash, generation.commit_marker);
479    hash = hash_usize(hash, generation.payload.len());
480    for byte in &generation.payload {
481        hash = hash_byte(hash, *byte);
482    }
483    hash
484}
485
486fn hash_usize(hash: u64, value: usize) -> u64 {
487    hash_u64(hash, value as u64)
488}
489
490fn hash_u64(mut hash: u64, value: u64) -> u64 {
491    for byte in value.to_le_bytes() {
492        hash = hash_byte(hash, byte);
493    }
494    hash
495}
496
497const fn hash_byte(hash: u64, byte: u8) -> u64 {
498    (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    #[test]
506    fn payload_uses_binary_bytes_and_human_readable_arrays() {
507        let record = CommittedGenerationBytes::new(7, vec![0, 24, 255]);
508        let bytes = crate::test_cbor::to_vec(&record).unwrap();
509        let value: ciborium::Value = crate::cbor::from_slice_exact(&bytes).unwrap();
510        let ciborium::Value::Map(mut fields) = value else {
511            panic!("record map")
512        };
513        let (_, payload) = fields
514            .iter_mut()
515            .find(|(key, _)| key.as_text() == Some("payload"))
516            .unwrap();
517        assert_eq!(*payload, ciborium::Value::Bytes(vec![0, 24, 255]));
518        // Removed durable integer-array representations must reject.
519        *payload = ciborium::Value::Array(vec![0.into(), 24.into(), 255.into()]);
520        let removed = crate::test_cbor::to_vec(&ciborium::Value::Map(fields)).unwrap();
521        assert!(crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&removed).is_err());
522        assert_eq!(
523            crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&bytes).unwrap(),
524            record
525        );
526        let json = serde_json::to_value(&record).unwrap();
527        assert_eq!(json["payload"], serde_json::json!([0, 24, 255]));
528        assert_eq!(
529            serde_json::from_value::<CommittedGenerationBytes>(json).unwrap(),
530            record
531        );
532    }
533
534    #[test]
535    fn maximum_payload_writer_reader_agree() {
536        let record = CommittedGenerationBytes::new(
537            0,
538            vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES],
539        );
540        let store = DualCommitStore {
541            slot0: Some(record.clone()),
542            slot1: Some(record),
543        };
544        let bytes = crate::test_cbor::to_vec(&store).unwrap();
545        assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
546        let decoded: DualCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
547        assert_eq!(decoded, store);
548        assert!(decoded.authoritative().is_ok());
549        let oversized = CommittedGenerationBytes::new(
550            0,
551            vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1],
552        );
553        assert!(crate::test_cbor::to_vec(&oversized).is_err());
554    }
555
556    fn payload(value: u8) -> Vec<u8> {
557        vec![value; 4]
558    }
559
560    #[test]
561    fn committed_generation_validates_marker_and_checksum() {
562        let mut generation = CommittedGenerationBytes::new(7, payload(1));
563        assert!(generation.validates());
564
565        generation.checksum = generation.checksum.wrapping_add(1);
566        assert!(!generation.validates());
567    }
568
569    #[test]
570    fn physical_commit_accessors_expose_read_only_state() {
571        let mut store = DualCommitStore::default();
572        store.commit_payload(payload(1)).expect("first commit");
573
574        let slot = store.slot0().expect("first slot");
575
576        assert_eq!(slot.generation(), 0);
577        assert_eq!(slot.payload(), payload(1).as_slice());
578        assert_eq!(slot.commit_marker(), COMMIT_MARKER);
579        assert_eq!(slot.checksum(), generation_checksum(slot));
580        assert!(store.slot1().is_none());
581    }
582
583    #[test]
584    fn authoritative_selects_highest_valid_generation() {
585        let mut store = DualCommitStore::default();
586        store.commit_payload(payload(1)).expect("first commit");
587        store.commit_payload(payload(2)).expect("second commit");
588
589        let authoritative = store.authoritative().expect("authoritative");
590        let authoritative_slot =
591            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
592                .expect("authoritative slot");
593
594        assert_eq!(authoritative.generation, 1);
595        assert_eq!(authoritative.payload, payload(2));
596        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
597        assert_eq!(authoritative_slot.record.payload, payload(2));
598    }
599
600    #[test]
601    fn corrupt_newer_slot_fails_closed() {
602        let mut store = DualCommitStore::default();
603        store.commit_payload(payload(1)).expect("first commit");
604        store.write_corrupt_inactive_slot(1, payload(2));
605
606        let err = store.authoritative().expect_err("corrupt slot");
607
608        assert_eq!(
609            err,
610            CommitRecoveryError::InvalidCommitSlots {
611                slot0_invalid: false,
612                slot1_invalid: true,
613            }
614        );
615    }
616
617    #[test]
618    fn two_invalid_commit_slots_fail_closed() {
619        let mut store = DualCommitStore::default();
620        store.write_corrupt_inactive_slot(0, payload(1));
621        store.write_corrupt_inactive_slot(1, payload(2));
622
623        let err = store.authoritative().expect_err("invalid slots");
624
625        assert_eq!(
626            err,
627            CommitRecoveryError::InvalidCommitSlots {
628                slot0_invalid: true,
629                slot1_invalid: true,
630            }
631        );
632    }
633
634    #[test]
635    fn same_generation_identical_slots_recover_deterministically() {
636        let committed = CommittedGenerationBytes::new(7, payload(1));
637        let store = DualCommitStore {
638            slot0: Some(committed.clone()),
639            slot1: Some(committed),
640        };
641
642        let authoritative = store.authoritative_slot().expect("authoritative");
643
644        assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
645        assert_eq!(authoritative.record.generation, 7);
646    }
647
648    #[test]
649    fn same_generation_divergent_slots_fail_closed() {
650        let store = DualCommitStore {
651            slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
652            slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
653        };
654
655        let err = store.authoritative().expect_err("ambiguous generation");
656
657        assert_eq!(
658            err,
659            CommitRecoveryError::AmbiguousGeneration { generation: 7 }
660        );
661    }
662
663    #[test]
664    fn physical_generation_overflow_fails_closed() {
665        let mut store = DualCommitStore {
666            slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
667            slot1: None,
668        };
669
670        let err = store
671            .commit_payload(payload(2))
672            .expect_err("overflow must fail");
673
674        assert_eq!(
675            err,
676            CommitRecoveryError::GenerationOverflow {
677                generation: u64::MAX
678            }
679        );
680    }
681
682    #[test]
683    fn diagnostic_reports_corrupt_slots_without_an_authoritative_generation() {
684        let mut store = DualCommitStore::default();
685        store.commit_payload(payload(1)).expect("first commit");
686        store.write_corrupt_inactive_slot(1, payload(2));
687
688        let diagnostic = store.diagnostic();
689
690        assert_eq!(
691            diagnostic.recovery,
692            Err(CommitRecoveryError::InvalidCommitSlots {
693                slot0_invalid: false,
694                slot1_invalid: true,
695            })
696        );
697        assert_eq!(
698            diagnostic.slot0,
699            CommitSlotDiagnostic::Valid { generation: 0 }
700        );
701        assert_eq!(
702            diagnostic.slot1,
703            CommitSlotDiagnostic::Invalid { generation: 1 }
704        );
705        let bytes = crate::test_cbor::to_vec(&diagnostic).expect("diagnostic bytes");
706        let decoded: CommitStoreDiagnostic =
707            crate::test_cbor::from_slice(&bytes).expect("diagnostic round trip");
708        assert_eq!(decoded, diagnostic);
709    }
710
711    #[test]
712    fn diagnostic_reports_no_valid_generation_for_empty_store() {
713        let diagnostic = DualCommitStore::default().diagnostic();
714
715        assert_eq!(
716            diagnostic.recovery,
717            Err(CommitRecoveryError::NoValidGeneration)
718        );
719        assert_eq!(diagnostic.slot0, CommitSlotDiagnostic::Empty);
720        assert_eq!(diagnostic.slot1, CommitSlotDiagnostic::Empty);
721    }
722
723    #[test]
724    fn uninitialized_distinguishes_empty_from_corrupt() {
725        let mut store = DualCommitStore::default();
726        assert!(store.is_uninitialized());
727
728        store.write_corrupt_inactive_slot(0, payload(1));
729
730        assert!(!store.is_uninitialized());
731    }
732
733    #[test]
734    fn commit_after_corrupt_slot_fails_closed() {
735        let mut store = DualCommitStore::default();
736        store.commit_payload(payload(1)).expect("first commit");
737        store.write_corrupt_inactive_slot(1, payload(2));
738
739        let err = store
740            .commit_payload(payload(3))
741            .expect_err("corrupt history must not be overwritten");
742
743        assert_eq!(
744            err,
745            CommitRecoveryError::InvalidCommitSlots {
746                slot0_invalid: false,
747                slot1_invalid: true,
748            }
749        );
750    }
751}