1use serde::{Deserialize, Serialize};
2
3const COMMIT_MARKER: u64 = 0x4943_4D45_4D43_4F4D;
4const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
5const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
6
7#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8enum CommitSlotIndex {
9 Slot0,
10 Slot1,
11}
12
13impl CommitSlotIndex {
14 const fn opposite(self) -> Self {
15 match self {
16 Self::Slot0 => Self::Slot1,
17 Self::Slot1 => Self::Slot0,
18 }
19 }
20}
21
22#[derive(Clone, Copy, Debug, Eq, PartialEq)]
23struct AuthoritativeSlot<'slot> {
24 index: CommitSlotIndex,
25 record: &'slot CommittedGenerationBytes,
26}
27
28fn select_authoritative_slot<'slot>(
29 slot0: Option<&'slot CommittedGenerationBytes>,
30 slot1: Option<&'slot CommittedGenerationBytes>,
31) -> Result<AuthoritativeSlot<'slot>, CommitRecoveryError> {
32 let slot0_invalid = slot0.is_some_and(|slot| !slot.validates());
33 let slot1_invalid = slot1.is_some_and(|slot| !slot.validates());
34 if slot0_invalid || slot1_invalid {
35 return Err(CommitRecoveryError::InvalidCommitSlots {
36 slot0_invalid,
37 slot1_invalid,
38 });
39 }
40
41 let slot0 = slot0.map(|record| AuthoritativeSlot {
42 index: CommitSlotIndex::Slot0,
43 record,
44 });
45 let slot1 = slot1.map(|record| AuthoritativeSlot {
46 index: CommitSlotIndex::Slot1,
47 record,
48 });
49
50 match (slot0, slot1) {
51 (Some(left), Some(right))
52 if left.record.generation() == right.record.generation()
53 && left.record != right.record =>
54 {
55 Err(CommitRecoveryError::AmbiguousGeneration {
56 generation: left.record.generation(),
57 })
58 }
59 (Some(left), Some(right)) if right.record.generation() > left.record.generation() => {
60 Ok(right)
61 }
62 (Some(left), Some(_) | None) => Ok(left),
63 (None, Some(right)) => Ok(right),
64 (None, None) => Err(CommitRecoveryError::NoValidGeneration),
65 }
66}
67
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(deny_unknown_fields)]
83pub struct CommittedGenerationBytes {
84 pub(crate) generation: u64,
86 pub(crate) commit_marker: u64,
88 pub(crate) checksum: u64,
90 #[serde(with = "opaque_payload")]
92 pub(crate) payload: Vec<u8>,
93}
94
95mod opaque_payload {
98 use crate::constants::MAX_COMMITTED_PAYLOAD_BYTES;
99 use serde::{Deserializer, Serialize, Serializer, de::Visitor};
100
101 pub fn serialize<S: Serializer>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error> {
102 if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
103 return Err(serde::ser::Error::custom(
104 "ledger byte string exceeds payload bound",
105 ));
106 }
107 if serializer.is_human_readable() {
108 bytes.serialize(serializer)
109 } else {
110 serializer.serialize_bytes(bytes)
111 }
112 }
113
114 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> {
115 struct Bytes;
116 impl Visitor<'_> for Bytes {
117 type Value = Vec<u8>;
118
119 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.write_str("a bounded opaque ledger byte string")
121 }
122
123 fn visit_bytes<E: serde::de::Error>(self, bytes: &[u8]) -> Result<Self::Value, E> {
124 if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
125 return Err(E::custom("ledger byte string exceeds payload bound"));
126 }
127 Ok(bytes.to_vec())
128 }
129
130 fn visit_byte_buf<E: serde::de::Error>(self, bytes: Vec<u8>) -> Result<Self::Value, E> {
131 if bytes.len() > MAX_COMMITTED_PAYLOAD_BYTES {
132 return Err(E::custom("ledger byte string exceeds payload bound"));
133 }
134 Ok(bytes)
135 }
136 }
137 if deserializer.is_human_readable() {
138 return crate::cbor::deserialize_bounded_vec::<D, u8, MAX_COMMITTED_PAYLOAD_BYTES>(
139 deserializer,
140 );
141 }
142 deserializer.deserialize_byte_buf(Bytes)
143 }
144}
145
146impl CommittedGenerationBytes {
147 #[must_use]
149 pub fn new(generation: u64, payload: Vec<u8>) -> Self {
150 let mut record = Self {
151 generation,
152 commit_marker: COMMIT_MARKER,
153 checksum: 0,
154 payload,
155 };
156 record.checksum = generation_checksum(&record);
157 record
158 }
159
160 #[must_use]
162 pub const fn generation(&self) -> u64 {
163 self.generation
164 }
165
166 #[must_use]
172 pub const fn commit_marker(&self) -> u64 {
173 self.commit_marker
174 }
175
176 #[must_use]
181 pub const fn checksum(&self) -> u64 {
182 self.checksum
183 }
184
185 #[must_use]
187 pub fn payload(&self) -> &[u8] {
188 &self.payload
189 }
190
191 #[must_use]
193 pub fn validates(&self) -> bool {
194 self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
195 }
196}
197
198#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
221#[serde(deny_unknown_fields)]
222pub struct DualCommitStore {
223 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
225 pub(crate) slot0: Option<CommittedGenerationBytes>,
226 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
228 pub(crate) slot1: Option<CommittedGenerationBytes>,
229}
230
231impl DualCommitStore {
232 #[must_use]
234 pub const fn is_uninitialized(&self) -> bool {
235 self.slot0.is_none() && self.slot1.is_none()
236 }
237
238 #[must_use]
243 pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
244 self.slot0.as_ref()
245 }
246
247 #[must_use]
252 pub const fn slot1(&self) -> Option<&CommittedGenerationBytes> {
253 self.slot1.as_ref()
254 }
255
256 fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_>, CommitRecoveryError> {
257 select_authoritative_slot(self.slot0(), self.slot1())
258 }
259
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 #[must_use]
276 pub fn diagnostic(&self) -> CommitStoreDiagnostic {
277 CommitStoreDiagnostic::from_store(self)
278 }
279
280 pub fn commit_payload(
285 &mut self,
286 payload: Vec<u8>,
287 ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
288 let next_generation =
289 match self.authoritative() {
290 Ok(record) => record.generation.checked_add(1).ok_or(
291 CommitRecoveryError::GenerationOverflow {
292 generation: record.generation,
293 },
294 )?,
295 Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
296 Err(err) => return Err(err),
297 };
298
299 self.commit_payload_at_generation(next_generation, payload)
300 }
301
302 pub fn commit_payload_at_generation(
313 &mut self,
314 generation: u64,
315 payload: Vec<u8>,
316 ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
317 match self.authoritative() {
318 Ok(record) => {
319 let expected = record.generation.checked_add(1).ok_or(
320 CommitRecoveryError::GenerationOverflow {
321 generation: record.generation,
322 },
323 )?;
324 if generation != expected {
325 return Err(CommitRecoveryError::UnexpectedGeneration {
326 expected,
327 actual: generation,
328 });
329 }
330 }
331 Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
332 Err(err) => return Err(err),
333 }
334
335 let next = CommittedGenerationBytes::new(generation, payload);
336
337 if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
338 self.slot0 = Some(next);
339 } else {
340 self.slot1 = Some(next);
341 }
342
343 self.authoritative()
344 }
345
346 #[cfg(test)]
351 pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
352 let mut corrupt = CommittedGenerationBytes::new(generation, payload);
353 corrupt.checksum = corrupt.checksum.wrapping_add(1);
354
355 if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
356 self.slot0 = Some(corrupt);
357 } else {
358 self.slot1 = Some(corrupt);
359 }
360 }
361}
362
363#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
370#[serde(deny_unknown_fields)]
371pub struct CommitStoreDiagnostic {
372 pub slot0: CommitSlotDiagnostic,
374 pub slot1: CommitSlotDiagnostic,
376 pub recovery: Result<u64, CommitRecoveryError>,
378}
379
380impl CommitStoreDiagnostic {
381 #[must_use]
383 pub fn from_store(store: &DualCommitStore) -> Self {
384 Self {
385 slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
386 slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
387 recovery: store
388 .authoritative_slot()
389 .map(|slot| slot.record.generation()),
390 }
391 }
392}
393
394#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
401#[serde(deny_unknown_fields)]
402pub enum CommitSlotDiagnostic {
403 Empty,
405 Valid {
407 generation: u64,
409 },
410 Invalid {
412 generation: u64,
414 },
415}
416
417impl CommitSlotDiagnostic {
418 fn from_slot(slot: Option<&CommittedGenerationBytes>) -> Self {
419 match slot {
420 Some(record) if record.validates() => Self::Valid {
421 generation: record.generation(),
422 },
423 Some(record) => Self::Invalid {
424 generation: record.generation(),
425 },
426 None => Self::Empty,
427 }
428 }
429}
430
431#[non_exhaustive]
438#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
439pub enum CommitRecoveryError {
440 #[error("no committed ledger generation is present")]
442 NoValidGeneration,
443 #[error(
445 "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
446 )]
447 InvalidCommitSlots {
448 slot0_invalid: bool,
450 slot1_invalid: bool,
452 },
453 #[error("ambiguous committed ledger generation {generation}")]
455 AmbiguousGeneration {
456 generation: u64,
458 },
459 #[error("committed ledger generation {generation} cannot be advanced without overflow")]
461 GenerationOverflow {
462 generation: u64,
464 },
465 #[error("expected committed ledger generation {expected}, got {actual}")]
467 UnexpectedGeneration {
468 expected: u64,
470 actual: u64,
472 },
473}
474
475fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
476 let mut hash = FNV_OFFSET;
477 hash = hash_u64(hash, generation.generation);
478 hash = hash_u64(hash, generation.commit_marker);
479 hash = hash_usize(hash, generation.payload.len());
480 for byte in &generation.payload {
481 hash = hash_byte(hash, *byte);
482 }
483 hash
484}
485
486fn hash_usize(hash: u64, value: usize) -> u64 {
487 hash_u64(hash, value as u64)
488}
489
490fn hash_u64(mut hash: u64, value: u64) -> u64 {
491 for byte in value.to_le_bytes() {
492 hash = hash_byte(hash, byte);
493 }
494 hash
495}
496
497const fn hash_byte(hash: u64, byte: u8) -> u64 {
498 (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn payload_uses_binary_bytes_and_human_readable_arrays() {
507 let record = CommittedGenerationBytes::new(7, vec![0, 24, 255]);
508 let bytes = crate::test_cbor::to_vec(&record).unwrap();
509 let value: ciborium::Value = crate::cbor::from_slice_exact(&bytes).unwrap();
510 let ciborium::Value::Map(mut fields) = value else {
511 panic!("record map")
512 };
513 let (_, payload) = fields
514 .iter_mut()
515 .find(|(key, _)| key.as_text() == Some("payload"))
516 .unwrap();
517 assert_eq!(*payload, ciborium::Value::Bytes(vec![0, 24, 255]));
518 *payload = ciborium::Value::Array(vec![0.into(), 24.into(), 255.into()]);
520 let removed = crate::test_cbor::to_vec(&ciborium::Value::Map(fields)).unwrap();
521 assert!(crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&removed).is_err());
522 assert_eq!(
523 crate::cbor::from_slice_exact::<CommittedGenerationBytes>(&bytes).unwrap(),
524 record
525 );
526 let json = serde_json::to_value(&record).unwrap();
527 assert_eq!(json["payload"], serde_json::json!([0, 24, 255]));
528 assert_eq!(
529 serde_json::from_value::<CommittedGenerationBytes>(json).unwrap(),
530 record
531 );
532 }
533
534 #[test]
535 fn maximum_payload_writer_reader_agree() {
536 let record = CommittedGenerationBytes::new(
537 0,
538 vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES],
539 );
540 let store = DualCommitStore {
541 slot0: Some(record.clone()),
542 slot1: Some(record),
543 };
544 let bytes = crate::test_cbor::to_vec(&store).unwrap();
545 assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
546 let decoded: DualCommitStore = crate::cbor::from_slice_exact(&bytes).unwrap();
547 assert_eq!(decoded, store);
548 assert!(decoded.authoritative().is_ok());
549 let oversized = CommittedGenerationBytes::new(
550 0,
551 vec![0; crate::constants::MAX_COMMITTED_PAYLOAD_BYTES + 1],
552 );
553 assert!(crate::test_cbor::to_vec(&oversized).is_err());
554 }
555
556 fn payload(value: u8) -> Vec<u8> {
557 vec![value; 4]
558 }
559
560 #[test]
561 fn committed_generation_validates_marker_and_checksum() {
562 let mut generation = CommittedGenerationBytes::new(7, payload(1));
563 assert!(generation.validates());
564
565 generation.checksum = generation.checksum.wrapping_add(1);
566 assert!(!generation.validates());
567 }
568
569 #[test]
570 fn physical_commit_accessors_expose_read_only_state() {
571 let mut store = DualCommitStore::default();
572 store.commit_payload(payload(1)).expect("first commit");
573
574 let slot = store.slot0().expect("first slot");
575
576 assert_eq!(slot.generation(), 0);
577 assert_eq!(slot.payload(), payload(1).as_slice());
578 assert_eq!(slot.commit_marker(), COMMIT_MARKER);
579 assert_eq!(slot.checksum(), generation_checksum(slot));
580 assert!(store.slot1().is_none());
581 }
582
583 #[test]
584 fn authoritative_selects_highest_valid_generation() {
585 let mut store = DualCommitStore::default();
586 store.commit_payload(payload(1)).expect("first commit");
587 store.commit_payload(payload(2)).expect("second commit");
588
589 let authoritative = store.authoritative().expect("authoritative");
590 let authoritative_slot =
591 select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
592 .expect("authoritative slot");
593
594 assert_eq!(authoritative.generation, 1);
595 assert_eq!(authoritative.payload, payload(2));
596 assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
597 assert_eq!(authoritative_slot.record.payload, payload(2));
598 }
599
600 #[test]
601 fn corrupt_newer_slot_fails_closed() {
602 let mut store = DualCommitStore::default();
603 store.commit_payload(payload(1)).expect("first commit");
604 store.write_corrupt_inactive_slot(1, payload(2));
605
606 let err = store.authoritative().expect_err("corrupt slot");
607
608 assert_eq!(
609 err,
610 CommitRecoveryError::InvalidCommitSlots {
611 slot0_invalid: false,
612 slot1_invalid: true,
613 }
614 );
615 }
616
617 #[test]
618 fn two_invalid_commit_slots_fail_closed() {
619 let mut store = DualCommitStore::default();
620 store.write_corrupt_inactive_slot(0, payload(1));
621 store.write_corrupt_inactive_slot(1, payload(2));
622
623 let err = store.authoritative().expect_err("invalid slots");
624
625 assert_eq!(
626 err,
627 CommitRecoveryError::InvalidCommitSlots {
628 slot0_invalid: true,
629 slot1_invalid: true,
630 }
631 );
632 }
633
634 #[test]
635 fn same_generation_identical_slots_recover_deterministically() {
636 let committed = CommittedGenerationBytes::new(7, payload(1));
637 let store = DualCommitStore {
638 slot0: Some(committed.clone()),
639 slot1: Some(committed),
640 };
641
642 let authoritative = store.authoritative_slot().expect("authoritative");
643
644 assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
645 assert_eq!(authoritative.record.generation, 7);
646 }
647
648 #[test]
649 fn same_generation_divergent_slots_fail_closed() {
650 let store = DualCommitStore {
651 slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
652 slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
653 };
654
655 let err = store.authoritative().expect_err("ambiguous generation");
656
657 assert_eq!(
658 err,
659 CommitRecoveryError::AmbiguousGeneration { generation: 7 }
660 );
661 }
662
663 #[test]
664 fn physical_generation_overflow_fails_closed() {
665 let mut store = DualCommitStore {
666 slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
667 slot1: None,
668 };
669
670 let err = store
671 .commit_payload(payload(2))
672 .expect_err("overflow must fail");
673
674 assert_eq!(
675 err,
676 CommitRecoveryError::GenerationOverflow {
677 generation: u64::MAX
678 }
679 );
680 }
681
682 #[test]
683 fn diagnostic_reports_corrupt_slots_without_an_authoritative_generation() {
684 let mut store = DualCommitStore::default();
685 store.commit_payload(payload(1)).expect("first commit");
686 store.write_corrupt_inactive_slot(1, payload(2));
687
688 let diagnostic = store.diagnostic();
689
690 assert_eq!(
691 diagnostic.recovery,
692 Err(CommitRecoveryError::InvalidCommitSlots {
693 slot0_invalid: false,
694 slot1_invalid: true,
695 })
696 );
697 assert_eq!(
698 diagnostic.slot0,
699 CommitSlotDiagnostic::Valid { generation: 0 }
700 );
701 assert_eq!(
702 diagnostic.slot1,
703 CommitSlotDiagnostic::Invalid { generation: 1 }
704 );
705 let bytes = crate::test_cbor::to_vec(&diagnostic).expect("diagnostic bytes");
706 let decoded: CommitStoreDiagnostic =
707 crate::test_cbor::from_slice(&bytes).expect("diagnostic round trip");
708 assert_eq!(decoded, diagnostic);
709 }
710
711 #[test]
712 fn diagnostic_reports_no_valid_generation_for_empty_store() {
713 let diagnostic = DualCommitStore::default().diagnostic();
714
715 assert_eq!(
716 diagnostic.recovery,
717 Err(CommitRecoveryError::NoValidGeneration)
718 );
719 assert_eq!(diagnostic.slot0, CommitSlotDiagnostic::Empty);
720 assert_eq!(diagnostic.slot1, CommitSlotDiagnostic::Empty);
721 }
722
723 #[test]
724 fn uninitialized_distinguishes_empty_from_corrupt() {
725 let mut store = DualCommitStore::default();
726 assert!(store.is_uninitialized());
727
728 store.write_corrupt_inactive_slot(0, payload(1));
729
730 assert!(!store.is_uninitialized());
731 }
732
733 #[test]
734 fn commit_after_corrupt_slot_fails_closed() {
735 let mut store = DualCommitStore::default();
736 store.commit_payload(payload(1)).expect("first commit");
737 store.write_corrupt_inactive_slot(1, payload(2));
738
739 let err = store
740 .commit_payload(payload(3))
741 .expect_err("corrupt history must not be overwritten");
742
743 assert_eq!(
744 err,
745 CommitRecoveryError::InvalidCommitSlots {
746 slot0_invalid: false,
747 slot1_invalid: true,
748 }
749 );
750 }
751}