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 #[cfg(test)]
172 pub const fn commit_marker(&self) -> u64 {
173 self.commit_marker
174 }
175
176 #[must_use]
181 #[cfg(test)]
182 pub const fn checksum(&self) -> u64 {
183 self.checksum
184 }
185
186 #[must_use]
188 pub fn payload(&self) -> &[u8] {
189 &self.payload
190 }
191
192 #[must_use]
194 pub fn validates(&self) -> bool {
195 self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
196 }
197}
198
199#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
222#[serde(deny_unknown_fields)]
223pub struct DualCommitStore {
224 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
226 pub(crate) slot0: Option<CommittedGenerationBytes>,
227 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
229 pub(crate) slot1: Option<CommittedGenerationBytes>,
230}
231
232impl DualCommitStore {
233 #[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 #[must_use]
245 pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
246 self.slot0.as_ref()
247 }
248
249 #[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 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 #[must_use]
290 pub fn diagnostic(&self) -> CommitStoreDiagnostic {
291 CommitStoreDiagnostic::from_store(self)
292 }
293
294 #[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 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 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 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 Ok(slot.insert(CommittedGenerationBytes::new(generation, payload)))
366 }
367
368 #[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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
392#[serde(deny_unknown_fields)]
393pub struct CommitStoreDiagnostic {
394 pub slot0: CommitSlotDiagnostic,
396 pub slot1: CommitSlotDiagnostic,
398 pub recovery: Result<u64, CommitRecoveryError>,
400}
401
402impl CommitStoreDiagnostic {
403 #[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 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#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
438#[serde(deny_unknown_fields)]
439pub enum CommitSlotDiagnostic {
440 Empty,
442 Valid {
444 generation: u64,
446 },
447 Invalid {
449 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#[non_exhaustive]
475#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
476pub enum CommitRecoveryError {
477 #[error("committed payload length {len} exceeds byte limit {limit}")]
479 PayloadTooLarge {
480 len: usize,
482 limit: usize,
484 },
485 #[error("no committed ledger generation is present")]
487 NoValidGeneration,
488 #[error(
490 "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
491 )]
492 InvalidCommitSlots {
493 slot0_invalid: bool,
495 slot1_invalid: bool,
497 },
498 #[error("ambiguous committed ledger generation {generation}")]
500 AmbiguousGeneration {
501 generation: u64,
503 },
504 #[error("committed ledger generation {generation} cannot be advanced without overflow")]
506 GenerationOverflow {
507 generation: u64,
509 },
510 #[error("expected committed ledger generation {expected}, got {actual}")]
512 UnexpectedGeneration {
513 expected: u64,
515 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 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 *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}