Skip to main content

ic_memory/ledger/
mod.rs

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///
19/// LedgerCommitStore
20///
21/// Generation-scoped allocation ledger commit store.
22///
23/// This type owns the logical commit lifecycle and the crate-owned CBOR ledger
24/// encoding. It deliberately does not own stable-memory IO; that remains the
25/// substrate or framework owner's responsibility.
26///
27/// The runtime commits allocation ledger generations through this passive DTO.
28/// Decoding it grants no memory access or persistence authority. It does not
29/// open stable-memory handles or allocate application slots.
30///
31
32#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(deny_unknown_fields)]
34pub struct LedgerCommitStore {
35    /// Protected current physical snapshot.
36    #[serde(deserialize_with = "crate::cbor::deserialize_present_option")]
37    pub(crate) current: Option<CommittedGenerationBytes>,
38}
39
40impl LedgerCommitStore {
41    /// Recover the authoritative allocation ledger using the native CBOR ledger codec.
42    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    /// Recover the authoritative ledger, or use logical genesis for an empty store.
77    ///
78    /// Only explicit snapshot absence admits the empty generation-zero base.
79    /// No payload is encoded or committed here. Corrupt or partially written
80    /// stores fail closed; the caller must persist its snapshot successor
81    /// before publishing allocation authority.
82    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    /// Commit one logical allocation ledger generation through the native CBOR ledger codec.
88    ///
89    /// # Panics
90    ///
91    /// Panics only if the private concrete ledger-encoding invariant is broken.
92    #[cfg(test)]
93    pub fn commit(
94        &mut self,
95        ledger: &AllocationLedger,
96    ) -> Result<RecoveredLedger, LedgerCommitError> {
97        self.commit_generation(ledger)?;
98        // The current writer encoded this integrity-checked ledger, and the
99        // physical commit checked its predecessor and established this generation.
100        // Existing persisted bytes still cross the full recovery boundary.
101        Ok(RecoveredLedger::from_trusted_ledger(ledger.clone()))
102    }
103
104    // Bootstrap already owns its staged ledger. Share the checked mutation
105    // without cloning that ledger into a proof only to unwrap it immediately.
106    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    /// Simulate corruption of the current logical ledger snapshot.
120    #[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        // CBOR integer widths may grow; the number of records and metadata do not.
155        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                // Reserved is the longest encoded lifecycle variant.
173                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}