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