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