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#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81#[serde(deny_unknown_fields)]
82pub struct CommittedGenerationBytes {
83 pub(crate) generation: u64,
85 pub(crate) commit_marker: u64,
87 pub(crate) checksum: u64,
89 #[serde(with = "opaque_payload")]
91 pub(crate) payload: Vec<u8>,
92}
93
94mod 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 #[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 #[must_use]
161 pub const fn generation(&self) -> u64 {
162 self.generation
163 }
164
165 #[must_use]
171 pub const fn commit_marker(&self) -> u64 {
172 self.commit_marker
173 }
174
175 #[must_use]
180 pub const fn checksum(&self) -> u64 {
181 self.checksum
182 }
183
184 #[must_use]
186 pub fn payload(&self) -> &[u8] {
187 &self.payload
188 }
189
190 #[must_use]
192 pub fn validates(&self) -> bool {
193 self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
194 }
195}
196
197#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
220#[serde(deny_unknown_fields)]
221pub struct DualCommitStore {
222 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
224 pub(crate) slot0: Option<CommittedGenerationBytes>,
225 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
227 pub(crate) slot1: Option<CommittedGenerationBytes>,
228}
229
230impl DualCommitStore {
231 #[must_use]
233 pub const fn is_uninitialized(&self) -> bool {
234 self.slot0.is_none() && self.slot1.is_none()
235 }
236
237 #[must_use]
242 pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
243 self.slot0.as_ref()
244 }
245
246 #[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 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 #[must_use]
287 pub fn diagnostic(&self) -> CommitStoreDiagnostic {
288 CommitStoreDiagnostic::from_store(self)
289 }
290
291 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 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 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 Ok(slot.insert(CommittedGenerationBytes::new(generation, payload)))
361 }
362
363 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
387#[serde(deny_unknown_fields)]
388pub struct CommitStoreDiagnostic {
389 pub slot0: CommitSlotDiagnostic,
391 pub slot1: CommitSlotDiagnostic,
393 pub recovery: Result<u64, CommitRecoveryError>,
395}
396
397impl CommitStoreDiagnostic {
398 #[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 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
433#[serde(deny_unknown_fields)]
434pub enum CommitSlotDiagnostic {
435 Empty,
437 Valid {
439 generation: u64,
441 },
442 Invalid {
444 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#[non_exhaustive]
470#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
471pub enum CommitRecoveryError {
472 #[error("committed payload length {len} exceeds byte limit {limit}")]
474 PayloadTooLarge {
475 len: usize,
477 limit: usize,
479 },
480 #[error("no committed ledger generation is present")]
482 NoValidGeneration,
483 #[error(
485 "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
486 )]
487 InvalidCommitSlots {
488 slot0_invalid: bool,
490 slot1_invalid: bool,
492 },
493 #[error("ambiguous committed ledger generation {generation}")]
495 AmbiguousGeneration {
496 generation: u64,
498 },
499 #[error("committed ledger generation {generation} cannot be advanced without overflow")]
501 GenerationOverflow {
502 generation: u64,
504 },
505 #[error("expected committed ledger generation {expected}, got {actual}")]
507 UnexpectedGeneration {
508 expected: u64,
510 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 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 *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}