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)]
77#[serde(deny_unknown_fields)]
78pub struct CommittedGenerationBytes {
79 pub(crate) generation: u64,
81 pub(crate) commit_marker: u64,
83 pub(crate) checksum: u64,
85 pub(crate) payload: Vec<u8>,
87}
88
89impl CommittedGenerationBytes {
90 #[must_use]
92 pub fn new(generation: u64, payload: Vec<u8>) -> Self {
93 let mut record = Self {
94 generation,
95 commit_marker: COMMIT_MARKER,
96 checksum: 0,
97 payload,
98 };
99 record.checksum = generation_checksum(&record);
100 record
101 }
102
103 #[must_use]
105 pub const fn generation(&self) -> u64 {
106 self.generation
107 }
108
109 #[must_use]
115 pub const fn commit_marker(&self) -> u64 {
116 self.commit_marker
117 }
118
119 #[must_use]
124 pub const fn checksum(&self) -> u64 {
125 self.checksum
126 }
127
128 #[must_use]
130 pub fn payload(&self) -> &[u8] {
131 &self.payload
132 }
133
134 #[must_use]
136 pub fn validates(&self) -> bool {
137 self.commit_marker == COMMIT_MARKER && self.checksum == generation_checksum(self)
138 }
139}
140
141#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
164#[serde(deny_unknown_fields)]
165pub struct DualCommitStore {
166 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
168 pub(crate) slot0: Option<CommittedGenerationBytes>,
169 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
171 pub(crate) slot1: Option<CommittedGenerationBytes>,
172}
173
174impl DualCommitStore {
175 #[must_use]
177 pub const fn is_uninitialized(&self) -> bool {
178 self.slot0.is_none() && self.slot1.is_none()
179 }
180
181 #[must_use]
186 pub const fn slot0(&self) -> Option<&CommittedGenerationBytes> {
187 self.slot0.as_ref()
188 }
189
190 #[must_use]
195 pub const fn slot1(&self) -> Option<&CommittedGenerationBytes> {
196 self.slot1.as_ref()
197 }
198
199 fn authoritative_slot(&self) -> Result<AuthoritativeSlot<'_>, CommitRecoveryError> {
200 select_authoritative_slot(self.slot0(), self.slot1())
201 }
202
203 fn inactive_slot_index(&self) -> CommitSlotIndex {
204 match self.authoritative_slot() {
205 Ok(authoritative) => authoritative.index.opposite(),
206 Err(_) if self.slot0.is_none() => CommitSlotIndex::Slot0,
207 Err(_) => CommitSlotIndex::Slot1,
208 }
209 }
210
211 pub fn authoritative(&self) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
213 self.authoritative_slot()
214 .map(|authoritative| authoritative.record)
215 }
216
217 #[must_use]
219 pub fn diagnostic(&self) -> CommitStoreDiagnostic {
220 CommitStoreDiagnostic::from_store(self)
221 }
222
223 pub fn commit_payload(
228 &mut self,
229 payload: Vec<u8>,
230 ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
231 let next_generation =
232 match self.authoritative() {
233 Ok(record) => record.generation.checked_add(1).ok_or(
234 CommitRecoveryError::GenerationOverflow {
235 generation: record.generation,
236 },
237 )?,
238 Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => 0,
239 Err(err) => return Err(err),
240 };
241
242 self.commit_payload_at_generation(next_generation, payload)
243 }
244
245 pub fn commit_payload_at_generation(
256 &mut self,
257 generation: u64,
258 payload: Vec<u8>,
259 ) -> Result<&CommittedGenerationBytes, CommitRecoveryError> {
260 match self.authoritative() {
261 Ok(record) => {
262 let expected = record.generation.checked_add(1).ok_or(
263 CommitRecoveryError::GenerationOverflow {
264 generation: record.generation,
265 },
266 )?;
267 if generation != expected {
268 return Err(CommitRecoveryError::UnexpectedGeneration {
269 expected,
270 actual: generation,
271 });
272 }
273 }
274 Err(CommitRecoveryError::NoValidGeneration) if self.is_uninitialized() => {}
275 Err(err) => return Err(err),
276 }
277
278 let next = CommittedGenerationBytes::new(generation, payload);
279
280 if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
281 self.slot0 = Some(next);
282 } else {
283 self.slot1 = Some(next);
284 }
285
286 self.authoritative()
287 }
288
289 #[cfg(test)]
294 pub fn write_corrupt_inactive_slot(&mut self, generation: u64, payload: Vec<u8>) {
295 let mut corrupt = CommittedGenerationBytes::new(generation, payload);
296 corrupt.checksum = corrupt.checksum.wrapping_add(1);
297
298 if self.inactive_slot_index() == CommitSlotIndex::Slot0 {
299 self.slot0 = Some(corrupt);
300 } else {
301 self.slot1 = Some(corrupt);
302 }
303 }
304}
305
306#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct CommitStoreDiagnostic {
315 pub slot0: CommitSlotDiagnostic,
317 pub slot1: CommitSlotDiagnostic,
319 pub recovery: Result<u64, CommitRecoveryError>,
321}
322
323impl CommitStoreDiagnostic {
324 #[must_use]
326 pub fn from_store(store: &DualCommitStore) -> Self {
327 Self {
328 slot0: CommitSlotDiagnostic::from_slot(store.slot0()),
329 slot1: CommitSlotDiagnostic::from_slot(store.slot1()),
330 recovery: store
331 .authoritative_slot()
332 .map(|slot| slot.record.generation()),
333 }
334 }
335}
336
337#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
344#[serde(deny_unknown_fields)]
345pub enum CommitSlotDiagnostic {
346 Empty,
348 Valid {
350 generation: u64,
352 },
353 Invalid {
355 generation: u64,
357 },
358}
359
360impl CommitSlotDiagnostic {
361 fn from_slot(slot: Option<&CommittedGenerationBytes>) -> Self {
362 match slot {
363 Some(record) if record.validates() => Self::Valid {
364 generation: record.generation(),
365 },
366 Some(record) => Self::Invalid {
367 generation: record.generation(),
368 },
369 None => Self::Empty,
370 }
371 }
372}
373
374#[non_exhaustive]
381#[derive(Clone, Copy, Debug, Deserialize, Eq, thiserror::Error, PartialEq, Serialize)]
382pub enum CommitRecoveryError {
383 #[error("no committed ledger generation is present")]
385 NoValidGeneration,
386 #[error(
388 "present commit slot validation failed (slot0_invalid={slot0_invalid}, slot1_invalid={slot1_invalid})"
389 )]
390 InvalidCommitSlots {
391 slot0_invalid: bool,
393 slot1_invalid: bool,
395 },
396 #[error("ambiguous committed ledger generation {generation}")]
398 AmbiguousGeneration {
399 generation: u64,
401 },
402 #[error("committed ledger generation {generation} cannot be advanced without overflow")]
404 GenerationOverflow {
405 generation: u64,
407 },
408 #[error("expected committed ledger generation {expected}, got {actual}")]
410 UnexpectedGeneration {
411 expected: u64,
413 actual: u64,
415 },
416}
417
418fn generation_checksum(generation: &CommittedGenerationBytes) -> u64 {
419 let mut hash = FNV_OFFSET;
420 hash = hash_u64(hash, generation.generation);
421 hash = hash_u64(hash, generation.commit_marker);
422 hash = hash_usize(hash, generation.payload.len());
423 for byte in &generation.payload {
424 hash = hash_byte(hash, *byte);
425 }
426 hash
427}
428
429fn hash_usize(hash: u64, value: usize) -> u64 {
430 hash_u64(hash, value as u64)
431}
432
433fn hash_u64(mut hash: u64, value: u64) -> u64 {
434 for byte in value.to_le_bytes() {
435 hash = hash_byte(hash, byte);
436 }
437 hash
438}
439
440const fn hash_byte(hash: u64, byte: u8) -> u64 {
441 (hash ^ byte as u64).wrapping_mul(FNV_PRIME)
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 fn payload(value: u8) -> Vec<u8> {
449 vec![value; 4]
450 }
451
452 #[test]
453 fn committed_generation_validates_marker_and_checksum() {
454 let mut generation = CommittedGenerationBytes::new(7, payload(1));
455 assert!(generation.validates());
456
457 generation.checksum = generation.checksum.wrapping_add(1);
458 assert!(!generation.validates());
459 }
460
461 #[test]
462 fn physical_commit_accessors_expose_read_only_state() {
463 let mut store = DualCommitStore::default();
464 store.commit_payload(payload(1)).expect("first commit");
465
466 let slot = store.slot0().expect("first slot");
467
468 assert_eq!(slot.generation(), 0);
469 assert_eq!(slot.payload(), payload(1).as_slice());
470 assert_eq!(slot.commit_marker(), COMMIT_MARKER);
471 assert_eq!(slot.checksum(), generation_checksum(slot));
472 assert!(store.slot1().is_none());
473 }
474
475 #[test]
476 fn authoritative_selects_highest_valid_generation() {
477 let mut store = DualCommitStore::default();
478 store.commit_payload(payload(1)).expect("first commit");
479 store.commit_payload(payload(2)).expect("second commit");
480
481 let authoritative = store.authoritative().expect("authoritative");
482 let authoritative_slot =
483 select_authoritative_slot(store.slot0.as_ref(), store.slot1.as_ref())
484 .expect("authoritative slot");
485
486 assert_eq!(authoritative.generation, 1);
487 assert_eq!(authoritative.payload, payload(2));
488 assert_eq!(authoritative_slot.index, CommitSlotIndex::Slot1);
489 assert_eq!(authoritative_slot.record.payload, payload(2));
490 }
491
492 #[test]
493 fn corrupt_newer_slot_fails_closed() {
494 let mut store = DualCommitStore::default();
495 store.commit_payload(payload(1)).expect("first commit");
496 store.write_corrupt_inactive_slot(1, payload(2));
497
498 let err = store.authoritative().expect_err("corrupt slot");
499
500 assert_eq!(
501 err,
502 CommitRecoveryError::InvalidCommitSlots {
503 slot0_invalid: false,
504 slot1_invalid: true,
505 }
506 );
507 }
508
509 #[test]
510 fn two_invalid_commit_slots_fail_closed() {
511 let mut store = DualCommitStore::default();
512 store.write_corrupt_inactive_slot(0, payload(1));
513 store.write_corrupt_inactive_slot(1, payload(2));
514
515 let err = store.authoritative().expect_err("invalid slots");
516
517 assert_eq!(
518 err,
519 CommitRecoveryError::InvalidCommitSlots {
520 slot0_invalid: true,
521 slot1_invalid: true,
522 }
523 );
524 }
525
526 #[test]
527 fn same_generation_identical_slots_recover_deterministically() {
528 let committed = CommittedGenerationBytes::new(7, payload(1));
529 let store = DualCommitStore {
530 slot0: Some(committed.clone()),
531 slot1: Some(committed),
532 };
533
534 let authoritative = store.authoritative_slot().expect("authoritative");
535
536 assert_eq!(authoritative.index, CommitSlotIndex::Slot0);
537 assert_eq!(authoritative.record.generation, 7);
538 }
539
540 #[test]
541 fn same_generation_divergent_slots_fail_closed() {
542 let store = DualCommitStore {
543 slot0: Some(CommittedGenerationBytes::new(7, payload(1))),
544 slot1: Some(CommittedGenerationBytes::new(7, payload(2))),
545 };
546
547 let err = store.authoritative().expect_err("ambiguous generation");
548
549 assert_eq!(
550 err,
551 CommitRecoveryError::AmbiguousGeneration { generation: 7 }
552 );
553 }
554
555 #[test]
556 fn physical_generation_overflow_fails_closed() {
557 let mut store = DualCommitStore {
558 slot0: Some(CommittedGenerationBytes::new(u64::MAX, payload(1))),
559 slot1: None,
560 };
561
562 let err = store
563 .commit_payload(payload(2))
564 .expect_err("overflow must fail");
565
566 assert_eq!(
567 err,
568 CommitRecoveryError::GenerationOverflow {
569 generation: u64::MAX
570 }
571 );
572 }
573
574 #[test]
575 fn diagnostic_reports_corrupt_slots_without_an_authoritative_generation() {
576 let mut store = DualCommitStore::default();
577 store.commit_payload(payload(1)).expect("first commit");
578 store.write_corrupt_inactive_slot(1, payload(2));
579
580 let diagnostic = store.diagnostic();
581
582 assert_eq!(
583 diagnostic.recovery,
584 Err(CommitRecoveryError::InvalidCommitSlots {
585 slot0_invalid: false,
586 slot1_invalid: true,
587 })
588 );
589 assert_eq!(
590 diagnostic.slot0,
591 CommitSlotDiagnostic::Valid { generation: 0 }
592 );
593 assert_eq!(
594 diagnostic.slot1,
595 CommitSlotDiagnostic::Invalid { generation: 1 }
596 );
597 let bytes = crate::test_cbor::to_vec(&diagnostic).expect("diagnostic bytes");
598 let decoded: CommitStoreDiagnostic =
599 crate::test_cbor::from_slice(&bytes).expect("diagnostic round trip");
600 assert_eq!(decoded, diagnostic);
601 }
602
603 #[test]
604 fn diagnostic_reports_no_valid_generation_for_empty_store() {
605 let diagnostic = DualCommitStore::default().diagnostic();
606
607 assert_eq!(
608 diagnostic.recovery,
609 Err(CommitRecoveryError::NoValidGeneration)
610 );
611 assert_eq!(diagnostic.slot0, CommitSlotDiagnostic::Empty);
612 assert_eq!(diagnostic.slot1, CommitSlotDiagnostic::Empty);
613 }
614
615 #[test]
616 fn uninitialized_distinguishes_empty_from_corrupt() {
617 let mut store = DualCommitStore::default();
618 assert!(store.is_uninitialized());
619
620 store.write_corrupt_inactive_slot(0, payload(1));
621
622 assert!(!store.is_uninitialized());
623 }
624
625 #[test]
626 fn commit_after_corrupt_slot_fails_closed() {
627 let mut store = DualCommitStore::default();
628 store.commit_payload(payload(1)).expect("first commit");
629 store.write_corrupt_inactive_slot(1, payload(2));
630
631 let err = store
632 .commit_payload(payload(3))
633 .expect_err("corrupt history must not be overwritten");
634
635 assert_eq!(
636 err,
637 CommitRecoveryError::InvalidCommitSlots {
638 slot0_invalid: false,
639 slot1_invalid: true,
640 }
641 );
642 }
643}