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            Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {
350                (CommitSlotIndex::Slot0, requested.unwrap_or(0))
351            }
352            Err(err) => return Err(err),
353        };
354        let slot = match index {
355            CommitSlotIndex::Slot0 => &mut self.slot0,
356            CommitSlotIndex::Slot1 => &mut self.slot1,
357        };
358        // Construction computes the current marker/checksum. No second scan of
359        // the unchanged predecessor or newly constructed payload is needed.
360        Ok(slot.insert(CommittedGenerationBytes::new(generation, payload)))
361    }
362
363    /// Simulate corruption in the inactive slot.
364    ///
365    /// This helper is intentionally part of the model because recovery behavior
366    /// is an ABI requirement, not an implementation detail.
367    #[cfg(test)]
368    pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
369        let mut corrupt = CommittedGenerationBytes::new(generation, payload);
370        corrupt.checksum = corrupt.checksum.wrapping_add(1);
371
372        if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
373            self.slot0 = Some(corrupt);
374        } else {
375            self.slot1 = Some(corrupt);
376        }
377    }
378}
379
380///
381/// CommitStoreDiagnostic
382///
383/// Read-only diagnostic summary of protected commit recovery state.
384///
385
386#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
387#[serde(deny_unknown_fields)]
388pub struct CommitStoreDiagnostic {
389    /// First physical commit slot diagnostic.
390    pub slot0: CommitSlotDiagnostic,
391    /// Second physical commit slot diagnostic.
392    pub slot1: CommitSlotDiagnostic,
393    /// Authoritative generation or the recovery error that prevented selection.
394    pub recovery: Result<u64, CommitRecoveryError>,
395}
396
397impl CommitStoreDiagnostic {
398    /// Build a read-only recovery diagnostic from a dual commit store.
399    #[must_use]
400    pub fn from_store(store: &DualCommitStore) -> Self {
401        let recovery = store.authoritative_slot();
402        Self::from_recovery(store, &recovery)
403    }
404
405    fn from_recovery(
406        store: &DualCommitStore,
407        recovery: &Result<AuthoritativeSlot<'_>, CommitRecoveryError>,
408    ) -> Self {
409        // Selection validates every present slot before considering generations.
410        // Reuse that evidence rather than scanning their payloads again.
411        let (slot0_invalid, slot1_invalid) = match recovery {
412            Err(CommitRecoveryError::InvalidCommitSlots {
413                slot0_invalid,
414                slot1_invalid,
415            }) => (*slot0_invalid, *slot1_invalid),
416            _ => (false, false),
417        };
418        Self {
419            slot0: CommitSlotDiagnostic::from_slot(store.slot0(), slot0_invalid),
420            slot1: CommitSlotDiagnostic::from_slot(store.slot1(), slot1_invalid),
421            recovery: recovery.map(|slot| slot.record.generation()),
422        }
423    }
424}
425
426///
427/// CommitSlotDiagnostic
428///
429/// Read-only diagnostic summary for one protected commit slot.
430///
431
432#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
433#[serde(deny_unknown_fields)]
434pub enum CommitSlotDiagnostic {
435    /// No physical slot record is present.
436    Empty,
437    /// A present slot passed marker and checksum validation.
438    Valid {
439        /// Generation encoded by the valid slot.
440        generation: u64,
441    },
442    /// A present slot failed marker or checksum validation.
443    Invalid {
444        /// Generation encoded by the invalid slot.
445        generation: u64,
446    },
447}
448
449impl CommitSlotDiagnostic {
450    const fn from_slot(slot: Option<&CommittedGenerationBytes>, invalid: bool) -> Self {
451        match slot {
452            Some(record) if !invalid => Self::Valid {
453                generation: record.generation(),
454            },
455            Some(record) => Self::Invalid {
456                generation: record.generation(),
457            },
458            None => Self::Empty,
459        }
460    }
461}
462
463///
464/// CommitRecoveryError
465///
466/// Protected commit recovery failure.
467///
468
469#[non_exhaustive]
470#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
471pub enum CommitRecoveryError {
472    /// A candidate payload exceeds the serialized commit-slot byte ceiling.
473    #[error("committed payload length {len} exceeds byte limit {limit}")]
474    PayloadTooLarge {
475        /// Candidate payload length in bytes.
476        len: usize,
477        /// Maximum serialized payload length in bytes.
478        limit: usize,
479    },
480    /// No committed slot is present.
481    #[error("no committed ledger generation is present")]
482    NoValidGeneration,
483    /// At least one present commit slot failed marker/checksum validation.
484    #[error(
485        "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
486    )]
487    InvalidCommitSlots {
488        /// Whether the first present slot failed validation.
489        slot0_invalid: bool,
490        /// Whether the second present slot failed validation.
491        slot1_invalid: bool,
492    },
493    /// Both commit slots validated at the same generation but contained different bytes.
494    #[error("ambiguous committed ledger generation {generation}")]
495    AmbiguousGeneration {
496        /// Ambiguous physical generation.
497        generation: u64,
498    },
499    /// Physical generation advancement would overflow.
500    #[error("committed ledger generation {generation} cannot be advanced without overflow")]
501    GenerationOverflow {
502        /// Last valid physical generation.
503        generation: u64,
504    },
505    /// Caller attempted to commit a physical generation other than the next generation.
506    #[error("expected committed ledger generation {expected}, got {actual}")]
507    UnexpectedGeneration {
508        /// Expected next physical generation.
509        expected: u64,
510        /// Actual requested physical generation.
511        actual: u64,
512    },
513}
514
515fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
516    let header = [
517        generation.generation.to_le_bytes(),
518        generation.commit_marker.to_le_bytes(),
519        (generation.payload.len() as u64).to_le_bytes(),
520    ];
521    let hash = fnv64(FNV_OFFSET, header.as_flattened());
522    fnv64(hash, &generation.payload)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    #[test]
530    fn oversized_commits_preserve_empty_and_populated_stores_and_permit_retry() {
531        let limit = crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
532        for populated in [false, true] {
533            let mut store = DualCommitStore::default();
534            if populated {
535                store.commit_payload(payload(1)).unwrap();
536                store.commit_payload(payload(2)).unwrap();
537            }
538            let before = store.clone();
539            let generation = if populated { 2 } else { 0 };
540            for explicit in [false, true] {
541                let oversized = vec![0; limit + 1];
542                let result = if explicit {
543                    store.commit_payload_at_generation(generation, oversized)
544                } else {
545                    store.commit_payload(oversized)
546                };
547                assert_eq!(
548                    result,
549                    Err(CommitRecoveryError::PayloadTooLarge {
550                        len: limit + 1,
551                        limit,
552                    })
553                );
554                assert_eq!(store, before);
555            }
556            assert_eq!(
557                store.commit_payload(payload(3)).unwrap().generation(),
558                generation
559            );
560            let bytes = crate::test_cbor::to_vec(&store).unwrap();
561            let decoded: DualCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
562            assert_eq!(decoded, store);
563        }
564    }
565
566    #[test]
567    fn automatic_and_explicit_commits_preserve_rejected_predecessors() {
568        let mut store = DualCommitStore::default();
569        // An explicit first physical generation can represent an imported baseline.
570        assert_eq!(
571            store
572                .commit_payload_at_generation(7, payload(1))
573                .unwrap()
574                .generation(),
575            7
576        );
577        let before = store.clone();
578        assert_eq!(
579            store.commit_payload_at_generation(9, payload(2)),
580            Err(CommitRecoveryError::UnexpectedGeneration {
581                expected: 8,
582                actual: 9
583            })
584        );
585        assert_eq!(store, before);
586        assert_eq!(store.commit_payload(payload(2)).unwrap().generation(), 8);
587        assert_eq!(store.slot0().unwrap().generation(), 7);
588        assert_eq!(store.slot1().unwrap().generation(), 8);
589        assert_eq!(
590            store
591                .commit_payload_at_generation(9, payload(3))
592                .unwrap()
593                .generation(),
594            9
595        );
596        assert_eq!(store.slot0().unwrap().generation(), 9);
597        assert_eq!(store.slot1().unwrap().generation(), 8);
598
599        store.slot1.as_mut().unwrap().checksum ^= 1;
600        let corrupt = store.clone();
601        for result in [
602            store
603                .commit_payload(payload(4))
604                .map(CommittedGenerationBytes::generation),
605            store
606                .commit_payload_at_generation(10, payload(4))
607                .map(CommittedGenerationBytes::generation),
608        ] {
609            assert_eq!(
610                result,
611                Err(CommitRecoveryError::InvalidCommitSlots {
612                    slot0_invalid: false,
613                    slot1_invalid: true,
614                })
615            );
616        }
617        assert_eq!(store, corrupt);
618    }
619
620    #[test]
621    fn payload_uses_binary_bytes_and_human_readable_arrays() {
622        let record = CommittedGenerationBytes::new(7, vec![0, 24, 255]);
623        let bytes = crate::test_cbor::to_vec(&record).unwrap();
624        let value: ciborium::Value = crate::cbor::from_slice_exact(&bytes).unwrap();
625        let ciborium::Value::Map(mut fields) = value else {
626            panic!("record map")
627        };
628        let (_, payload) = fields
629            .iter_mut()
630            .find(|(key, _)| key.as_text() == Some("payload"))
631            .unwrap();
632        assert_eq!(*payload, ciborium::Value::Bytes(vec![0, 24, 255]));
633        // Removed durable integer-array representations must reject.
634        *payload = ciborium::Value::Array(vec![0.into(), 24.into(), 255.into()]);
635        let removed = crate::test_cbor::to_vec(&ciborium::Value::Map(fields)).unwrap();
636        assert!(crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&removed).is_err());
637        assert_eq!(
638            crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&bytes).unwrap(),
639            record
640        );
641        let json = serde_json::to_value(&record).unwrap();
642        assert_eq!(json["payload"], serde_json::json!([0, 24, 255]));
643        assert_eq!(
644            serde_json::from_value::<CommittedGenerationBytes>(json).unwrap(),
645            record
646        );
647    }
648
649    #[test]
650    fn maximum_payload_writer_reader_agree() {
651        let mut store = DualCommitStore::default();
652        store
653            .commit_payload_at_generation(0, vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES])
654            .unwrap();
655        store
656            .commit_payload(vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES])
657            .unwrap();
658        let bytes = crate::test_cbor::to_vec(&store).unwrap();
659        assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
660        let decoded: DualCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
661        assert_eq!(decoded, store);
662        assert!(decoded.authoritative().is_ok());
663        let oversized = CommittedGenerationBytes::new(
664            0,
665            vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1],
666        );
667        assert!(crate::test_cbor::to_vec(&oversized).is_err());
668    }
669
670    fn payload(value: u8) -> Vec<u8> {
671        vec![value; 4]
672    }
673
674    #[test]
675    fn committed_generation_validates_marker_and_checksum() {
676        let mut generation = CommittedGenerationBytes::new(7, payload(1));
677        assert!(generation.validates());
678
679        generation.checksum = generation.checksum.wrapping_add(1);
680        assert!(!generation.validates());
681    }
682
683    #[test]
684    fn physical_commit_accessors_expose_read_only_state() {
685        let mut store = DualCommitStore::default();
686        store.commit_payload(payload(1)).expect("first commit");
687
688        let slot = store.slot0().expect("first slot");
689
690        assert_eq!(slot.generation(), 0);
691        assert_eq!(slot.payload(), payload(1).as_slice());
692        assert_eq!(slot.commit_marker(), COMMIT_MARKER);
693        assert_eq!(slot.checksum(), generation_checksum(slot));
694        assert!(store.slot1().is_none());
695    }
696
697    #[test]
698    fn authoritative_selects_highest_valid_generation() {
699        let mut store = DualCommitStore::default();
700        store.commit_payload(payload(1)).expect("first commit");
701        store.commit_payload(payload(2)).expect("second commit");
702
703        let authoritative = store.authoritative().expect("authoritative");
704        let authoritative_slot =
705            select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
706                .expect("authoritative slot");
707
708        assert_eq!(authoritative.generation, 1);
709        assert_eq!(authoritative.payload, payload(2));
710        assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
711        assert_eq!(authoritative_slot.record.payload, payload(2));
712    }
713
714    #[test]
715    fn corrupt_newer_slot_fails_closed() {
716        let mut store = DualCommitStore::default();
717        store.commit_payload(payload(1)).expect("first commit");
718        store.write_corrupt_inactive_slot(1, payload(2));
719
720        let err = store.authoritative().expect_err("corrupt slot");
721
722        assert_eq!(
723            err,
724            CommitRecoveryError::InvalidCommitSlots {
725                slot0_invalid: false,
726                slot1_invalid: true,
727            }
728        );
729    }
730
731    #[test]
732    fn two_invalid_commit_slots_fail_closed() {
733        let mut store = DualCommitStore::default();
734        store.write_corrupt_inactive_slot(0, payload(1));
735        store.write_corrupt_inactive_slot(1, payload(2));
736
737        let err = store.authoritative().expect_err("invalid slots");
738
739        assert_eq!(
740            err,
741            CommitRecoveryError::InvalidCommitSlots {
742                slot0_invalid: true,
743                slot1_invalid: true,
744            }
745        );
746    }
747
748    #[test]
749    fn same_generation_identical_slots_recover_deterministically() {
750        let committed = CommittedGenerationBytes::new(7, payload(1));
751        let store = DualCommitStore {
752            slot0: Some(committed.clone()),
753            slot1: Some(committed),
754        };
755
756        let authoritative = store.authoritative_slot().expect("authoritative");
757
758        assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
759        assert_eq!(authoritative.record.generation, 7);
760    }
761
762    #[test]
763    fn same_generation_divergent_slots_fail_closed() {
764        let store = DualCommitStore {
765            slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
766            slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
767        };
768
769        let err = store.authoritative().expect_err("ambiguous generation");
770
771        assert_eq!(
772            err,
773            CommitRecoveryError::AmbiguousGeneration { generation: 7 }
774        );
775    }
776
777    #[test]
778    fn physical_generation_overflow_fails_closed() {
779        let mut store = DualCommitStore {
780            slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
781            slot1: None,
782        };
783
784        let err = store
785            .commit_payload(payload(2))
786            .expect_err("overflow must fail");
787
788        assert_eq!(
789            err,
790            CommitRecoveryError::GenerationOverflow {
791                generation: u64::MAX
792            }
793        );
794    }
795
796    #[test]
797    fn diagnostic_reports_corrupt_slots_without_an_authoritative_generation() {
798        let mut store = DualCommitStore::default();
799        store.commit_payload(payload(1)).expect("first commit");
800        store.write_corrupt_inactive_slot(1, payload(2));
801
802        let diagnostic = store.diagnostic();
803
804        assert_eq!(
805            diagnostic.recovery,
806            Err(CommitRecoveryError::InvalidCommitSlots {
807                slot0_invalid: false,
808                slot1_invalid: true,
809            })
810        );
811        assert_eq!(
812            diagnostic.slot0,
813            CommitSlotDiagnostic::Valid { generation: 0 }
814        );
815        assert_eq!(
816            diagnostic.slot1,
817            CommitSlotDiagnostic::Invalid { generation: 1 }
818        );
819        let bytes = crate::test_cbor::to_vec(&diagnostic).expect("diagnostic bytes");
820        let decoded: CommitStoreDiagnostic =
821            crate::test_cbor::from_slice(&bytes).expect("diagnostic round trip");
822        assert_eq!(decoded, diagnostic);
823    }
824
825    #[test]
826    fn diagnostic_selection_covers_valid_ties_and_corruption_on_either_slot() {
827        let mut store = DualCommitStore {
828            slot0: None,
829            slot1: Some(CommittedGenerationBytes::new(8, payload(2))),
830        };
831        assert_eq!(store.diagnostic().recovery, Ok(8));
832        assert_eq!(store.diagnostic().slot0, CommitSlotDiagnostic::Empty);
833        store.slot0 = Some(CommittedGenerationBytes::new(7, payload(1)));
834        assert_eq!(store.diagnostic().recovery, Ok(8));
835        store.slot1.clone_from(&store.slot0);
836        assert_eq!(store.diagnostic().recovery, Ok(7));
837
838        store.slot1 = Some(CommittedGenerationBytes::new(7, payload(2)));
839        let diagnostic = store.diagnostic();
840        assert_eq!(
841            diagnostic.recovery,
842            Err(CommitRecoveryError::AmbiguousGeneration { generation: 7 })
843        );
844        assert_eq!(
845            diagnostic.slot0,
846            CommitSlotDiagnostic::Valid { generation: 7 }
847        );
848        assert_eq!(
849            diagnostic.slot1,
850            CommitSlotDiagnostic::Valid { generation: 7 }
851        );
852
853        store.slot0.as_mut().unwrap().checksum ^= 1;
854        let diagnostic = store.diagnostic();
855        assert_eq!(
856            diagnostic.recovery,
857            Err(CommitRecoveryError::InvalidCommitSlots {
858                slot0_invalid: true,
859                slot1_invalid: false,
860            })
861        );
862        assert_eq!(
863            diagnostic.slot0,
864            CommitSlotDiagnostic::Invalid { generation: 7 }
865        );
866        assert_eq!(
867            diagnostic.slot1,
868            CommitSlotDiagnostic::Valid { generation: 7 }
869        );
870
871        store.slot1.as_mut().unwrap().commit_marker = 0;
872        let diagnostic = store.diagnostic();
873        assert_eq!(
874            diagnostic.recovery,
875            Err(CommitRecoveryError::InvalidCommitSlots {
876                slot0_invalid: true,
877                slot1_invalid: true,
878            })
879        );
880        assert_eq!(
881            diagnostic.slot0,
882            CommitSlotDiagnostic::Invalid { generation: 7 }
883        );
884        assert_eq!(
885            diagnostic.slot1,
886            CommitSlotDiagnostic::Invalid { generation: 7 }
887        );
888    }
889
890    #[test]
891    fn diagnostic_reports_no_valid_generation_for_empty_store() {
892        let diagnostic = DualCommitStore::default().diagnostic();
893
894        assert_eq!(
895            diagnostic.recovery,
896            Err(CommitRecoveryError::NoValidGeneration)
897        );
898        assert_eq!(diagnostic.slot0, CommitSlotDiagnostic::Empty);
899        assert_eq!(diagnostic.slot1, CommitSlotDiagnostic::Empty);
900    }
901
902    #[test]
903    fn uninitialized_distinguishes_empty_from_corrupt() {
904        let mut store = DualCommitStore::default();
905        assert!(store.is_uninitialized());
906
907        store.write_corrupt_inactive_slot(0, payload(1));
908
909        assert!(!store.is_uninitialized());
910    }
911
912    #[test]
913    fn commit_after_corrupt_slot_fails_closed() {
914        let mut store = DualCommitStore::default();
915        store.commit_payload(payload(1)).expect("first commit");
916        store.write_corrupt_inactive_slot(1, payload(2));
917
918        let err = store
919            .commit_payload(payload(3))
920            .expect_err("corrupt history must not be overwritten");
921
922        assert_eq!(
923            err,
924            CommitRecoveryError::InvalidCommitSlots {
925                slot0_invalid: false,
926                slot1_invalid: true,
927            }
928        );
929    }
930}