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