Skip to main content

ic_memory/
physical.rs

1use crate::hash::{FNV_OFFSET, fnv64};
2use serde::{Deserialize, Serialize};
3
4const COMMIT_MARKER: u64 = 0x4943_4D45_4D43_4F4D;
5
6#[derive(Clone, Copy, Debug, Eq, PartialEq)]
7enum CommitSlotIndex {
8    Slot0,
9    Slot1,
10}
11
12impl CommitSlotIndex {
13    const fn opposite(self) -> Self {
14        match self {
15            Self::Slot0 => Self::Slot1,
16            Self::Slot1 => Self::Slot0,
17        }
18    }
19}
20
21#[derive(Clone, Copy, Debug, Eq, PartialEq)]
22struct AuthoritativeSlot<'slot> {
23    index: CommitSlotIndex,
24    record: &'slot CommittedGenerationBytes,
25}
26
27fn select_authoritative_slot<'slot>(
28    slot0: Option<&'slot CommittedGenerationBytes>,
29    slot1: Option<&'slot CommittedGenerationBytes>,
30) -> Result<AuthoritativeSlot<'slot>, CommitRecoveryError> {
31    let slot0_invalid = slot0.is_some_and(|slot| !slot.validates());
32    let slot1_invalid = slot1.is_some_and(|slot| !slot.validates());
33    if slot0_invalid || slot1_invalid {
34        return Err(CommitRecoveryError::InvalidCommitSlots {
35            slot0_invalid,
36            slot1_invalid,
37        });
38    }
39
40    let slot0 = slot0.map(|record| AuthoritativeSlot {
41        index: CommitSlotIndex::Slot0,
42        record,
43    });
44    let slot1 = slot1.map(|record| AuthoritativeSlot {
45        index: CommitSlotIndex::Slot1,
46        record,
47    });
48
49    match (slot0, slot1) {
50        (Some(left), Some(right))
51            if left.record.generation() == right.record.generation()
52                && left.record != right.record =>
53        {
54            Err(CommitRecoveryError::AmbiguousGeneration {
55                generation: left.record.generation(),
56            })
57        }
58        (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
59            Ok(right)
60        }
61        (Some(left), Some(_) | None) => Ok(left),
62        (None, Some(right)) => Ok(right),
63        (None, None) => Err(CommitRecoveryError::NoValidGeneration),
64    }
65}
66
67///
68/// CommittedGenerationBytes
69///
70/// Committed ledger generation payload protected by a checksum.
71///
72/// This is an advanced low-level DTO for framework or stable-IO owners. Its
73/// recovered bytes are untrusted until marker/checksum validation and ledger
74/// decoding/integrity validation have both succeeded.
75/// Binary serde formats encode the payload as a bounded byte string; human-readable
76/// formats retain an array of bytes. Direct serde decoding is a DTO operation:
77/// maintained durable readers additionally preflight CBOR before allocation.
78///
79
80#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[serde(deny_unknown_fields)]
82pub struct CommittedGenerationBytes {
83    /// Generation number represented by this payload.
84    pub(crate) generation: u64,
85    /// Physical commit marker. Readers reject records with an invalid marker.
86    pub(crate) commit_marker: u64,
87    /// Checksum over the generation, marker, and payload bytes.
88    pub(crate) checksum: u64,
89    /// Encoded ledger generation payload.
90    #[serde(with = "opaque_payload")]
91    pub(crate) payload: Vec<u8>,
92}
93
94// The binary representation belongs to the persisted codec. Human-readable DTOs
95// continue to round-trip byte arrays without accepting arrays in durable CBOR.
96mod opaque_payload {
97    use crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
98    use serde::{Deserializer, Serialize, Serializer, de::Visitor};
99
100    pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
101        if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
102            return Err(serde::ser::Error::custom(
103                "ledger byte string exceeds payload bound",
104            ));
105        }
106        if serializer.is_human_readable() {
107            bytes.serialize(serializer)
108        } else {
109            serializer.serialize_bytes(bytes)
110        }
111    }
112
113    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
114        struct Bytes;
115        impl Visitor<'_> for Bytes {
116            type Value = Vec<u8>;
117
118            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119                f.write_str("a bounded opaque ledger byte string")
120            }
121
122            fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
123                if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
124                    return Err(E::custom("ledger byte string exceeds payload bound"));
125                }
126                Ok(bytes.to_vec())
127            }
128
129            fn visit_byte_buf<E: serde::de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> {
130                if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
131                    return Err(E::custom("ledger byte string exceeds payload bound"));
132                }
133                Ok(bytes)
134            }
135        }
136        if deserializer.is_human_readable() {
137            return crate::cbor::deserialize_bounded_vec::<D, u8, MAX_COMMITTED_PAYLOAD_BYTES>(
138                deserializer,
139            );
140        }
141        deserializer.deserialize_byte_buf(Bytes)
142    }
143}
144
145impl CommittedGenerationBytes {
146    /// Build a committed generation record.
147    #[must_use]
148    pub fn new(generation: u64, payload: Vec<u8>) -> Self {
149        let mut record = Self {
150            generation,
151            commit_marker: COMMIT_MARKER,
152            checksum: 0,
153            payload,
154        };
155        record.checksum = generation_checksum(&record);
156        record
157    }
158
159    /// Return the generation number represented by this payload.
160    #[must_use]
161    pub const fn generation(&self) -> u64 {
162        self.generation
163    }
164
165    /// Return the physical commit marker.
166    ///
167    /// This is diagnostic data from a recovered record. Callers should use
168    /// [`CommittedGenerationBytes::validates`] before treating the record as
169    /// authoritative.
170    #[must_use]
171    pub const fn commit_marker(&self) -> u64 {
172        self.commit_marker
173    }
174
175    /// Return the checksum over the generation, marker, and payload bytes.
176    ///
177    /// The checksum is non-cryptographic and detects accidental corruption
178    /// only.
179    #[must_use]
180    pub const fn checksum(&self) -> u64 {
181        self.checksum
182    }
183
184    /// Borrow the encoded ledger generation payload.
185    #[must_use]
186    pub fn payload(&self) -> &[u8] {
187        &self.payload
188    }
189
190    /// Return whether the marker and checksum validate.
191    #[must_use]
192    pub fn validates(&self) -> bool {
193        self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
194    }
195}
196
197///
198/// DualCommitStore
199///
200/// Redundant commit store for encoded ledger generations.
201///
202/// This is an advanced low-level API for framework or stable-IO owners. Most
203/// applications should recover, validate, and commit through the allocation
204/// ledger flow rather than manipulating encoded physical commit slots directly.
205///
206/// Writers stage a complete generation record into the inactive slot. Readers
207/// recover by selecting the highest-generation slot after every present slot
208/// passes marker and checksum validation. Any present invalid slot fails
209/// closed; recovery never rolls durable allocation history back to an older
210/// generation.
211///
212/// In the default runtime both slots are serialized together inside one
213/// `ic-stable-structures::Cell`; they are not independently atomic physical
214/// writes. ICP message execution supplies atomic stable-memory commit and
215/// rollback. The checksum is for accidental-corruption detection only. It is
216/// not a cryptographic hash and does not provide adversarial tamper resistance.
217///
218
219#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
220#[serde(deny_unknown_fields)]
221pub struct DualCommitStore {
222    /// First commit slot.
223    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
224    pub(crate) slot0: Option<CommittedGenerationBytes>,
225    /// Second commit slot.
226    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
227    pub(crate) slot1: Option<CommittedGenerationBytes>,
228}
229
230impl DualCommitStore {
231    /// Return true when no commit slot has ever been written.
232    #[must_use]
233    pub const fn is_uninitialized(&self) -> bool {
234        self.slot0.is_none() && self.slot1.is_none()
235    }
236
237    /// Borrow the first commit slot.
238    ///
239    /// Slot records are untrusted recovered state until recovery selects an
240    /// authoritative generation.
241    #[must_use]
242    pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
243        self.slot0.as_ref()
244    }
245
246    /// Borrow the second commit slot.
247    ///
248    /// Slot records are untrusted recovered state until recovery selects an
249    /// authoritative generation.
250    #[must_use]
251    pub const fn slot1(&self) -> Option<&CommittedGenerationBytes> {
252        self.slot1.as_ref()
253    }
254
255    fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_>, CommitRecoveryError> {
256        select_authoritative_slot(self.slot0(), self.slot1())
257    }
258
259    #[cfg(test)]
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    pub(crate) fn authoritative_with_diagnostic(
275        &self,
276    ) -> (
277        Result<&CommittedGenerationBytes, CommitRecoveryError>,
278        CommitStoreDiagnostic,
279    ) {
280        let recovery = self.authoritative_slot();
281        let diagnostic = CommitStoreDiagnostic::from_recovery(self, &recovery);
282        (recovery.map(|slot| slot.record), diagnostic)
283    }
284
285    /// Build a read-only recovery diagnostic for the protected commit slots.
286    #[must_use]
287    pub fn diagnostic(&self) -> CommitStoreDiagnostic {
288        CommitStoreDiagnostic::from_store(self)
289    }
290
291    /// Commit a new payload to the inactive slot.
292    ///
293    /// The returned record is the new authoritative in-memory slot. The owner
294    /// remains responsible for persisting the enclosing store. Payloads exceeding
295    /// the current serialized byte ceiling are rejected without changing slots.
296    pub fn commit_payload(
297        &mut self,
298        payload: Vec<u8>,
299    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
300        self.commit_payload_with_generation(None, payload)
301    }
302
303    /// Commit `payload` as an explicitly numbered physical generation.
304    ///
305    /// This is the low-level physical-slot primitive used by
306    /// [`crate::LedgerCommitStore`]. Normal ledger commits should use
307    /// [`crate::LedgerCommitStore::commit`] or [`crate::AllocationBootstrap`] so
308    /// payloads are decoded, current-format checked, and integrity-validated
309    /// before they can become authoritative.
310    ///
311    /// The commit-slot generation is checked against the recovered
312    /// predecessor. This method bounds the payload length without decoding it.
313    pub fn commit_payload_at_generation(
314        &mut self,
315        generation: u64,
316        payload: Vec<u8>,
317    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
318        self.commit_payload_with_generation(Some(generation), payload)
319    }
320
321    fn commit_payload_with_generation(
322        &mut self,
323        requested: Option<u64>,
324        payload: Vec<u8>,
325    ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
326        if payload.len() > crate::constants::MAX_COMMITTED_PAYLOAD_BYTES {
327            return Err(CommitRecoveryError::PayloadTooLarge {
328                len: payload.len(),
329                limit: crate::constants::MAX_COMMITTED_PAYLOAD_BYTES,
330            });
331        }
332        // Validate every predecessor once before mutation, and reuse its slot.
333        let (index, generation) = match self.authoritative_slot() {
334            Ok(authoritative) => {
335                let expected = authoritative.record.generation.checked_add(1).ok_or(
336                    CommitRecoveryError::GenerationOverflow {
337                        generation: authoritative.record.generation,
338                    },
339                )?;
340                let generation = requested.unwrap_or(expected);
341                if generation != expected {
342                    return Err(CommitRecoveryError::UnexpectedGeneration {
343                        expected,
344                        actual: generation,
345                    });
346                }
347                (authoritative.index.opposite(), generation)
348            }
349            // Slot selection reports this only for two absent slots.
350            Err(CommitRecoveryError::NoValidGeneration) => {
351                (CommitSlotIndex::Slot0, requested.unwrap_or(0))
352            }
353            Err(err) => return Err(err),
354        };
355        let slot = match index {
356            CommitSlotIndex::Slot0 => &mut self.slot0,
357            CommitSlotIndex::Slot1 => &mut self.slot1,
358        };
359        // Construction computes the current marker/checksum. No second scan of
360        // the unchanged predecessor or newly constructed payload is needed.
361        Ok(slot.insert(CommittedGenerationBytes::new(generation, payload)))
362    }
363
364    /// Simulate corruption in the inactive slot.
365    ///
366    /// This helper is intentionally part of the model because recovery behavior
367    /// is an ABI requirement, not an implementation detail.
368    #[cfg(test)]
369    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
370        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
371        corrupt.checksum = corrupt.checksum.wrapping_add(1);
372
373        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
374            self.slot0 = Some(corrupt);
375        } else {
376            self.slot1 = Some(corrupt);
377        }
378    }
379}
380
381///
382/// CommitStoreDiagnostic
383///
384/// Read-only diagnostic summary of protected commit recovery state.
385///
386
387#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
388#[serde(deny_unknown_fields)]
389pub struct CommitStoreDiagnostic {
390    /// First physical commit slot diagnostic.
391    pub slot0: CommitSlotDiagnostic,
392    /// Second physical commit slot diagnostic.
393    pub slot1: CommitSlotDiagnostic,
394    /// Authoritative generation or the recovery error that prevented selection.
395    pub recovery: Result<u64, CommitRecoveryError>,
396}
397
398impl CommitStoreDiagnostic {
399    /// Build a read-only recovery diagnostic from a dual commit store.
400    #[must_use]
401    pub fn from_store(store: &DualCommitStore) -> Self {
402        let recovery = store.authoritative_slot();
403        Self::from_recovery(store, &recovery)
404    }
405
406    fn from_recovery(
407        store: &DualCommitStore,
408        recovery: &Result<AuthoritativeSlot<'_>, CommitRecoveryError>,
409    ) -> Self {
410        // Selection validates every present slot before considering generations.
411        // Reuse that evidence rather than scanning their payloads again.
412        let (slot0_invalid, slot1_invalid) = match recovery {
413            Err(CommitRecoveryError::InvalidCommitSlots {
414                slot0_invalid,
415                slot1_invalid,
416            }) => (*slot0_invalid, *slot1_invalid),
417            _ => (false, false),
418        };
419        Self {
420            slot0: CommitSlotDiagnostic::from_slot(store.slot0(), slot0_invalid),
421            slot1: CommitSlotDiagnostic::from_slot(store.slot1(), slot1_invalid),
422            recovery: recovery.map(|slot| slot.record.generation()),
423        }
424    }
425}
426
427///
428/// CommitSlotDiagnostic
429///
430/// Read-only diagnostic summary for one protected commit slot.
431///
432
433#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
434#[serde(deny_unknown_fields)]
435pub enum CommitSlotDiagnostic {
436    /// No physical slot record is present.
437    Empty,
438    /// A present slot passed marker and checksum validation.
439    Valid {
440        /// Generation encoded by the valid slot.
441        generation: u64,
442    },
443    /// A present slot failed marker or checksum validation.
444    Invalid {
445        /// Generation encoded by the invalid slot.
446        generation: u64,
447    },
448}
449
450impl CommitSlotDiagnostic {
451    const fn from_slot(slot: Option<&CommittedGenerationBytes>, invalid: bool) -> Self {
452        match slot {
453            Some(record) if !invalid => Self::Valid {
454                generation: record.generation(),
455            },
456            Some(record) => Self::Invalid {
457                generation: record.generation(),
458            },
459            None => Self::Empty,
460        }
461    }
462}
463
464///
465/// CommitRecoveryError
466///
467/// Protected commit recovery failure.
468///
469
470#[non_exhaustive]
471#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
472pub enum CommitRecoveryError {
473    /// A candidate payload exceeds the serialized commit-slot byte ceiling.
474    #[error("committed payload length {len} exceeds byte limit {limit}")]
475    PayloadTooLarge {
476        /// Candidate payload length in bytes.
477        len: usize,
478        /// Maximum serialized payload length in bytes.
479        limit: usize,
480    },
481    /// No committed slot is present.
482    #[error("no committed ledger generation is present")]
483    NoValidGeneration,
484    /// At least one present commit slot failed marker/checksum validation.
485    #[error(
486        "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
487    )]
488    InvalidCommitSlots {
489        /// Whether the first present slot failed validation.
490        slot0_invalid: bool,
491        /// Whether the second present slot failed validation.
492        slot1_invalid: bool,
493    },
494    /// Both commit slots validated at the same generation but contained different bytes.
495    #[error("ambiguous committed ledger generation {generation}")]
496    AmbiguousGeneration {
497        /// Ambiguous physical generation.
498        generation: u64,
499    },
500    /// Physical generation advancement would overflow.
501    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
502    GenerationOverflow {
503        /// Last valid physical generation.
504        generation: u64,
505    },
506    /// Caller attempted to commit a physical generation other than the next generation.
507    #[error("expected committed ledger generation {expected}, got {actual}")]
508    UnexpectedGeneration {
509        /// Expected next physical generation.
510        expected: u64,
511        /// Actual requested physical generation.
512        actual: u64,
513    },
514}
515
516fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
517    let header = [
518        generation.generation.to_le_bytes(),
519        generation.commit_marker.to_le_bytes(),
520        (generation.payload.len() as u64).to_le_bytes(),
521    ];
522    let hash = fnv64(FNV_OFFSET, header.as_flattened());
523    fnv64(hash, &generation.payload)
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn oversized_commits_preserve_empty_and_populated_stores_and_permit_retry() {
532        let limit = crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
533        for populated in [false, true] {
534            let mut store = DualCommitStore::default();
535            if populated {
536                store.commit_payload(payload(1)).unwrap();
537                store.commit_payload(payload(2)).unwrap();
538            }
539            let before = store.clone();
540            let generation = if populated { 2 } else { 0 };
541            for explicit in [false, true] {
542                let oversized = vec![0; limit + 1];
543                let result = if explicit {
544                    store.commit_payload_at_generation(generation, oversized)
545                } else {
546                    store.commit_payload(oversized)
547                };
548                assert_eq!(
549                    result,
550                    Err(CommitRecoveryError::PayloadTooLarge {
551                        len: limit + 1,
552                        limit,
553                    })
554                );
555                assert_eq!(store, before);
556            }
557            assert_eq!(
558                store.commit_payload(payload(3)).unwrap().generation(),
559                generation
560            );
561            let bytes = crate::test_cbor::to_vec(&store).unwrap();
562            let decoded: DualCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
563            assert_eq!(decoded, store);
564        }
565    }
566
567    #[test]
568    fn automatic_and_explicit_commits_preserve_rejected_predecessors() {
569        let mut store = DualCommitStore::default();
570        // An explicit first physical generation can represent an imported baseline.
571        assert_eq!(
572            store
573                .commit_payload_at_generation(7, payload(1))
574                .unwrap()
575                .generation(),
576            7
577        );
578        let before = store.clone();
579        assert_eq!(
580            store.commit_payload_at_generation(9, payload(2)),
581            Err(CommitRecoveryError::UnexpectedGeneration {
582                expected: 8,
583                actual: 9
584            })
585        );
586        assert_eq!(store, before);
587        assert_eq!(store.commit_payload(payload(2)).unwrap().generation(), 8);
588        assert_eq!(store.slot0().unwrap().generation(), 7);
589        assert_eq!(store.slot1().unwrap().generation(), 8);
590        assert_eq!(
591            store
592                .commit_payload_at_generation(9, payload(3))
593                .unwrap()
594                .generation(),
595            9
596        );
597        assert_eq!(store.slot0().unwrap().generation(), 9);
598        assert_eq!(store.slot1().unwrap().generation(), 8);
599
600        store.slot1.as_mut().unwrap().checksum ^= 1;
601        let corrupt = store.clone();
602        for result in [
603            store
604                .commit_payload(payload(4))
605                .map(CommittedGenerationBytes::generation),
606            store
607                .commit_payload_at_generation(10, payload(4))
608                .map(CommittedGenerationBytes::generation),
609        ] {
610            assert_eq!(
611                result,
612                Err(CommitRecoveryError::InvalidCommitSlots {
613                    slot0_invalid: false,
614                    slot1_invalid: true,
615                })
616            );
617        }
618        assert_eq!(store, corrupt);
619    }
620
621    #[test]
622    fn payload_uses_binary_bytes_and_human_readable_arrays() {
623        let record = CommittedGenerationBytes::new(7, vec![0, 24, 255]);
624        let bytes = crate::test_cbor::to_vec(&record).unwrap();
625        let value: ciborium::Value = crate::cbor::from_slice_exact(&bytes).unwrap();
626        let ciborium::Value::Map(mut fields) = value else {
627            panic!("record map")
628        };
629        let (_, payload) = fields
630            .iter_mut()
631            .find(|(key, _)| key.as_text() == Some("payload"))
632            .unwrap();
633        assert_eq!(*payload, ciborium::Value::Bytes(vec![0, 24, 255]));
634        // Removed durable integer-array representations must reject.
635        *payload = ciborium::Value::Array(vec![0.into(), 24.into(), 255.into()]);
636        let removed = crate::test_cbor::to_vec(&ciborium::Value::Map(fields)).unwrap();
637        assert!(crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&removed).is_err());
638        assert_eq!(
639            crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&bytes).unwrap(),
640            record
641        );
642        let json = serde_json::to_value(&record).unwrap();
643        assert_eq!(json["payload"], serde_json::json!([0, 24, 255]));
644        assert_eq!(
645            serde_json::from_value::<CommittedGenerationBytes>(json).unwrap(),
646            record
647        );
648    }
649
650    #[test]
651    fn maximum_payload_writer_reader_agree() {
652        let mut store = DualCommitStore::default();
653        store
654            .commit_payload_at_generation(0, vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES])
655            .unwrap();
656        store
657            .commit_payload(vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES])
658            .unwrap();
659        let bytes = crate::test_cbor::to_vec(&store).unwrap();
660        assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
661        let decoded: DualCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
662        assert_eq!(decoded, store);
663        assert!(decoded.authoritative().is_ok());
664        let oversized = CommittedGenerationBytes::new(
665            0,
666            vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1],
667        );
668        assert!(crate::test_cbor::to_vec(&oversized).is_err());
669    }
670
671    fn payload(value: u8) -> Vec<u8> {
672        vec![value; 4]
673    }
674
675    #[test]
676    fn committed_generation_validates_marker_and_checksum() {
677        let mut generation = CommittedGenerationBytes::new(7, payload(1));
678        assert!(generation.validates());
679
680        generation.checksum = generation.checksum.wrapping_add(1);
681        assert!(!generation.validates());
682    }
683
684    #[test]
685    fn physical_commit_accessors_expose_read_only_state() {
686        let mut store = DualCommitStore::default();
687        store.commit_payload(payload(1)).expect("first commit");
688
689        let slot = store.slot0().expect("first slot");
690
691        assert_eq!(slot.generation(), 0);
692        assert_eq!(slot.payload(), payload(1).as_slice());
693        assert_eq!(slot.commit_marker(), COMMIT_MARKER);
694        assert_eq!(slot.checksum(), generation_checksum(slot));
695        assert!(store.slot1().is_none());
696    }
697
698    #[test]
699    fn authoritative_selects_highest_valid_generation() {
700        let mut store = DualCommitStore::default();
701        store.commit_payload(payload(1)).expect("first commit");
702        store.commit_payload(payload(2)).expect("second commit");
703
704        let authoritative = store.authoritative().expect("authoritative");
705        let authoritative_slot =
706            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
707                .expect("authoritative slot");
708
709        assert_eq!(authoritative.generation, 1);
710        assert_eq!(authoritative.payload, payload(2));
711        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
712        assert_eq!(authoritative_slot.record.payload, payload(2));
713    }
714
715    #[test]
716    fn corrupt_newer_slot_fails_closed() {
717        let mut store = DualCommitStore::default();
718        store.commit_payload(payload(1)).expect("first commit");
719        store.write_corrupt_inactive_slot(1, payload(2));
720
721        let err = store.authoritative().expect_err("corrupt slot");
722
723        assert_eq!(
724            err,
725            CommitRecoveryError::InvalidCommitSlots {
726                slot0_invalid: false,
727                slot1_invalid: true,
728            }
729        );
730    }
731
732    #[test]
733    fn two_invalid_commit_slots_fail_closed() {
734        let mut store = DualCommitStore::default();
735        store.write_corrupt_inactive_slot(0, payload(1));
736        store.write_corrupt_inactive_slot(1, payload(2));
737
738        let err = store.authoritative().expect_err("invalid slots");
739
740        assert_eq!(
741            err,
742            CommitRecoveryError::InvalidCommitSlots {
743                slot0_invalid: true,
744                slot1_invalid: true,
745            }
746        );
747    }
748
749    #[test]
750    fn same_generation_identical_slots_recover_deterministically() {
751        let committed = CommittedGenerationBytes::new(7, payload(1));
752        let store = DualCommitStore {
753            slot0: Some(committed.clone()),
754            slot1: Some(committed),
755        };
756
757        let authoritative = store.authoritative_slot().expect("authoritative");
758
759        assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
760        assert_eq!(authoritative.record.generation, 7);
761    }
762
763    #[test]
764    fn same_generation_divergent_slots_fail_closed() {
765        let store = DualCommitStore {
766            slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
767            slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
768        };
769
770        let err = store.authoritative().expect_err("ambiguous generation");
771
772        assert_eq!(
773            err,
774            CommitRecoveryError::AmbiguousGeneration { generation: 7 }
775        );
776    }
777
778    #[test]
779    fn physical_generation_overflow_fails_closed() {
780        let mut store = DualCommitStore {
781            slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
782            slot1: None,
783        };
784
785        let err = store
786            .commit_payload(payload(2))
787            .expect_err("overflow must fail");
788
789        assert_eq!(
790            err,
791            CommitRecoveryError::GenerationOverflow {
792                generation: u64::MAX
793            }
794        );
795    }
796
797    #[test]
798    fn diagnostic_reports_corrupt_slots_without_an_authoritative_generation() {
799        let mut store = DualCommitStore::default();
800        store.commit_payload(payload(1)).expect("first commit");
801        store.write_corrupt_inactive_slot(1, payload(2));
802
803        let diagnostic = store.diagnostic();
804
805        assert_eq!(
806            diagnostic.recovery,
807            Err(CommitRecoveryError::InvalidCommitSlots {
808                slot0_invalid: false,
809                slot1_invalid: true,
810            })
811        );
812        assert_eq!(
813            diagnostic.slot0,
814            CommitSlotDiagnostic::Valid { generation: 0 }
815        );
816        assert_eq!(
817            diagnostic.slot1,
818            CommitSlotDiagnostic::Invalid { generation: 1 }
819        );
820        let bytes = crate::test_cbor::to_vec(&diagnostic).expect("diagnostic bytes");
821        let decoded: CommitStoreDiagnostic =
822            crate::test_cbor::from_slice(&bytes).expect("diagnostic round trip");
823        assert_eq!(decoded, diagnostic);
824    }
825
826    #[test]
827    fn diagnostic_selection_covers_valid_ties_and_corruption_on_either_slot() {
828        let mut store = DualCommitStore {
829            slot0: None,
830            slot1: Some(CommittedGenerationBytes::new(8, payload(2))),
831        };
832        assert_eq!(store.diagnostic().recovery, Ok(8));
833        assert_eq!(store.diagnostic().slot0, CommitSlotDiagnostic::Empty);
834        store.slot0 = Some(CommittedGenerationBytes::new(7, payload(1)));
835        assert_eq!(store.diagnostic().recovery, Ok(8));
836        store.slot1.clone_from(&store.slot0);
837        assert_eq!(store.diagnostic().recovery, Ok(7));
838
839        store.slot1 = Some(CommittedGenerationBytes::new(7, payload(2)));
840        let diagnostic = store.diagnostic();
841        assert_eq!(
842            diagnostic.recovery,
843            Err(CommitRecoveryError::AmbiguousGeneration { generation: 7 })
844        );
845        assert_eq!(
846            diagnostic.slot0,
847            CommitSlotDiagnostic::Valid { generation: 7 }
848        );
849        assert_eq!(
850            diagnostic.slot1,
851            CommitSlotDiagnostic::Valid { generation: 7 }
852        );
853
854        store.slot0.as_mut().unwrap().checksum ^= 1;
855        let diagnostic = store.diagnostic();
856        assert_eq!(
857            diagnostic.recovery,
858            Err(CommitRecoveryError::InvalidCommitSlots {
859                slot0_invalid: true,
860                slot1_invalid: false,
861            })
862        );
863        assert_eq!(
864            diagnostic.slot0,
865            CommitSlotDiagnostic::Invalid { generation: 7 }
866        );
867        assert_eq!(
868            diagnostic.slot1,
869            CommitSlotDiagnostic::Valid { generation: 7 }
870        );
871
872        store.slot1.as_mut().unwrap().commit_marker = 0;
873        let diagnostic = store.diagnostic();
874        assert_eq!(
875            diagnostic.recovery,
876            Err(CommitRecoveryError::InvalidCommitSlots {
877                slot0_invalid: true,
878                slot1_invalid: true,
879            })
880        );
881        assert_eq!(
882            diagnostic.slot0,
883            CommitSlotDiagnostic::Invalid { generation: 7 }
884        );
885        assert_eq!(
886            diagnostic.slot1,
887            CommitSlotDiagnostic::Invalid { generation: 7 }
888        );
889    }
890
891    #[test]
892    fn diagnostic_reports_no_valid_generation_for_empty_store() {
893        let diagnostic = DualCommitStore::default().diagnostic();
894
895        assert_eq!(
896            diagnostic.recovery,
897            Err(CommitRecoveryError::NoValidGeneration)
898        );
899        assert_eq!(diagnostic.slot0, CommitSlotDiagnostic::Empty);
900        assert_eq!(diagnostic.slot1, CommitSlotDiagnostic::Empty);
901    }
902
903    #[test]
904    fn uninitialized_distinguishes_empty_from_corrupt() {
905        let mut store = DualCommitStore::default();
906        assert!(store.is_uninitialized());
907
908        store.write_corrupt_inactive_slot(0, payload(1));
909
910        assert!(!store.is_uninitialized());
911    }
912
913    #[test]
914    fn commit_after_corrupt_slot_fails_closed() {
915        let mut store = DualCommitStore::default();
916        store.commit_payload(payload(1)).expect("first commit");
917        store.write_corrupt_inactive_slot(1, payload(2));
918
919        let err = store
920            .commit_payload(payload(3))
921            .expect_err("corrupt history must not be overwritten");
922
923        assert_eq!(
924            err,
925            CommitRecoveryError::InvalidCommitSlots {
926                slot0_invalid: false,
927                slot1_invalid: true,
928            }
929        );
930    }
931}