1mod claim;
2mod codec;
3mod error;
4mod integrity;
5mod record;
6mod stage;
7
8use crate::physical::{CommitSnapshotDiagnostic, CommittedGenerationBytes};
9use serde::{Deserialize, Serialize};
10
11pub use claim::{ClaimConflict, ClaimOutcome, validate_declaration_claim};
12pub use error::{AllocationStageError, LedgerCommitError, LedgerIntegrityError};
13pub use record::{AllocationLedger, AllocationRecord, AllocationState, RecoveredLedger};
14pub use stage::stage_generation;
15
16use codec::decode_ledger;
17
18#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct LedgerCommitStore {
35 #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
37 pub(crate) current: Option<CommittedGenerationBytes>,
38}
39
40impl LedgerCommitStore {
41 pub(crate) fn recover(&self) -> Result<RecoveredLedger, LedgerCommitError> {
43 let committed = self.authoritative().map_err(LedgerCommitError::Recovery)?;
44 Self::recover_committed(committed)
45 }
46
47 pub(crate) fn recover_with_diagnostic(
48 &self,
49 ) -> (
50 Result<RecoveredLedger, LedgerCommitError>,
51 CommitSnapshotDiagnostic,
52 ) {
53 let (committed, diagnostic) = self.authoritative_with_diagnostic();
54 let recovered = committed
55 .map_err(LedgerCommitError::Recovery)
56 .and_then(Self::recover_committed);
57 (recovered, diagnostic)
58 }
59
60 fn recover_committed(
61 committed: &CommittedGenerationBytes,
62 ) -> Result<RecoveredLedger, LedgerCommitError> {
63 let ledger = decode_ledger(committed.payload()).map_err(LedgerCommitError::Codec)?;
64 if committed.generation() != ledger.current_generation {
65 return Err(LedgerCommitError::PhysicalLogicalGenerationMismatch {
66 physical_generation: committed.generation(),
67 logical_generation: ledger.current_generation,
68 });
69 }
70 ledger
71 .validate_integrity()
72 .map_err(LedgerCommitError::Integrity)?;
73 Ok(RecoveredLedger::from_trusted_ledger(ledger))
74 }
75
76 pub(crate) fn recover_or_genesis(&self) -> Result<RecoveredLedger, LedgerCommitError> {
83 self.recover()
84 .or_else(|error| RecoveredLedger::genesis_for_absent_snapshot(&error).ok_or(error))
85 }
86
87 #[cfg(test)]
93 pub fn commit(
94 &mut self,
95 ledger: &AllocationLedger,
96 ) -> Result<RecoveredLedger, LedgerCommitError> {
97 self.commit_generation(ledger)?;
98 Ok(RecoveredLedger::from_trusted_ledger(ledger.clone()))
102 }
103
104 pub(crate) fn commit_generation(
107 &mut self,
108 ledger: &AllocationLedger,
109 ) -> Result<(), LedgerCommitError> {
110 ledger
111 .validate_integrity()
112 .map_err(LedgerCommitError::Integrity)?;
113 let payload = codec::encode_ledger(ledger).map_err(LedgerCommitError::Integrity)?;
114 self.commit_payload_at_generation(ledger.current_generation, payload)
115 .map(|_| ())
116 .map_err(LedgerCommitError::Recovery)
117 }
118
119 #[cfg(test)]
121 pub fn write_corrupt_ledger(
122 &mut self,
123 ledger: &AllocationLedger,
124 ) -> Result<(), LedgerCommitError> {
125 let payload = codec::encode_ledger(ledger).map_err(LedgerCommitError::Integrity)?;
126 self.write_corrupt_snapshot(ledger.current_generation, payload);
127 Ok(())
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134 use crate::{CommitRecoveryError, test_cbor::hex_fixture};
135
136 #[test]
137 fn repeated_commits_keep_metadata_bounded() {
138 let mut store = LedgerCommitStore::default();
139 store
140 .commit(&AllocationLedger::new(65_530, Vec::new()).unwrap())
141 .unwrap();
142 let mut largest_payload = 0;
143 for version in 1_u32..=1024 {
144 let snapshot = DeclarationSnapshot::for_test(vec![declaration("app.users.v1", 100)]);
145 let recovered = store.recover().unwrap();
146 let (next, _) =
147 stage_generation(recovered, snapshot, &crate::GenericAllocationPolicy).unwrap();
148 store.commit_generation(&next).unwrap();
149 assert_eq!(next.current_generation(), 65_530 + u64::from(version));
150 let recovered = store.recover().unwrap();
151 assert_eq!(recovered.ledger().records().len(), 1);
152 largest_payload = largest_payload.max(store.authoritative().unwrap().payload().len());
153 }
154 assert!(largest_payload < 200);
156 assert!(
157 crate::stable_cell::EncodedLedgerRecord::new(&store)
158 .unwrap()
159 .len()
160 < 600
161 );
162 }
163
164 #[test]
165 fn largest_current_record_set_fits_writer_and_reader_bounds() {
166 let records = (0..255_u8)
167 .map(|id| {
168 let key = format!("app.store{id:03}.{}.v1", "x".repeat(112));
169 assert_eq!(key.len(), 128);
170 let declaration = declaration(&key, id);
171 let mut record = AllocationRecord::active(&declaration);
172 record.state = AllocationState::Reserved;
174 record
175 })
176 .collect();
177 let ledger = AllocationLedger::new(u64::MAX - 1, records).unwrap();
178 let payload = encoded_payload(&ledger);
179 assert!(payload.len() <= crate::constants::MAX_COMMITTED_PAYLOAD_BYTES);
180 let mut store = LedgerCommitStore::default();
181 store.commit(&ledger).unwrap();
182 let next = AllocationLedger::new(u64::MAX, ledger.records().to_vec()).unwrap();
183 store.commit(&next).unwrap();
184 let record = store;
185 let bytes = crate::stable_cell::EncodedLedgerRecord::new(&record)
186 .unwrap()
187 .into_bytes();
188 assert!(bytes.len() <= crate::constants::MAX_LEDGER_RECORD_BYTES);
189 let decoded = crate::decode_stable_cell_commit_store(&bytes).unwrap();
190 assert_eq!(decoded.recover().unwrap().ledger(), &next);
191 }
192
193 #[test]
194 fn validated_counter_overflow_rejects_without_commit_mutation() {
195 let mut store = LedgerCommitStore::default();
196 store
197 .commit(&AllocationLedger::new(u64::MAX, Vec::new()).unwrap())
198 .unwrap();
199 let before = store.clone();
200 let recovered = store.recover().unwrap();
201 let result = stage_generation(
202 recovered,
203 DeclarationSnapshot::for_test(Vec::new()),
204 &crate::GenericAllocationPolicy,
205 );
206 assert!(matches!(
207 result,
208 Err(crate::RuntimeBootstrapError::Staging(
209 AllocationStageError::GenerationOverflow {
210 generation: u64::MAX
211 }
212 ))
213 ));
214 assert_eq!(store, before);
215 }
216
217 #[test]
218 fn unsupported_discriminator_precedes_logical_decode_and_preserves_storage() {
219 let mut committed = CommittedGenerationBytes::new(1, vec![0xff]);
220 committed.commit_marker = u64::MAX;
221 let store = LedgerCommitStore {
222 current: Some(committed),
223 };
224 let before = store.clone();
225 let expected = LedgerCommitError::Recovery(CommitRecoveryError::UnsupportedFormat {
226 marker: u64::MAX,
227 });
228 assert_eq!(store.recover(), Err(expected.clone()));
229 assert_eq!(store.recover_or_genesis(), Err(expected));
230 assert_eq!(store, before);
231 }
232 use crate::{
233 declaration::AllocationDeclaration, key::StableKey, physical::CommittedGenerationBytes,
234 requests::DeclarationSnapshot, slot::MemoryManagerSlot,
235 };
236 use ic_stable_structures::Storable;
237 fn declaration(key: &str, id: u8) -> AllocationDeclaration {
238 AllocationDeclaration::new(key, MemoryManagerSlot::new(id).expect("usable slot"))
239 .expect("declaration")
240 }
241
242 fn ledger() -> AllocationLedger {
243 AllocationLedger {
244 current_generation: 3,
245 records: Vec::new(),
246 }
247 }
248
249 fn committed_ledger(current_generation: u64) -> AllocationLedger {
250 AllocationLedger {
251 current_generation,
252 records: Vec::new(),
253 }
254 }
255
256 fn active_record(key: &str, id: u8) -> AllocationRecord {
257 AllocationRecord::active(&declaration(key, id))
258 }
259
260 fn snapshot_from(declarations: Vec<AllocationDeclaration>) -> DeclarationSnapshot {
261 DeclarationSnapshot::for_test(declarations)
262 }
263
264 fn stage(
265 ledger: AllocationLedger,
266 snapshot: &DeclarationSnapshot,
267 ) -> Result<AllocationLedger, crate::RuntimeBootstrapError<std::convert::Infallible>> {
268 stage_generation(
269 RecoveredLedger::from_trusted_ledger(ledger),
270 snapshot_from(snapshot.declarations().to_vec()),
271 &crate::GenericAllocationPolicy,
272 )
273 .map(|(ledger, _)| ledger)
274 }
275
276 fn record<'ledger>(ledger: &'ledger AllocationLedger, key: &str) -> &'ledger AllocationRecord {
277 ledger
278 .records()
279 .iter()
280 .find(|record| record.stable_key.as_str() == key)
281 .expect("allocation record")
282 }
283
284 fn encoded_payload(ledger: &AllocationLedger) -> Vec<u8> {
285 codec::encode_ledger(ledger).expect("fixture ledger")
286 }
287
288 fn ledger_from_payload_fixture(contents: &str) -> AllocationLedger {
289 let bytes = hex_fixture(contents);
290 let ledger = decode_ledger(&bytes).expect("fixture ledger");
291 ledger
292 .validate_integrity()
293 .expect("fixture ledger integrity");
294 assert_eq!(bytes, encoded_payload(&ledger));
295 ledger
296 }
297
298 fn store_from_fixture(contents: &str) -> LedgerCommitStore {
299 let bytes = hex_fixture(contents);
300 let store: LedgerCommitStore = crate::test_cbor::from_slice(&bytes).expect("fixture store");
301 assert_eq!(
302 bytes,
303 crate::test_cbor::to_vec(&store).expect("re-encoded fixture store")
304 );
305 store
306 }
307
308 fn active_committed_ledger() -> AllocationLedger {
309 AllocationLedger {
310 current_generation: 1,
311 records: vec![active_record("app.users.v1", 100)],
312 }
313 }
314
315 fn active_ledger_value() -> crate::test_cbor::Value {
316 crate::test_cbor::to_value(active_committed_ledger()).expect("ledger value")
317 }
318
319 #[test]
320 fn recovery_rejects_unknown_fields_inside_retirement_state() {
321 use crate::LedgerCommitStore;
322
323 let retired = AllocationLedger::new(
324 2,
325 vec![crate::testing::allocation_record(
326 &declaration("app.users.v1", 100),
327 AllocationState::Retired,
328 )],
329 )
330 .unwrap();
331 let mut store = LedgerCommitStore::default();
332 let committed = store.commit(&retired).unwrap();
333 assert_eq!(committed, store.recover().unwrap());
334
335 let mut value = crate::test_cbor::to_value(&retired).unwrap();
336 let records = value_array_mut(map_field_mut(value_map_mut(&mut value), "records"));
337 let state = map_field_mut(value_map_mut(&mut records[0]), "state");
338 *state = serde_json::from_value::<crate::test_cbor::Value>(
339 serde_json::json!({"Retired": {"unexpected": true}}),
340 )
341 .unwrap();
342 let bytes = crate::test_cbor::to_vec(&value).unwrap();
343 let payload = bytes;
344 let mut physical = LedgerCommitStore::default();
345 physical.commit_payload_at_generation(2, payload).unwrap();
346 let record = physical;
347 let encoded = crate::test_cbor::to_vec(&record).unwrap();
348 let decoded = crate::decode_stable_cell_commit_store(&encoded).unwrap();
349 let before = decoded.clone();
350 let error = decoded.recover().unwrap_err();
351 assert!(matches!(error, LedgerCommitError::Codec(_)));
352
353 assert_eq!(decoded, before);
354 }
355
356 fn value_map_mut(
357 value: &mut crate::test_cbor::Value,
358 ) -> &mut Vec<(crate::test_cbor::Value, crate::test_cbor::Value)> {
359 let crate::test_cbor::Value::Map(map) = value else {
360 panic!("expected CBOR map");
361 };
362 map
363 }
364
365 fn map_field_mut<'map>(
366 map: &'map mut [(crate::test_cbor::Value, crate::test_cbor::Value)],
367 field: &str,
368 ) -> &'map mut crate::test_cbor::Value {
369 map.iter_mut()
370 .find(|(key, _)| key == &crate::test_cbor::Value::Text(field.to_string()))
371 .map(|(_, value)| value)
372 .expect("CBOR map field")
373 }
374
375 fn value_array_mut(value: &mut crate::test_cbor::Value) -> &mut Vec<crate::test_cbor::Value> {
376 let crate::test_cbor::Value::Array(values) = value else {
377 panic!("expected CBOR array");
378 };
379 values
380 }
381
382 fn decode_mutated_ledger(value: crate::test_cbor::Value) -> String {
383 let bytes = crate::test_cbor::to_vec(&value).expect("mutated ledger bytes");
384 decode_ledger(&bytes).expect_err("mutated ledger must fail closed")
385 }
386
387 #[test]
388 fn allocation_record_accessors_expose_read_only_views() {
389 let ledger = AllocationLedger::new(1, vec![active_record("app.users.v1", 100)]).unwrap();
390 assert_eq!(ledger.records().len(), 1);
391 let record = &ledger.records()[0];
392 assert_eq!(record.stable_key().as_str(), "app.users.v1");
393 assert_eq!(record.slot().id(), 100);
394 assert_eq!(record.state(), AllocationState::Active);
395 }
396
397 #[test]
398 fn ledger_cbor_round_trips_allocation_ledger() {
399 let ledger = committed_ledger(2);
400
401 let encoded = crate::test_cbor::to_vec(&ledger).expect("encode ledger");
402 let decoded = decode_ledger(&encoded).expect("decode ledger");
403
404 assert_eq!(decoded, ledger);
405 }
406
407 #[test]
408 fn ledger_cbor_rejects_trailing_bytes() {
409 let mut encoded = crate::test_cbor::to_vec(&committed_ledger(2)).expect("encode ledger");
410 encoded.push(0);
411
412 let err = decode_ledger(&encoded).expect_err("trailing bytes must fail closed");
413
414 assert!(err.contains("trailing bytes"));
415 }
416
417 #[test]
418 fn cbor_ledger_codec_rejects_unknown_top_level_fields() {
419 use crate::test_cbor::Value;
420
421 let map = vec![
422 (
423 Value::Text("current_generation".to_string()),
424 Value::Integer(0.into()),
425 ),
426 (Value::Text("records".to_string()), Value::Array(vec![])),
427 (Value::Text("future_field".to_string()), Value::Bool(true)),
428 ];
429 let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field ledger");
430
431 let err = decode_ledger(&bytes).expect_err("unknown ledger field must fail closed");
432
433 assert!(err.contains("future_field"));
434 }
435
436 #[test]
437 fn cbor_ledger_codec_rejects_unknown_nested_record_fields() {
438 let mut value = active_ledger_value();
439 let records = map_field_mut(value_map_mut(&mut value), "records");
440 let record = value_array_mut(records)
441 .first_mut()
442 .expect("allocation record");
443 value_map_mut(record).push((
444 crate::test_cbor::Value::Text("future_record_field".to_string()),
445 crate::test_cbor::Value::Bool(true),
446 ));
447
448 let err = decode_mutated_ledger(value);
449
450 assert!(err.contains("future_record_field"));
451 }
452
453 #[test]
454 fn ledger_commit_store_rejects_unknown_top_level_fields() {
455 use crate::test_cbor::Value;
456
457 let map = vec![
458 (Value::Text("current".to_string()), Value::Null),
459 (Value::Text("future_field".to_string()), Value::Bool(true)),
460 ];
461 let bytes = crate::test_cbor::to_vec(&Value::Map(map)).expect("unknown-field store");
462
463 let err = crate::test_cbor::from_slice::<LedgerCommitStore>(&bytes)
464 .expect_err("unknown store field must fail closed");
465
466 assert!(err.to_string().contains("future_field"));
467 }
468
469 #[test]
470 fn current_empty_genesis_payload_fixture_recovers() {
471 let ledger = ledger_from_payload_fixture(include_str!(
472 "../../fixtures/current/empty_genesis_ledger.cbor.hex"
473 ));
474
475 assert_eq!(ledger.current_generation, 0);
476 assert_eq!(ledger.records(), []);
477 }
478
479 #[test]
480 fn current_single_active_payload_fixture_recovers() {
481 let ledger = ledger_from_payload_fixture(include_str!(
482 "../../fixtures/current/single_active_allocation_ledger.cbor.hex"
483 ));
484 let record = record(&ledger, "app.users.v1");
485
486 assert_eq!(ledger.current_generation, 1);
487 assert_eq!(record.state(), AllocationState::Active);
488 assert_eq!(record.slot().id(), 100);
489 }
490
491 #[test]
492 fn current_reserved_payload_fixture_recovers() {
493 let ledger = ledger_from_payload_fixture(include_str!(
494 "../../fixtures/current/reserved_allocation_ledger.cbor.hex"
495 ));
496 let record = record(&ledger, "app.future_store.v1");
497
498 assert_eq!(ledger.current_generation, 1);
499 assert_eq!(record.state(), AllocationState::Reserved);
500 assert_eq!(record.slot().id(), 101);
501 }
502
503 #[test]
504 fn current_retired_payload_fixture_recovers() {
505 let ledger = ledger_from_payload_fixture(include_str!(
506 "../../fixtures/current/retired_allocation_ledger.cbor.hex"
507 ));
508 let record = record(&ledger, "app.users.v1");
509
510 assert_eq!(ledger.current_generation, 2);
511 assert_eq!(record.state(), AllocationState::Retired);
512 }
513
514 #[test]
515 fn current_memory_manager_slot_fixture_decodes() {
516 let bytes = hex_fixture(include_str!(
517 "../../fixtures/current/memory_manager_slot.cbor.hex"
518 ));
519 let slot: MemoryManagerSlot = crate::test_cbor::from_slice(&bytes).expect("slot fixture");
520
521 assert_eq!(slot.id(), 100);
522 assert_eq!(
523 bytes,
524 crate::test_cbor::to_vec(&slot).expect("re-encoded slot")
525 );
526 }
527
528 #[test]
529 fn current_ledger_commit_store_single_active_fixture_recovers() {
530 let store = store_from_fixture(include_str!(
531 "../../fixtures/current/ledger_commit_store_single_active.cbor.hex"
532 ));
533
534 let recovered = store.recover().expect("fixture store recovers");
535 assert_eq!(recovered.current_generation(), 1);
536 assert_eq!(record(recovered.ledger(), "app.users.v1").slot().id(), 100);
537 }
538
539 #[test]
540 fn current_snapshot_fixture_recovers() {
541 let store = store_from_fixture(include_str!(
542 "../../fixtures/current/snapshot_store_valid.cbor.hex"
543 ));
544
545 assert_eq!(
546 store.diagnostic(),
547 crate::CommitSnapshotDiagnostic::Valid { generation: 2 }
548 );
549 let recovered = store.recover().expect("fixture store recovers");
550 assert_eq!(recovered.current_generation(), 2);
551 }
552
553 #[test]
554 fn current_corrupt_snapshot_fixture_fails_closed() {
555 let store = store_from_fixture(include_str!(
556 "../../fixtures/current/snapshot_store_corrupt.cbor.hex"
557 ));
558
559 let diagnostic = store.diagnostic();
560 assert_eq!(
561 diagnostic,
562 crate::CommitSnapshotDiagnostic::Invalid { generation: 2 }
563 );
564 let err = store
565 .recover()
566 .expect_err("corrupt fixture must fail closed");
567 assert!(matches!(
568 err,
569 LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 2 })
570 ));
571 }
572
573 #[test]
574 fn stage_generation_records_new_allocations() {
575 let declarations = vec![declaration("app.users.v1", 100)];
576 let snapshot = snapshot_from(declarations);
577
578 let staged = stage(ledger(), &snapshot).expect("staged generation");
579
580 assert_eq!(staged.current_generation, 4);
581 assert_eq!(staged.records().len(), 1);
582 }
583
584 #[test]
585 fn stage_generation_allows_empty_generation_boundary() {
586 let snapshot = snapshot_from(Vec::new());
587
588 let staged = stage(ledger(), &snapshot).expect("empty snapshot generation");
589
590 assert_eq!(staged.current_generation, 4);
591 assert_eq!(staged.records(), []);
592 }
593
594 #[test]
595 fn stage_generation_rejects_same_key_different_slot() {
596 let mut ledger = committed_ledger(3);
597 ledger.records = vec![active_record("app.users.v1", 100)];
598 ledger.validate_integrity().unwrap();
599 let snapshot = snapshot_from(vec![declaration("app.users.v1", 101)]);
600
601 let err = stage(ledger, &snapshot).expect_err("stable key cannot move slots");
602
603 assert!(matches!(
604 err,
605 crate::RuntimeBootstrapError::Validation(
606 crate::AllocationValidationError::StableKeySlotConflict { .. }
607 )
608 ));
609 }
610
611 #[test]
612 fn stage_generation_rejects_same_slot_different_key() {
613 let mut ledger = ledger();
614 ledger.records = vec![active_record("app.users.v1", 100)];
615 let snapshot = snapshot_from(vec![declaration("app.orders.v1", 100)]);
616
617 let err = stage(ledger, &snapshot).expect_err("slot cannot be reused by another key");
618
619 assert!(matches!(
620 err,
621 crate::RuntimeBootstrapError::Validation(
622 crate::AllocationValidationError::SlotStableKeyConflict { .. }
623 )
624 ));
625 }
626
627 #[test]
628 fn stage_generation_rejects_retired_redeclaration() {
629 let mut ledger = ledger();
630 let mut record = active_record("app.users.v1", 100);
631 record.state = AllocationState::Retired;
632 ledger.records = vec![record];
633 let snapshot = snapshot_from(vec![declaration("app.users.v1", 100)]);
634
635 let err = stage(ledger, &snapshot).expect_err("retired allocation cannot be redeclared");
636
637 assert!(matches!(
638 err,
639 crate::RuntimeBootstrapError::Validation(
640 crate::AllocationValidationError::RetiredAllocation { .. }
641 )
642 ));
643 }
644
645 #[test]
646 fn stage_generation_preserves_omitted_records() {
647 let first = snapshot_from(vec![
648 declaration("app.users.v1", 100),
649 declaration("app.orders.v1", 101),
650 ]);
651 let second = snapshot_from(vec![declaration("app.users.v1", 100)]);
652
653 let staged = stage(ledger(), &first).expect("first generation");
654 let staged = stage(staged, &second).expect("second generation");
655
656 assert_eq!(staged.current_generation, 5);
657 assert_eq!(staged.records().len(), 2);
658 let omitted = staged
659 .records()
660 .iter()
661 .find(|record| record.stable_key.as_str() == "app.orders.v1")
662 .expect("omitted record");
663 assert_eq!(omitted.state, AllocationState::Active);
664 }
665
666 #[test]
667 fn stage_generation_activates_reserved_record() {
668 let staged = AllocationLedger::new(
669 4,
670 vec![crate::testing::allocation_record(
671 &declaration("app.future_store.v1", 100),
672 AllocationState::Reserved,
673 )],
674 )
675 .unwrap();
676 let active = snapshot_from(vec![declaration("app.future_store.v1", 100)]);
677
678 let staged = stage(staged, &active).expect("active generation");
679 let record = &staged.records()[0];
680
681 assert_eq!(record.state, AllocationState::Active);
682 }
683
684 #[test]
685 fn transition_matrix_preserves_committed_invariants() {
686 fn visit(ledger: AllocationLedger, depth: usize) {
687 ledger.validate_integrity().unwrap();
688 if depth == 0 {
689 return;
690 }
691 for declarations in [
692 vec![],
693 vec![declaration("app.users.v1", 100)],
694 vec![declaration("app.users.v1", 100)],
695 vec![declaration("app.orders.v1", 101)],
696 vec![declaration("app.other.v1", 100)],
697 ] {
698 let next = stage(ledger.clone(), &snapshot_from(declarations))
699 .unwrap_or_else(|_| ledger.clone());
700 visit(next, depth - 1);
701 }
702 }
703 visit(AllocationLedger::empty_genesis(), 4);
704 for state in [
705 AllocationState::Reserved,
706 AllocationState::Active,
707 AllocationState::Retired,
708 ] {
709 let record =
710 crate::testing::allocation_record(&declaration("app.users.v1", 100), state);
711 visit(AllocationLedger::new(1, vec![record]).unwrap(), 4);
712 }
713 }
714
715 #[test]
716 fn validate_integrity_rejects_duplicate_stable_keys() {
717 let mut ledger = ledger();
718 for second_id in [101, 100] {
719 ledger.records = vec![
720 active_record("app.users.v1", 100),
721 active_record("app.users.v1", second_id),
722 ];
723
724 let err = ledger
725 .validate_integrity()
726 .expect_err("duplicate key precedes duplicate slot");
727
728 assert_eq!(
729 err,
730 LedgerIntegrityError::DuplicateStableKey {
731 stable_key: StableKey::parse("app.users.v1").unwrap(),
732 }
733 );
734 }
735 }
736
737 #[test]
738 fn validate_integrity_rejects_duplicate_slots() {
739 let mut ledger = ledger();
740 ledger.records = vec![
741 active_record("app.users.v1", 100),
742 active_record("app.orders.v1", 100),
743 ];
744
745 let err = ledger.validate_integrity().expect_err("duplicate slot");
746
747 assert_eq!(
748 err,
749 LedgerIntegrityError::DuplicateSlot {
750 slot: MemoryManagerSlot::new(100).unwrap(),
751 }
752 );
753 }
754
755 #[test]
756 fn recovery_rejects_invalid_stable_key_during_ledger_decode() {
757 let mut ledger = committed_ledger(1);
758 ledger.records.push(active_record("app.users.v1", 100));
759 let mut bytes = crate::test_cbor::to_vec(&ledger).expect("encode ledger");
760 let key_start = bytes
761 .windows(b"app.users.v1".len())
762 .position(|window| window == b"app.users.v1")
763 .expect("encoded stable key");
764 bytes[key_start] = b'A';
765 assert!(crate::test_cbor::from_slice::<AllocationLedger>(&bytes).is_err());
766 let payload = bytes;
767 let mut physical = LedgerCommitStore::default();
768 physical.commit_payload_at_generation(1, payload).unwrap();
769 let store = physical;
770 let before = store.clone();
771 assert!(matches!(store.recover(), Err(LedgerCommitError::Codec(_))));
772 assert_eq!(store, before, "malformed-key recovery must preserve slots");
773 }
774
775 #[test]
776 fn ledger_decode_rejects_unusable_memory_manager_slot() {
777 let mut ledger = committed_ledger(1);
778 ledger.records.push(active_record("app.users.v1", 100));
779 let mut value = serde_json::to_value(ledger).unwrap();
780 value["records"][0]["slot"] = serde_json::json!(255);
781 let bytes = crate::test_cbor::to_vec(&value).unwrap();
782 assert!(decode_ledger(&bytes).is_err());
783 }
784
785 #[test]
786 fn integrity_rejects_nonempty_genesis_before_commit() {
787 let invalid = AllocationLedger {
788 current_generation: 0,
789 records: vec![active_record("app.users.v1", 100)],
790 };
791 assert_eq!(
792 invalid.validate_integrity(),
793 Err(LedgerIntegrityError::NonemptyGenesis)
794 );
795 let mut store = LedgerCommitStore::default();
796 let before = store.clone();
797 assert_eq!(
798 store.commit(&invalid),
799 Err(LedgerCommitError::Integrity(
800 LedgerIntegrityError::NonemptyGenesis
801 ))
802 );
803 assert_eq!(store, before);
804 }
805
806 #[test]
807 fn ledger_commit_store_rejects_invalid_ledger_before_write() {
808 let mut store = LedgerCommitStore::default();
809 let mut invalid = ledger();
810 invalid.records = vec![
811 active_record("app.users.v1", 100),
812 active_record("app.orders.v1", 100),
813 ];
814
815 let err = store.commit(&invalid).expect_err("invalid ledger");
816
817 assert!(matches!(
818 err,
819 LedgerCommitError::Integrity(LedgerIntegrityError::DuplicateSlot { .. })
820 ));
821 assert!(store.current().is_none());
822 }
823
824 #[test]
825 fn ledger_commit_store_recovers_latest_committed_ledger() {
826 let mut store = LedgerCommitStore::default();
827 let first = committed_ledger(1);
828 let second = committed_ledger(2);
829
830 store.commit(&first).expect("first commit");
831 store.commit(&second).expect("second commit");
832 let recovered = store.recover().expect("recovered ledger");
833
834 assert_eq!(recovered.current_generation(), 2);
835 }
836
837 #[test]
838 fn ledger_commit_store_encodes_direct_logical_cbor() {
839 let mut store = LedgerCommitStore::default();
840 let ledger = committed_ledger(1);
841
842 store.commit(&ledger).expect("commit");
843
844 let committed = store.authoritative().expect("authoritative");
845 assert_eq!(
846 committed.payload(),
847 crate::test_cbor::to_vec(&ledger).unwrap(),
848 "direct commit encoding matches logical CBOR"
849 );
850 }
851
852 #[test]
853 fn ledger_commit_store_rejects_malformed_logical_cbor() {
854 let committed = CommittedGenerationBytes::new(1, vec![b'X'; 28]);
855 let store = LedgerCommitStore {
856 current: Some(committed),
857 };
858
859 let err = store.recover().expect_err("bad logical CBOR");
860
861 assert!(matches!(err, LedgerCommitError::Codec(_)));
862 }
863
864 #[test]
865 fn recovery_and_initialization_preserve_physical_diagnostics_and_logical_failures() {
866 let genesis = encoded_payload(&committed_ledger(0));
867 let committed = CommittedGenerationBytes::new(0, genesis.clone());
868 let mut corrupt = committed.clone();
869 corrupt.checksum ^= 1;
870 let mut unsupported = committed.clone();
871 unsupported.commit_marker = u64::MAX;
872 let cases = [
873 ("empty", None),
874 ("current", Some(committed)),
875 ("corrupt", Some(corrupt)),
876 ("unsupported format", Some(unsupported)),
877 (
878 "invalid CBOR",
879 Some(CommittedGenerationBytes::new(0, vec![255])),
880 ),
881 (
882 "generation mismatch",
883 Some(CommittedGenerationBytes::new(1, genesis)),
884 ),
885 (
886 "invalid committed integrity",
887 Some(CommittedGenerationBytes::new(3, encoded_payload(&ledger()))),
888 ),
889 ];
890
891 for (label, current) in cases {
892 let empty = current.is_none();
893 let store = LedgerCommitStore { current };
894 let before = store.clone();
895 let (recovered, diagnostic) = store.recover_with_diagnostic();
896 assert_eq!(recovered, store.recover(), "{label}");
897 assert_eq!(diagnostic, store.diagnostic(), "{label}");
898 assert_eq!(store, before, "{label}");
899
900 let initializing = store.clone();
901 let initialized = initializing.recover_or_genesis();
902 if empty {
903 let initialized = initialized.expect("empty store accepts genesis");
904 assert_eq!(initialized.current_generation(), 0);
905 assert_eq!(initialized.ledger().records(), []);
906 } else {
907 assert_eq!(initialized, recovered, "{label}");
908 }
909 assert_eq!(initializing, before, "{label}");
910
911 if let Err(LedgerCommitError::Recovery(error)) = recovered {
912 let expected = match error {
913 CommitRecoveryError::NoValidGeneration => {
914 crate::CommitSnapshotDiagnostic::Empty
915 }
916 CommitRecoveryError::InvalidSnapshot { generation } => {
917 crate::CommitSnapshotDiagnostic::Invalid { generation }
918 }
919 CommitRecoveryError::UnsupportedFormat { .. } => {
920 crate::CommitSnapshotDiagnostic::Invalid {
921 generation: before.current().unwrap().generation(),
922 }
923 }
924 _ => panic!("unexpected physical recovery failure: {error}"),
925 };
926 assert_eq!(diagnostic, expected, "{label}");
927 } else {
928 assert!(
929 matches!(diagnostic, crate::CommitSnapshotDiagnostic::Valid { .. }),
930 "{label}"
931 );
932 }
933 }
934 }
935
936 #[test]
937 fn ledger_commit_store_recovers_current_format_genesis_and_first_real_commit() {
938 let mut store = LedgerCommitStore::default();
939 let recovered = store
940 .recover_or_genesis()
941 .expect("current-format genesis ledger");
942 assert_eq!(recovered.current_generation(), 0);
943
944 assert!(store.current().is_none());
945 assert!(matches!(
946 store.recover(),
947 Err(LedgerCommitError::Recovery(
948 CommitRecoveryError::NoValidGeneration
949 ))
950 ));
951
952 let first = stage(
953 recovered.ledger().clone(),
954 &snapshot_from(vec![declaration("app.users.v1", 100)]),
955 )
956 .expect("first real generation");
957 let recovered = store.commit(&first).expect("first commit");
958
959 assert_eq!(recovered, store.recover().unwrap());
960 assert_eq!(recovered.current_generation(), 1);
961 }
962
963 #[test]
964 fn ledger_commit_store_rejects_corrupt_current_snapshot() {
965 let mut store = LedgerCommitStore::default();
966 let genesis = committed_ledger(0);
967 store.commit(&genesis).expect("genesis commit");
968 let first = stage(
969 genesis,
970 &snapshot_from(vec![declaration("app.users.v1", 100)]),
971 )
972 .expect("first generation");
973 let first = store.commit(&first).expect("first commit");
974 let second = stage(
975 first.ledger().clone(),
976 &snapshot_from(vec![declaration("app.users.v1", 100)]),
977 )
978 .expect("second generation");
979
980 store.commit(&second).expect("second commit");
981 store.current.as_mut().expect("latest slot").checksum ^= 1;
982
983 let err = store
984 .recover()
985 .expect_err("corrupt latest must not roll back");
986
987 assert_eq!(
988 err,
989 LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 2 })
990 );
991 }
992
993 #[test]
994 fn ledger_commit_store_rejects_corrupt_current_ledger() {
995 let mut store = LedgerCommitStore::default();
996 let first = committed_ledger(1);
997 let second = committed_ledger(2);
998
999 store.commit(&first).expect("first commit");
1000 store.write_corrupt_ledger(&second).expect("corrupt write");
1001 let err = store.recover().expect_err("corrupt slot");
1002
1003 assert_eq!(
1004 err,
1005 LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 2 })
1006 );
1007 }
1008
1009 #[test]
1010 fn ledger_commit_store_rejects_physical_logical_generation_mismatch() {
1011 let store = LedgerCommitStore {
1012 current: Some(CommittedGenerationBytes::new(
1013 7,
1014 encoded_payload(&committed_ledger(6)),
1015 )),
1016 };
1017 let err = store.recover().expect_err("mismatch");
1018
1019 assert_eq!(
1020 err,
1021 LedgerCommitError::PhysicalLogicalGenerationMismatch {
1022 physical_generation: 7,
1023 logical_generation: 6
1024 }
1025 );
1026 }
1027
1028 #[test]
1029 fn ledger_commit_store_rejects_non_next_logical_generation() {
1030 let mut store = LedgerCommitStore::default();
1031 store.commit(&committed_ledger(1)).expect("first commit");
1032
1033 let err = store
1034 .commit(&committed_ledger(3))
1035 .expect_err("skipped generation");
1036
1037 assert_eq!(
1038 err,
1039 LedgerCommitError::Recovery(CommitRecoveryError::UnexpectedGeneration {
1040 expected: 2,
1041 actual: 3
1042 })
1043 );
1044 }
1045
1046 #[test]
1047 fn ledger_commit_store_rejects_corrupt_store_even_with_genesis() {
1048 let mut store = LedgerCommitStore::default();
1049 store
1050 .write_corrupt_ledger(&ledger())
1051 .expect("corrupt write");
1052
1053 let err = store.recover_or_genesis().expect_err("corrupt state");
1054
1055 assert!(matches!(
1056 err,
1057 LedgerCommitError::Recovery(CommitRecoveryError::InvalidSnapshot { generation: 3 })
1058 ));
1059 }
1060
1061 #[test]
1062 fn record_count_boundary_and_malicious_count_reject_on_recovery() {
1063 let mut ledger = committed_ledger(1);
1064 ledger.records = (0..255_u8)
1065 .map(|id| active_record(&format!("app.slot{id}.v1"), id))
1066 .collect();
1067 let mut store = LedgerCommitStore::default();
1068 store.commit(&ledger).unwrap();
1069 assert_eq!(store.recover().unwrap().ledger().records().len(), 255);
1070 ledger.records.push(active_record("app.extra.v1", 254));
1071 assert!(matches!(
1072 store.commit(&ledger),
1073 Err(LedgerCommitError::Integrity(
1074 LedgerIntegrityError::LimitExceeded { .. }
1075 ))
1076 ));
1077 let payload = encoded_payload(&ledger);
1078 let invalid = LedgerCommitStore {
1079 current: Some(CommittedGenerationBytes::new(1, payload)),
1080 };
1081 assert!(invalid.recover().is_err());
1082 assert!(invalid.recover_or_genesis().is_err());
1083 for bytes in [
1084 vec![0x9b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
1085 vec![0x81; 34],
1086 vec![0xa1],
1087 ] {
1088 let payload = bytes;
1089 let invalid = LedgerCommitStore {
1090 current: Some(CommittedGenerationBytes::new(1, payload)),
1091 };
1092 assert!(matches!(
1093 invalid.recover(),
1094 Err(LedgerCommitError::Codec(_))
1095 ));
1096 }
1097 }
1098
1099 #[test]
1100 fn oversized_logical_text_remains_opaque_to_record_decode_and_fails_closed() {
1101 for len in [257, 8192] {
1102 let mut malformed = serde_json::to_value(active_committed_ledger()).unwrap();
1103 malformed["records"][0]["stable_key"] = "x".repeat(len).into();
1104 let payload = crate::test_cbor::to_vec(&malformed).unwrap();
1105 let mut store = LedgerCommitStore::default();
1106 store.commit_payload_at_generation(1, payload).unwrap();
1107 let record = store;
1108 let bytes = crate::stable_cell::EncodedLedgerRecord::new(&record)
1109 .unwrap()
1110 .into_bytes();
1111 let decoded = crate::decode_stable_cell_commit_store(&bytes).unwrap();
1112 assert_eq!(decoded, record);
1113 let before = decoded.clone();
1114 assert!(matches!(
1115 decoded.recover(),
1116 Err(LedgerCommitError::Codec(_))
1117 ));
1118 assert!(matches!(
1119 decoded.recover_or_genesis(),
1120 Err(LedgerCommitError::Codec(_))
1121 ));
1122 assert_eq!(decoded, before);
1123 }
1124 }
1125}